From 88e2084f8b9846198610407412a529979dc8f3d4 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 20 Feb 2026 02:45:00 +0500 Subject: [PATCH] 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. --- .../com/rosetta/messenger/MainActivity.kt | 5 + .../biometric/BiometricAuthManager.kt | 134 +++++++--- .../biometric/BiometricPreferences.kt | 33 ++- .../rosetta/messenger/crypto/MessageCrypto.kt | 50 ++-- .../messenger/data/MessageRepository.kt | 30 ++- .../messenger/database/MessageEntities.kt | 18 +- .../com/rosetta/messenger/network/Protocol.kt | 33 ++- .../messenger/network/ProtocolManager.kt | 41 +++- .../com/rosetta/messenger/ui/auth/AuthFlow.kt | 13 + .../messenger/ui/auth/DeviceConfirmScreen.kt | 17 +- .../messenger/ui/auth/SetPasswordScreen.kt | 3 +- .../rosetta/messenger/ui/auth/UnlockScreen.kt | 11 +- .../messenger/ui/chats/ChatDetailScreen.kt | 71 +++++- .../messenger/ui/chats/ChatViewModel.kt | 131 ++++++---- .../messenger/ui/chats/ChatsListScreen.kt | 111 +++++++-- .../chats/components/AttachmentComponents.kt | 232 ++++++++++++------ .../chats/components/ChatDetailComponents.kt | 68 +---- .../ui/chats/components/ImageViewerScreen.kt | 16 +- .../ui/chats/components/InAppCameraScreen.kt | 27 ++ .../components/MediaPickerBottomSheet.kt | 51 ++-- .../ui/chats/input/ChatDetailInput.kt | 19 +- .../ui/components/AppleEmojiEditText.kt | 32 ++- .../messenger/ui/settings/AppearanceScreen.kt | 50 +++- .../ui/settings/OtherProfileScreen.kt | 108 ++++---- .../messenger/ui/settings/ProfileScreen.kt | 56 ++--- .../com/rosetta/messenger/utils/MediaUtils.kt | 47 +++- 26 files changed, 943 insertions(+), 464 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index a5e6497..ed79be2 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -366,6 +366,8 @@ class MainActivity : FragmentActivity() { super.onResume() // 🔥 Приложение стало видимым - отключаем уведомления com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true + // ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff. + ProtocolManager.reconnectNowIfNeeded("activity_onResume") } override fun onPause() { @@ -624,6 +626,9 @@ fun MainScreen( // Navigation helpers fun pushScreen(screen: Screen) { + // Anti-spam: do not stack duplicate screens from rapid taps. + if (navStack.lastOrNull() == screen) return + if (screen is Screen.Requests && navStack.any { it is Screen.Requests }) return navStack = navStack + screen } fun popScreen() { diff --git a/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt b/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt index ae6e9b0..a5f5ded 100644 --- a/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt +++ b/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt @@ -133,7 +133,7 @@ class BiometricAuthManager(private val context: Context) { removeBiometricData() onError("Биометрические данные изменились. Пожалуйста, настройте заново.") } catch (e: Exception) { - onError("Ошибка инициализации: ${e.message}") + onError("Ошибка инициализации: ${formatInitializationError(e)}") } } @@ -195,7 +195,7 @@ class BiometricAuthManager(private val context: Context) { removeBiometricData() onError("Биометрические данные изменились. Пожалуйста, войдите с паролем и настройте биометрию заново.") } catch (e: Exception) { - onError("Ошибка инициализации: ${e.message}") + onError("Ошибка инициализации: ${formatInitializationError(e)}") } } @@ -264,13 +264,68 @@ class BiometricAuthManager(private val context: Context) { private fun getOrCreateSecretKey(): SecretKey { // Проверяем, есть ли уже ключ getSecretKey()?.let { return it } - - // Генерируем новый ключ в Keystore - val keyGenerator = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, - KEYSTORE_PROVIDER + + val attempts = buildKeyGenerationAttempts() + var lastError: Exception? = null + + for ((index, config) in attempts.withIndex()) { + try { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + KEYSTORE_PROVIDER + ) + val spec = buildKeyGenSpec(config) + keyGenerator.init(spec) + return keyGenerator.generateKey() + } catch (e: Exception) { + lastError = e + Log.w( + TAG, + "Key generation attempt ${index + 1}/${attempts.size} failed " + + "(strongBox=${config.useStrongBox}, attestation=${config.useAttestation}, " + + "unlockedRequired=${config.requireUnlockedDevice})", + e + ) + removeKeyAliasSilently() + } + } + + throw IllegalStateException("Failed to generate key", lastError) + } + + private fun buildKeyGenerationAttempts(): List { + val attempts = mutableListOf() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + attempts += KeyGenerationConfig( + useStrongBox = true, + useAttestation = true, + requireUnlockedDevice = true + ) + } + + attempts += KeyGenerationConfig( + useStrongBox = false, + useAttestation = true, + requireUnlockedDevice = true ) - + + attempts += KeyGenerationConfig( + useStrongBox = false, + useAttestation = false, + requireUnlockedDevice = true + ) + + attempts += KeyGenerationConfig( + useStrongBox = false, + useAttestation = false, + requireUnlockedDevice = false + ) + + return attempts + } + + private fun buildKeyGenSpec(config: KeyGenerationConfig): KeyGenParameterSpec { val builder = KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT @@ -278,42 +333,49 @@ class BiometricAuthManager(private val context: Context) { .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) - // Ключ доступен только после биометрической аутентификации .setUserAuthenticationRequired(true) - // Ключ инвалидируется при добавлении новой биометрии .setInvalidatedByBiometricEnrollment(true) - - // Ключ доступен только когда устройство разблокировано - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + + if (config.requireUnlockedDevice && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { builder.setUnlockedDeviceRequired(true) } - - // Для Android 11+ устанавливаем параметры аутентификации + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { builder.setUserAuthenticationParameters( - 0, // timeout = 0 означает требование аутентификации для каждой операции + 0, KeyProperties.AUTH_BIOMETRIC_STRONG ) } - - // Включаем Key Attestation для проверки TEE/StrongBox - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - try { - builder.setAttestationChallenge(generateAttestationChallenge()) - } catch (e: Exception) { - } + + if (config.useAttestation && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + builder.setAttestationChallenge(generateAttestationChallenge()) } - - // Используем StrongBox если доступен (аппаратный модуль безопасности) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - builder.setIsStrongBoxBacked(true) - } catch (e: Exception) { - } + + if (config.useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + return builder.build() + } + + private fun removeKeyAliasSilently() { + try { + if (keyStore.containsAlias(KEY_ALIAS)) { + keyStore.deleteEntry(KEY_ALIAS) + } + } catch (_: Exception) { + } + } + + private fun formatInitializationError(error: Throwable): String { + val rootCause = generateSequence(error) { it.cause }.lastOrNull() ?: error + val message = rootCause.message ?: error.message ?: "Unknown error" + + return if (message.contains("Failed to generate key", ignoreCase = true)) { + "Не удалось создать ключ. Проверьте, что на устройстве включена блокировка экрана и настроена биометрия." + } else { + message } - - keyGenerator.init(builder.build()) - return keyGenerator.generateKey() } /** @@ -483,6 +545,12 @@ class BiometricAuthManager(private val context: Context) { } } +private data class KeyGenerationConfig( + val useStrongBox: Boolean, + val useAttestation: Boolean, + val requireUnlockedDevice: Boolean +) + /** * Результат проверки Key Attestation */ diff --git a/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt index c863e72..95ff1f5 100644 --- a/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt +++ b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt @@ -27,9 +27,13 @@ class BiometricPreferences(private val context: Context) { private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs" private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_" + // Shared between all BiometricPreferences instances so UI in different screens + // receives updates immediately (ProfileScreen <-> BiometricEnableScreen). + private val biometricEnabledState = MutableStateFlow(false) } - private val _isBiometricEnabled = MutableStateFlow(false) + private val appContext = context.applicationContext + private val _isBiometricEnabled = biometricEnabledState private val encryptedPrefs: SharedPreferences by lazy { createEncryptedPreferences() @@ -49,13 +53,13 @@ class BiometricPreferences(private val context: Context) { private fun createEncryptedPreferences(): SharedPreferences { try { // Создаем MasterKey с максимальной защитой - val masterKey = MasterKey.Builder(context) + val masterKey = MasterKey.Builder(appContext) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager .build() return EncryptedSharedPreferences.create( - context, + appContext, PREFS_FILE_NAME, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, @@ -63,7 +67,7 @@ class BiometricPreferences(private val context: Context) { ) } catch (e: Exception) { // Fallback на обычные SharedPreferences в случае ошибки (не должно произойти) - return context.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE) + return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE) } } @@ -76,16 +80,22 @@ class BiometricPreferences(private val context: Context) { * Включить биометрическую аутентификацию */ suspend fun enableBiometric() = withContext(Dispatchers.IO) { - encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).apply() - _isBiometricEnabled.value = true + val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit() + if (!success) { + Log.w(TAG, "Failed to persist biometric enabled state") + } + _isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false) } /** * Отключить биометрическую аутентификацию */ suspend fun disableBiometric() = withContext(Dispatchers.IO) { - encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).apply() - _isBiometricEnabled.value = false + val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit() + if (!success) { + Log.w(TAG, "Failed to persist biometric disabled state") + } + _isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false) } /** @@ -117,8 +127,11 @@ class BiometricPreferences(private val context: Context) { * Удалить все биометрические данные */ suspend fun clearAll() = withContext(Dispatchers.IO) { - encryptedPrefs.edit().clear().apply() - _isBiometricEnabled.value = false + val success = encryptedPrefs.edit().clear().commit() + if (!success) { + Log.w(TAG, "Failed to clear biometric preferences") + } + _isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false) } /** diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 8069eb3..2135d24 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -1020,17 +1020,9 @@ object MessageCrypto { val keySpec = SecretKeySpec(keyBytes, "AES") val ivSpec = IvParameterSpec(iv) cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) - val decompressed = cipher.doFinal(ciphertext) + val compressedBytes = cipher.doFinal(ciphertext) - // Decompress with inflate - val inflater = java.util.zip.Inflater() - inflater.setInput(decompressed) - val outputBuffer = ByteArray(decompressed.size * 10) - val outputSize = inflater.inflate(outputBuffer) - inflater.end() - val plaintext = String(outputBuffer, 0, outputSize, Charsets.UTF_8) - - plaintext + inflateToUtf8(compressedBytes) } catch (e: Exception) { // Fallback: пробуем SHA1 для обратной совместимости со старыми сообщениями try { @@ -1052,20 +1044,44 @@ object MessageCrypto { val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytesSha1, "AES"), IvParameterSpec(iv)) - val decompressed = cipher.doFinal(ciphertext) + val compressedBytes = cipher.doFinal(ciphertext) - val inflater = java.util.zip.Inflater() - inflater.setInput(decompressed) - val outputBuffer = ByteArray(decompressed.size * 10) - val outputSize = inflater.inflate(outputBuffer) - inflater.end() - String(outputBuffer, 0, outputSize, Charsets.UTF_8) + inflateToUtf8(compressedBytes) } catch (e2: Exception) { // Return as-is, might be plain JSON encryptedBlob } } } + + private fun inflateToUtf8(compressedBytes: ByteArray): String { + val inflater = java.util.zip.Inflater() + return try { + inflater.setInput(compressedBytes) + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count > 0) { + output.write(buffer, 0, count) + continue + } + if (inflater.needsInput()) { + break + } + if (inflater.needsDictionary()) { + throw java.util.zip.DataFormatException("Inflater requires dictionary") + } + throw java.util.zip.DataFormatException("Inflater stalled") + } + if (!inflater.finished()) { + throw java.util.zip.DataFormatException("Inflater did not finish") + } + String(output.toByteArray(), Charsets.UTF_8) + } finally { + inflater.end() + } + } } // Extension functions для конвертации diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 35cdb86..1e9b060 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -863,7 +863,7 @@ class MessageRepository private constructor(private val context: Context) { currentList[existingIndex] = message } else { currentList.add(message) - currentList.sortBy { it.timestamp } + currentList.sortWith(compareBy({ it.timestamp }, { it.messageId })) } flow.value = currentList } @@ -1018,12 +1018,13 @@ class MessageRepository private constructor(private val context: Context) { val decryptedText = if (privateKey != null && plainMessage.isNotEmpty()) { try { - CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage + CryptoManager.decryptWithPassword(plainMessage, privateKey) + ?: safePlainMessageFallback(plainMessage) } catch (e: Exception) { - plainMessage // Fallback на зашифрованный текст если расшифровка не удалась + safePlainMessageFallback(plainMessage) } } else { - plainMessage + safePlainMessageFallback(plainMessage) } return Message( @@ -1040,6 +1041,27 @@ class MessageRepository private constructor(private val context: Context) { ) } + /** + * Не показываем пользователю шифротекст (`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 == '=' + } + } + } + private fun DialogEntity.toDialog() = Dialog( opponentKey = opponentKey, diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 20273ed..79fc441 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -94,7 +94,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey - ORDER BY timestamp DESC + ORDER BY timestamp DESC, message_id DESC LIMIT :limit OFFSET :offset """ ) @@ -116,7 +116,7 @@ interface MessageDao { WHERE account = :account AND from_public_key = :account AND to_public_key = :account - ORDER BY timestamp DESC + ORDER BY timestamp DESC, message_id DESC LIMIT :limit OFFSET :offset """ ) @@ -142,7 +142,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey - ORDER BY timestamp ASC + ORDER BY timestamp ASC, message_id ASC """ ) fun getMessagesFlow(account: String, dialogKey: String): Flow> @@ -175,7 +175,7 @@ interface MessageDao { """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey - ORDER BY timestamp DESC + ORDER BY timestamp DESC, message_id DESC LIMIT :limit """ ) @@ -234,7 +234,7 @@ interface MessageDao { AND dialog_key = :dialogKey AND from_public_key = :fromPublicKey AND timestamp BETWEEN :timestampFrom AND :timestampTo - ORDER BY timestamp ASC + ORDER BY timestamp ASC, message_id ASC LIMIT 1 """ ) @@ -316,7 +316,7 @@ interface MessageDao { WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) OR (from_public_key = :account AND to_public_key = :opponent)) - ORDER BY timestamp DESC, id DESC LIMIT 1 + ORDER BY timestamp DESC, message_id DESC LIMIT 1 """ ) suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity? @@ -331,7 +331,7 @@ interface MessageDao { WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) OR (from_public_key = :account AND to_public_key = :opponent)) - ORDER BY timestamp DESC, id DESC LIMIT 1 + ORDER BY timestamp DESC, message_id DESC LIMIT 1 """ ) suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus? @@ -346,7 +346,7 @@ interface MessageDao { WHERE account = :account AND ((from_public_key = :opponent AND to_public_key = :account) OR (from_public_key = :account AND to_public_key = :opponent)) - ORDER BY timestamp DESC, id DESC LIMIT 1 + ORDER BY timestamp DESC, message_id DESC LIMIT 1 """ ) suspend fun getLastMessageAttachments(account: String, opponent: String): String? @@ -535,7 +535,7 @@ interface DialogDao { """ SELECT * FROM messages WHERE account = :account AND dialog_key = :dialogKey - ORDER BY timestamp DESC, id DESC LIMIT 1 + ORDER BY timestamp DESC, message_id DESC LIMIT 1 """ ) suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity? diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 5471a70..5e1322d 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -591,7 +591,38 @@ class Protocol( * Check if connected and authenticated */ fun isAuthenticated(): Boolean = _state.value == ProtocolState.AUTHENTICATED - + + /** + * Foreground fast reconnect: + * on app resume we should not wait scheduled exponential backoff. + */ + fun reconnectNowIfNeeded(reason: String = "foreground") { + val currentState = _state.value + val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank() + + log( + "⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason" + ) + + if (!hasCredentials) return + + if ( + currentState == ProtocolState.AUTHENTICATED || + currentState == ProtocolState.HANDSHAKING || + currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED || + currentState == ProtocolState.CONNECTED || + (currentState == ProtocolState.CONNECTING && isConnecting) + ) { + return + } + + // Reset backoff and connect immediately. + reconnectAttempts = 0 + reconnectJob?.cancel() + reconnectJob = null + connect() + } + /** * Check if connected (may not be authenticated yet) */ diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 6676cd6..56da17d 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.security.SecureRandom import java.text.SimpleDateFormat import java.util.* @@ -66,8 +68,18 @@ object ProtocolManager { private var uiLogsEnabled = false private var lastProtocolState: ProtocolState? = null @Volatile private var syncBatchInProgress = false + private val _syncInProgress = MutableStateFlow(false) + val syncInProgress: StateFlow = _syncInProgress.asStateFlow() @Volatile private var resyncRequiredAfterAccountInit = false private val inboundPacketTasks = AtomicInteger(0) + private val inboundPacketMutex = Mutex() + + private fun setSyncInProgress(value: Boolean) { + syncBatchInProgress = value + if (_syncInProgress.value != value) { + _syncInProgress.value = value + } + } fun addLog(message: String) { val timestamp = dateFormat.format(Date()) @@ -107,6 +119,9 @@ object ProtocolManager { if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) { onAuthenticated() } + if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) { + setSyncInProgress(false) + } lastProtocolState = newState } } @@ -117,7 +132,7 @@ object ProtocolManager { * Должен вызываться после авторизации пользователя */ fun initializeAccount(publicKey: String, privateKey: String) { - syncBatchInProgress = false + setSyncInProgress(false) messageRepository?.initialize(publicKey, privateKey) if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { resyncRequiredAfterAccountInit = false @@ -309,7 +324,10 @@ object ProtocolManager { inboundPacketTasks.incrementAndGet() scope.launch { try { - block() + // Preserve packet handling order to avoid read/message races during sync. + inboundPacketMutex.withLock { + block() + } } finally { inboundPacketTasks.decrementAndGet() } @@ -331,6 +349,7 @@ object ProtocolManager { } private fun onAuthenticated() { + setSyncInProgress(false) TransportManager.requestTransportServer() fetchOwnProfile() requestSynchronize() @@ -378,17 +397,16 @@ object ProtocolManager { scope.launch { when (packet.status) { SyncStatus.BATCH_START -> { - syncBatchInProgress = true + setSyncInProgress(true) } SyncStatus.BATCH_END -> { - syncBatchInProgress = true + setSyncInProgress(true) waitInboundPacketTasks() messageRepository?.updateLastSyncTimestamp(packet.timestamp) - syncBatchInProgress = false sendSynchronize(packet.timestamp) } SyncStatus.NOT_NEEDED -> { - syncBatchInProgress = false + setSyncInProgress(false) messageRepository?.updateLastSyncTimestamp(packet.timestamp) } } @@ -423,6 +441,13 @@ object ProtocolManager { fun connect() { getProtocol().connect() } + + /** + * Trigger immediate reconnect on app foreground (skip waiting backoff timer). + */ + fun reconnectNowIfNeeded(reason: String = "foreground_resume") { + getProtocol().reconnectNowIfNeeded(reason) + } /** * Authenticate with server @@ -647,7 +672,7 @@ object ProtocolManager { protocol?.clearCredentials() _devices.value = emptyList() _pendingDeviceVerification.value = null - syncBatchInProgress = false + setSyncInProgress(false) inboundPacketTasks.set(0) } @@ -659,7 +684,7 @@ object ProtocolManager { protocol = null _devices.value = emptyList() _pendingDeviceVerification.value = null - syncBatchInProgress = false + setSyncInProgress(false) inboundPacketTasks.set(0) scope.cancel() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index 47d91cd..d151d4e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -4,6 +4,8 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.AccountManager @@ -26,6 +28,17 @@ fun AuthFlow( onAuthComplete: (DecryptedAccount?) -> Unit, onLogout: () -> Unit = {} ) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? android.app.Activity)?.window ?: return@SideEffect + val insetsController = WindowCompat.getInsetsController(window, view) + window.statusBarColor = android.graphics.Color.TRANSPARENT + // Auth flow should always use light (white) status bar icons. + insetsController.isAppearanceLightStatusBars = false + } + } + var currentScreen by remember { mutableStateOf( when { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt index 09b3702..d82920a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt @@ -35,11 +35,13 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -64,6 +66,16 @@ fun DeviceConfirmScreen( isDarkTheme: Boolean, onExit: () -> Unit ) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? android.app.Activity)?.window ?: return@SideEffect + val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) + window.statusBarColor = android.graphics.Color.TRANSPARENT + insetsController.isAppearanceLightStatusBars = false + } + } + val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) @@ -145,10 +157,7 @@ fun DeviceConfirmScreen( Box( modifier = - Modifier - .size(102.dp) - .clip(RoundedCornerShape(24.dp)) - .background(accentColor.copy(alpha = if (isDarkTheme) 0.14f else 0.12f)), + Modifier.size(102.dp), contentAlignment = Alignment.Center ) { LottieAnimation( diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index f09104d..f27dfca 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -82,7 +82,8 @@ fun SetPasswordScreen( SideEffect { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - insetsController.isAppearanceLightStatusBars = !isDarkTheme + // Auth screens should always keep white status bar icons. + insetsController.isAppearanceLightStatusBars = false window.statusBarColor = android.graphics.Color.TRANSPARENT } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 4ed1255..234a297 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -374,13 +374,12 @@ fun UnlockScreen( ) { Row( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) .then( if (accounts.size > 1) Modifier.clickable { isDropdownExpanded = !isDropdownExpanded } else Modifier ) - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { @@ -429,13 +428,12 @@ fun UnlockScreen( Card( modifier = Modifier .fillMaxWidth() - .padding(bottom = 20.dp) - .heightIn(max = if (accounts.size > 5) 300.dp else ((accounts.size * 64 + 16).dp)), + .heightIn(max = if (accounts.size > 5) 300.dp else ((accounts.size * 64).dp)), colors = CardDefaults.cardColors(containerColor = cardBackground), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(14.dp) ) { LazyColumn( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + modifier = Modifier.fillMaxWidth() ) { items(accounts, key = { it.publicKey }) { account -> val isSelected = account.publicKey == selectedAccount?.publicKey @@ -443,7 +441,6 @@ fun UnlockScreen( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) .clickable { selectedAccount = account isDropdownExpanded = false diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index f341b1f..15a18bf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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({ it.timestamp.time }, { it.id })) + ?.id var lastNewestMessageId by remember { mutableStateOf(null) } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) @@ -640,9 +665,12 @@ fun ChatDetailScreen( it.id ) } - .sortedBy { - it.timestamp - } + .sortedWith( + compareBy( + { it.timestamp.time }, + { it.id } + ) + ) .joinToString( "\n\n" ) { @@ -1297,9 +1325,12 @@ fun ChatDetailScreen( it.id ) } - .sortedBy { - it.timestamp - } + .sortedWith( + compareBy( + { it.timestamp.time }, + { it.id } + ) + ) viewModel .setReplyMessages( selectedMsgs @@ -1380,9 +1411,12 @@ fun ChatDetailScreen( it.id ) } - .sortedBy { - it.timestamp - } + .sortedWith( + compareBy( + { 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 = { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 866c512..6fca105 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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({ it.timestamp.time }, { it.id }) + private val chatMessageDescComparator = + compareByDescending { it.timestamp.time }.thenByDescending { it.id } + + private fun sortMessagesAsc(messages: List): List = + messages.sortedWith(chatMessageAscComparator) // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List) // Ключ включает account для изоляции данных между аккаунтами @@ -55,14 +62,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * сообщений для предотвращения OOM */ private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List) { + 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>(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): List = + messages.sortedWith(chatMessageAscComparator) + + private fun latestIncomingMessage(messages: List): 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 не изменился - не отправляем повторно diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 76fab04..d028add 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 4697b78..fc0e483 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 { /** Декодирование 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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 19041fa..8255363 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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) {} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index c25932e..6be23f4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -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) } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index ee86540..0787da5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 3f55105..aee6583 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -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: diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 7b63d6d..27b07c1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index f583e8a..89283ca 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -241,11 +241,17 @@ class AppleEmojiEditTextView @JvmOverloads constructor( } private fun emojiToUnified(emoji: String): String { - return emoji.codePoints() - .filter { it != 0xFE0F } - .mapToObj { String.format("%04x", it) } - .toList() - .joinToString("-") + val codePoints = emoji.codePoints().toArray() + if (codePoints.isEmpty()) return "" + + val unifiedParts = ArrayList(codePoints.size) + for (codePoint in codePoints) { + if (codePoint != 0xFE0F) { + unifiedParts.add(String.format("%04x", codePoint)) + } + } + + return unifiedParts.joinToString("-") } } @@ -658,11 +664,17 @@ class AppleEmojiTextView @JvmOverloads constructor( } private fun emojiToUnified(emoji: String): String { - return emoji.codePoints() - .filter { it != 0xFE0F } - .mapToObj { String.format("%04x", it) } - .toList() - .joinToString("-") + val codePoints = emoji.codePoints().toArray() + if (codePoints.isEmpty()) return "" + + val unifiedParts = ArrayList(codePoints.size) + for (codePoint in codePoints) { + if (codePoint != 0xFE0F) { + unifiedParts.add(String.format("%04x", codePoint)) + } + } + + return unifiedParts.joinToString("-") } /** diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index fcd828d..e0aad45 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.repository.AvatarRepository @@ -405,7 +406,7 @@ private fun ProfileBlurPreview( // ═══════════════════════════════════════════════════════════════════ -// 🎨 COLOR GRID — сетка выбора цветов (8 в ряду) +// 🎨 COLOR GRID — адаптивная сетка выбора цветов // ═══════════════════════════════════════════════════════════════════ @Composable @@ -415,33 +416,59 @@ private fun ColorSelectionGrid( onSelect: (String) -> Unit ) { val allOptions = BackgroundBlurPresets.allWithDefault - val columns = 8 + val horizontalPadding = 12.dp + val preferredColumns = 8 + val minColumns = 6 + val maxCircleSize = 40.dp + val minCircleSize = 32.dp + val minItemSpacing = 6.dp - Column( + BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp) + .padding(horizontal = horizontalPadding) ) { + val maxColumnsThatFit = + ((maxWidth + minItemSpacing) / (maxCircleSize + minItemSpacing)) + .toInt() + .coerceAtLeast(1) + val columns = + maxOf(minColumns, minOf(preferredColumns, maxColumnsThatFit)) + + val circleSize = + ((maxWidth - minItemSpacing * (columns - 1)) / columns) + .coerceIn(minCircleSize, maxCircleSize) + + val itemSpacing = + if (columns > 1) { + ((maxWidth - circleSize * columns) / (columns - 1)).coerceAtLeast(minItemSpacing) + } else { + 0.dp + } + + Column(modifier = Modifier.fillMaxWidth()) { allOptions.chunked(columns).forEach { rowItems -> Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.spacedBy(itemSpacing, Alignment.CenterHorizontally) ) { rowItems.forEach { option -> ColorCircleItem( option = option, isSelected = option.id == selectedId, isDarkTheme = isDarkTheme, + circleSize = circleSize, onClick = { onSelect(option.id) } ) } repeat(columns - rowItems.size) { - Spacer(modifier = Modifier.size(40.dp)) + Spacer(modifier = Modifier.size(circleSize)) } } } + } } } @@ -450,10 +477,11 @@ private fun ColorCircleItem( option: BackgroundBlurOption, isSelected: Boolean, isDarkTheme: Boolean, + circleSize: Dp, onClick: () -> Unit ) { val scale by animateFloatAsState( - targetValue = if (isSelected) 1.15f else 1.0f, + targetValue = if (isSelected) 1.08f else 1.0f, animationSpec = tween(200), label = "scale" ) @@ -470,7 +498,7 @@ private fun ColorCircleItem( Box( modifier = Modifier - .size(40.dp) + .size(circleSize) .scale(scale) .clip(CircleShape) .border( @@ -496,7 +524,7 @@ private fun ColorCircleItem( imageVector = TablerIcons.X, contentDescription = "None", tint = Color.White.copy(alpha = 0.9f), - modifier = Modifier.size(18.dp) + modifier = Modifier.size((circleSize * 0.45f).coerceAtLeast(14.dp)) ) } } @@ -515,7 +543,7 @@ private fun ColorCircleItem( imageVector = TablerIcons.CircleOff, contentDescription = "Default", tint = Color.White.copy(alpha = 0.9f), - modifier = Modifier.size(18.dp) + modifier = Modifier.size((circleSize * 0.45f).coerceAtLeast(14.dp)) ) } } @@ -549,7 +577,7 @@ private fun ColorCircleItem( painter = TelegramIcons.Done, contentDescription = "Selected", tint = Color.White, - modifier = Modifier.size(18.dp) + modifier = Modifier.size((circleSize * 0.45f).coerceAtLeast(14.dp)) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index df2c891..6659927 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -72,6 +73,7 @@ import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition +import com.rosetta.messenger.R import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.MessageRepository @@ -196,6 +198,9 @@ fun OtherProfileScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) + val isSafetyProfile = remember(user.publicKey) { + user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY + } val context = LocalContext.current val view = LocalView.current val window = remember { (view.context as? Activity)?.window } @@ -585,35 +590,37 @@ fun OtherProfileScreen( ) } - // Call - Button( - onClick = { /* TODO: call action */ }, - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE8E8ED), - contentColor = if (isDarkTheme) Color.White else Color.Black - ), - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp + if (!isSafetyProfile) { + // Call + Button( + onClick = { /* TODO: call action */ }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE8E8ED), + contentColor = if (isDarkTheme) Color.White else Color.Black + ), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ) + ) { + Icon( + imageVector = TablerIcons.Phone, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isDarkTheme) Color.White else PrimaryBlue ) - ) { - Icon( - imageVector = TablerIcons.Phone, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = if (isDarkTheme) Color.White else PrimaryBlue - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Call", - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - color = if (isDarkTheme) Color.White else Color.Black - ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Call", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = if (isDarkTheme) Color.White else Color.Black + ) + } } } @@ -663,23 +670,25 @@ fun OtherProfileScreen( // ═══════════════════════════════════════════════════════════ // 🔔 NOTIFICATIONS SECTION // ═══════════════════════════════════════════════════════════ - TelegramToggleItem( - icon = if (notificationsEnabled) TelegramIcons.Notifications else TelegramIcons.Mute, - title = "Notifications", - subtitle = if (notificationsEnabled) "On" else "Off", - isEnabled = notificationsEnabled, - onToggle = { - notificationsEnabled = !notificationsEnabled - coroutineScope.launch { - preferencesManager.setChatMuted( - activeAccountPublicKey, - user.publicKey, - !notificationsEnabled - ) - } - }, - isDarkTheme = isDarkTheme - ) + if (!isSafetyProfile) { + TelegramToggleItem( + icon = if (notificationsEnabled) TelegramIcons.Notifications else TelegramIcons.Mute, + title = "Notifications", + subtitle = if (notificationsEnabled) "On" else "Off", + isEnabled = notificationsEnabled, + onToggle = { + notificationsEnabled = !notificationsEnabled + coroutineScope.launch { + preferencesManager.setChatMuted( + activeAccountPublicKey, + user.publicKey, + !notificationsEnabled + ) + } + }, + isDarkTheme = isDarkTheme + ) + } // ═══════════════════════════════════════════════════════════ // 📚 SHARED CONTENT (без разделителя — сразу табы) @@ -1862,7 +1871,14 @@ private fun CollapsingOtherProfileHeader( .background(avatarColors.backgroundColor), contentAlignment = Alignment.Center ) { - if (hasAvatar && avatarRepository != null) { + if (publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY) { + Image( + painter = painterResource(id = R.drawable.safe_account), + contentDescription = "Safe avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else if (hasAvatar && avatarRepository != null) { OtherProfileFullSizeAvatar( publicKey = publicKey, avatarRepository = avatarRepository, diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 14f3c6e..6250db2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -437,6 +437,7 @@ fun ProfileScreen( // Scroll state for collapsing header + overscroll avatar expansion val density = LocalDensity.current val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() // Header heights val expandedHeightPx = with(density) { (EXPANDED_HEADER_HEIGHT + statusBarHeight).toPx() } @@ -759,7 +760,11 @@ fun ProfileScreen( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = collapsedHeightDp) + contentPadding = + PaddingValues( + top = collapsedHeightDp, + bottom = navigationBarHeight + 28.dp + ) ) { // Item 0: spacer = ровно сколько нужно проскроллить для collapse item { @@ -905,7 +910,7 @@ fun ProfileScreen( // ═════════════════════════════════════════════════════════════ TelegramLogoutItem(onClick = onLogout, isDarkTheme = isDarkTheme) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } @@ -1127,6 +1132,7 @@ private fun CollapsingProfileHeader( Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { // Expansion fraction — computed early so gradient can fade during expansion val expandFraction = expansionProgress.coerceIn(0f, 1f) + val headerBaseColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) // ═══════════════════════════════════════════════════════════ // 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим @@ -1134,12 +1140,13 @@ private fun CollapsingProfileHeader( // и естественно перекрывает его. Без мерцания. // ═══════════════════════════════════════════════════════════ Box(modifier = Modifier.matchParentSize()) { + Box(modifier = Modifier.matchParentSize().background(headerBaseColor)) if (backgroundBlurColorId == "none") { // None — стандартный цвет шапки без blur Box( modifier = Modifier .fillMaxSize() - .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)) + .background(headerBaseColor) ) } else { BlurredAvatarBackground( @@ -1147,7 +1154,7 @@ private fun CollapsingProfileHeader( avatarRepository = avatarRepository, fallbackColor = avatarColors.backgroundColor, blurRadius = 20f, - alpha = 0.9f, + alpha = 1f, overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId), isDarkTheme = isDarkTheme ) @@ -1606,38 +1613,13 @@ private fun TelegramTextField( val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) val hasError = !errorText.isNullOrBlank() val errorColor = Color(0xFFFF3B30) + val labelText = if (hasError) errorText.orEmpty() else label val labelColor by animateColorAsState( targetValue = if (hasError) errorColor else secondaryTextColor, label = "profile_field_label_color" ) - val containerColor by - animateColorAsState( - targetValue = - if (hasError) { - if (isDarkTheme) errorColor.copy(alpha = 0.18f) - else errorColor.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - label = "profile_field_container_color" - ) - val borderColor by - animateColorAsState( - targetValue = if (hasError) errorColor else Color.Transparent, - label = "profile_field_border_color" - ) - val fieldModifier = - if (hasError) { - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .clip(RoundedCornerShape(12.dp)) - .background(containerColor) - .border(1.dp, borderColor, RoundedCornerShape(12.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp) - } else { - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp) - } + val fieldModifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp) Column { Column(modifier = fieldModifier) { @@ -1672,17 +1654,7 @@ private fun TelegramTextField( Spacer(modifier = Modifier.height(4.dp)) - Text(text = label, fontSize = 13.sp, color = labelColor) - - if (hasError) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = errorText ?: "", - fontSize = 12.sp, - color = errorColor, - lineHeight = 14.sp - ) - } + Text(text = labelText, fontSize = 13.sp, color = labelColor) } if (showDivider) { diff --git a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt index 7648739..1336f22 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt @@ -11,7 +11,6 @@ import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream -import java.io.InputStream private const val TAG = "MediaUtils" @@ -36,22 +35,34 @@ object MediaUtils { */ suspend fun uriToBase64Image(context: Context, uri: Uri): String? = withContext(Dispatchers.IO) { try { - // Читаем EXIF ориентацию val orientation = getExifOrientation(context, uri) - - // Открываем InputStream - val inputStream: InputStream = context.contentResolver.openInputStream(uri) - ?: return@withContext null - - // Декодируем изображение - var bitmap = BitmapFactory.decodeStream(inputStream) - inputStream.close() - - if (bitmap == null) { + + val boundsOptions = + BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, boundsOptions) + } ?: return@withContext null + + if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { return@withContext null } - + + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = + calculateInSampleSize( + 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 ориентацию (поворот/отражение) bitmap = applyExifOrientation(bitmap, orientation) @@ -74,6 +85,8 @@ object MediaUtils { base64 } catch (e: Exception) { null + } catch (e: OutOfMemoryError) { + null } } @@ -236,6 +249,14 @@ object MediaUtils { return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) } + + private fun calculateInSampleSize(width: Int, height: Int, maxDimension: Int): Int { + var sample = 1 + while ((width / (sample * 2)) >= maxDimension || (height / (sample * 2)) >= maxDimension) { + sample *= 2 + } + return sample.coerceAtLeast(1) + } /** * Конвертировать Bitmap в Base64 PNG