Refactor image handling and decoding logic

- Introduced a maximum bitmap decode dimension to prevent excessive memory usage.
- Enhanced base64 to bitmap conversion by extracting payload and applying EXIF orientation.
- Improved error handling for image downloads and decoding processes.
- Simplified media picker and chat input components to manage keyboard visibility more effectively.
- Updated color selection grid to adaptively adjust based on available width.
- Added safety checks for notifications and call actions in profile screens.
- Optimized bitmap decoding in uriToBase64Image to handle large images more efficiently.
This commit is contained in:
2026-02-20 02:45:00 +05:00
parent 5cf8b2866f
commit 88e2084f8b
26 changed files with 943 additions and 464 deletions

View File

@@ -366,6 +366,8 @@ class MainActivity : FragmentActivity() {
super.onResume() super.onResume()
// 🔥 Приложение стало видимым - отключаем уведомления // 🔥 Приложение стало видимым - отключаем уведомления
com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true com.rosetta.messenger.push.RosettaFirebaseMessagingService.isAppInForeground = true
// ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff.
ProtocolManager.reconnectNowIfNeeded("activity_onResume")
} }
override fun onPause() { override fun onPause() {
@@ -624,6 +626,9 @@ fun MainScreen(
// Navigation helpers // Navigation helpers
fun pushScreen(screen: Screen) { 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 navStack = navStack + screen
} }
fun popScreen() { fun popScreen() {

View File

@@ -133,7 +133,7 @@ class BiometricAuthManager(private val context: Context) {
removeBiometricData() removeBiometricData()
onError("Биометрические данные изменились. Пожалуйста, настройте заново.") onError("Биометрические данные изменились. Пожалуйста, настройте заново.")
} catch (e: Exception) { } catch (e: Exception) {
onError("Ошибка инициализации: ${e.message}") onError("Ошибка инициализации: ${formatInitializationError(e)}")
} }
} }
@@ -195,7 +195,7 @@ class BiometricAuthManager(private val context: Context) {
removeBiometricData() removeBiometricData()
onError("Биометрические данные изменились. Пожалуйста, войдите с паролем и настройте биометрию заново.") onError("Биометрические данные изменились. Пожалуйста, войдите с паролем и настройте биометрию заново.")
} catch (e: Exception) { } catch (e: Exception) {
onError("Ошибка инициализации: ${e.message}") onError("Ошибка инициализации: ${formatInitializationError(e)}")
} }
} }
@@ -265,12 +265,67 @@ class BiometricAuthManager(private val context: Context) {
// Проверяем, есть ли уже ключ // Проверяем, есть ли уже ключ
getSecretKey()?.let { return it } getSecretKey()?.let { return it }
// Генерируем новый ключ в Keystore val attempts = buildKeyGenerationAttempts()
val keyGenerator = KeyGenerator.getInstance( var lastError: Exception? = null
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER 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<KeyGenerationConfig> {
val attempts = mutableListOf<KeyGenerationConfig>()
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( val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS, KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
@@ -278,42 +333,49 @@ class BiometricAuthManager(private val context: Context) {
.setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256) .setKeySize(256)
// Ключ доступен только после биометрической аутентификации
.setUserAuthenticationRequired(true) .setUserAuthenticationRequired(true)
// Ключ инвалидируется при добавлении новой биометрии
.setInvalidatedByBiometricEnrollment(true) .setInvalidatedByBiometricEnrollment(true)
// Ключ доступен только когда устройство разблокировано if (config.requireUnlockedDevice && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setUnlockedDeviceRequired(true) builder.setUnlockedDeviceRequired(true)
} }
// Для Android 11+ устанавливаем параметры аутентификации
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters( builder.setUserAuthenticationParameters(
0, // timeout = 0 означает требование аутентификации для каждой операции 0,
KeyProperties.AUTH_BIOMETRIC_STRONG KeyProperties.AUTH_BIOMETRIC_STRONG
) )
} }
// Включаем Key Attestation для проверки TEE/StrongBox if (config.useAttestation && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { builder.setAttestationChallenge(generateAttestationChallenge())
try {
builder.setAttestationChallenge(generateAttestationChallenge())
} catch (e: Exception) {
}
} }
// Используем StrongBox если доступен (аппаратный модуль безопасности) if (config.useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { builder.setIsStrongBoxBacked(true)
try {
builder.setIsStrongBoxBacked(true)
} catch (e: Exception) {
}
} }
keyGenerator.init(builder.build()) return builder.build()
return keyGenerator.generateKey() }
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
}
} }
/** /**
@@ -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 * Результат проверки Key Attestation
*/ */

View File

@@ -27,9 +27,13 @@ class BiometricPreferences(private val context: Context) {
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs" private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_" 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 { private val encryptedPrefs: SharedPreferences by lazy {
createEncryptedPreferences() createEncryptedPreferences()
@@ -49,13 +53,13 @@ class BiometricPreferences(private val context: Context) {
private fun createEncryptedPreferences(): SharedPreferences { private fun createEncryptedPreferences(): SharedPreferences {
try { try {
// Создаем MasterKey с максимальной защитой // Создаем MasterKey с максимальной защитой
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager .setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager
.build() .build()
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, appContext,
PREFS_FILE_NAME, PREFS_FILE_NAME,
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
@@ -63,7 +67,7 @@ class BiometricPreferences(private val context: Context) {
) )
} catch (e: Exception) { } catch (e: Exception) {
// Fallback на обычные SharedPreferences в случае ошибки (не должно произойти) // 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) { suspend fun enableBiometric() = withContext(Dispatchers.IO) {
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).apply() val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit()
_isBiometricEnabled.value = true 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) { suspend fun disableBiometric() = withContext(Dispatchers.IO) {
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).apply() val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit()
_isBiometricEnabled.value = false 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) { suspend fun clearAll() = withContext(Dispatchers.IO) {
encryptedPrefs.edit().clear().apply() val success = encryptedPrefs.edit().clear().commit()
_isBiometricEnabled.value = false if (!success) {
Log.w(TAG, "Failed to clear biometric preferences")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
} }
/** /**

View File

@@ -1020,17 +1020,9 @@ object MessageCrypto {
val keySpec = SecretKeySpec(keyBytes, "AES") val keySpec = SecretKeySpec(keyBytes, "AES")
val ivSpec = IvParameterSpec(iv) val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
val decompressed = cipher.doFinal(ciphertext) val compressedBytes = cipher.doFinal(ciphertext)
// Decompress with inflate inflateToUtf8(compressedBytes)
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
} catch (e: Exception) { } catch (e: Exception) {
// Fallback: пробуем SHA1 для обратной совместимости со старыми сообщениями // Fallback: пробуем SHA1 для обратной совместимости со старыми сообщениями
try { try {
@@ -1052,20 +1044,44 @@ object MessageCrypto {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytesSha1, "AES"), IvParameterSpec(iv)) 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() inflateToUtf8(compressedBytes)
inflater.setInput(decompressed)
val outputBuffer = ByteArray(decompressed.size * 10)
val outputSize = inflater.inflate(outputBuffer)
inflater.end()
String(outputBuffer, 0, outputSize, Charsets.UTF_8)
} catch (e2: Exception) { } catch (e2: Exception) {
// Return as-is, might be plain JSON // Return as-is, might be plain JSON
encryptedBlob 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 для конвертации // Extension functions для конвертации

View File

@@ -863,7 +863,7 @@ class MessageRepository private constructor(private val context: Context) {
currentList[existingIndex] = message currentList[existingIndex] = message
} else { } else {
currentList.add(message) currentList.add(message)
currentList.sortBy { it.timestamp } currentList.sortWith(compareBy<Message>({ it.timestamp }, { it.messageId }))
} }
flow.value = currentList flow.value = currentList
} }
@@ -1018,12 +1018,13 @@ class MessageRepository private constructor(private val context: Context) {
val decryptedText = val decryptedText =
if (privateKey != null && plainMessage.isNotEmpty()) { if (privateKey != null && plainMessage.isNotEmpty()) {
try { try {
CryptoManager.decryptWithPassword(plainMessage, privateKey) ?: plainMessage CryptoManager.decryptWithPassword(plainMessage, privateKey)
?: safePlainMessageFallback(plainMessage)
} catch (e: Exception) { } catch (e: Exception) {
plainMessage // Fallback на зашифрованный текст если расшифровка не удалась safePlainMessageFallback(plainMessage)
} }
} else { } else {
plainMessage safePlainMessageFallback(plainMessage)
} }
return Message( 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() = private fun DialogEntity.toDialog() =
Dialog( Dialog(
opponentKey = opponentKey, opponentKey = opponentKey,

View File

@@ -94,7 +94,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC ORDER BY timestamp DESC, message_id DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
@@ -116,7 +116,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_public_key = :account AND from_public_key = :account
AND to_public_key = :account AND to_public_key = :account
ORDER BY timestamp DESC ORDER BY timestamp DESC, message_id DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
@@ -142,7 +142,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey 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<List<MessageEntity>> fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
@@ -175,7 +175,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC ORDER BY timestamp DESC, message_id DESC
LIMIT :limit LIMIT :limit
""" """
) )
@@ -234,7 +234,7 @@ interface MessageDao {
AND dialog_key = :dialogKey AND dialog_key = :dialogKey
AND from_public_key = :fromPublicKey AND from_public_key = :fromPublicKey
AND timestamp BETWEEN :timestampFrom AND :timestampTo AND timestamp BETWEEN :timestampFrom AND :timestampTo
ORDER BY timestamp ASC ORDER BY timestamp ASC, message_id ASC
LIMIT 1 LIMIT 1
""" """
) )
@@ -316,7 +316,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) 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? suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
@@ -331,7 +331,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) 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? suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
@@ -346,7 +346,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) 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? suspend fun getLastMessageAttachments(account: String, opponent: String): String?
@@ -535,7 +535,7 @@ interface DialogDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey 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? suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?

View File

@@ -592,6 +592,37 @@ class Protocol(
*/ */
fun isAuthenticated(): Boolean = _state.value == ProtocolState.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) * Check if connected (may not be authenticated yet)
*/ */

View File

@@ -9,6 +9,8 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.security.SecureRandom import java.security.SecureRandom
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -66,8 +68,18 @@ object ProtocolManager {
private var uiLogsEnabled = false private var uiLogsEnabled = false
private var lastProtocolState: ProtocolState? = null private var lastProtocolState: ProtocolState? = null
@Volatile private var syncBatchInProgress = false @Volatile private var syncBatchInProgress = false
private val _syncInProgress = MutableStateFlow(false)
val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow()
@Volatile private var resyncRequiredAfterAccountInit = false @Volatile private var resyncRequiredAfterAccountInit = false
private val inboundPacketTasks = AtomicInteger(0) 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) { fun addLog(message: String) {
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())
@@ -107,6 +119,9 @@ object ProtocolManager {
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) { if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
onAuthenticated() onAuthenticated()
} }
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
setSyncInProgress(false)
}
lastProtocolState = newState lastProtocolState = newState
} }
} }
@@ -117,7 +132,7 @@ object ProtocolManager {
* Должен вызываться после авторизации пользователя * Должен вызываться после авторизации пользователя
*/ */
fun initializeAccount(publicKey: String, privateKey: String) { fun initializeAccount(publicKey: String, privateKey: String) {
syncBatchInProgress = false setSyncInProgress(false)
messageRepository?.initialize(publicKey, privateKey) messageRepository?.initialize(publicKey, privateKey)
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
@@ -309,7 +324,10 @@ object ProtocolManager {
inboundPacketTasks.incrementAndGet() inboundPacketTasks.incrementAndGet()
scope.launch { scope.launch {
try { try {
block() // Preserve packet handling order to avoid read/message races during sync.
inboundPacketMutex.withLock {
block()
}
} finally { } finally {
inboundPacketTasks.decrementAndGet() inboundPacketTasks.decrementAndGet()
} }
@@ -331,6 +349,7 @@ object ProtocolManager {
} }
private fun onAuthenticated() { private fun onAuthenticated() {
setSyncInProgress(false)
TransportManager.requestTransportServer() TransportManager.requestTransportServer()
fetchOwnProfile() fetchOwnProfile()
requestSynchronize() requestSynchronize()
@@ -378,17 +397,16 @@ object ProtocolManager {
scope.launch { scope.launch {
when (packet.status) { when (packet.status) {
SyncStatus.BATCH_START -> { SyncStatus.BATCH_START -> {
syncBatchInProgress = true setSyncInProgress(true)
} }
SyncStatus.BATCH_END -> { SyncStatus.BATCH_END -> {
syncBatchInProgress = true setSyncInProgress(true)
waitInboundPacketTasks() waitInboundPacketTasks()
messageRepository?.updateLastSyncTimestamp(packet.timestamp) messageRepository?.updateLastSyncTimestamp(packet.timestamp)
syncBatchInProgress = false
sendSynchronize(packet.timestamp) sendSynchronize(packet.timestamp)
} }
SyncStatus.NOT_NEEDED -> { SyncStatus.NOT_NEEDED -> {
syncBatchInProgress = false setSyncInProgress(false)
messageRepository?.updateLastSyncTimestamp(packet.timestamp) messageRepository?.updateLastSyncTimestamp(packet.timestamp)
} }
} }
@@ -424,6 +442,13 @@ object ProtocolManager {
getProtocol().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 * Authenticate with server
*/ */
@@ -647,7 +672,7 @@ object ProtocolManager {
protocol?.clearCredentials() protocol?.clearCredentials()
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncBatchInProgress = false setSyncInProgress(false)
inboundPacketTasks.set(0) inboundPacketTasks.set(0)
} }
@@ -659,7 +684,7 @@ object ProtocolManager {
protocol = null protocol = null
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncBatchInProgress = false setSyncInProgress(false)
inboundPacketTasks.set(0) inboundPacketTasks.set(0)
scope.cancel() scope.cancel()
} }

View File

@@ -4,6 +4,8 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.runtime.* 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.DecryptedAccount
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
@@ -26,6 +28,17 @@ fun AuthFlow(
onAuthComplete: (DecryptedAccount?) -> Unit, onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> 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 { var currentScreen by remember {
mutableStateOf( mutableStateOf(
when { when {

View File

@@ -35,11 +35,13 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -64,6 +66,16 @@ fun DeviceConfirmScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onExit: () -> Unit 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 = val themeAnimSpec =
tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
@@ -145,10 +157,7 @@ fun DeviceConfirmScreen(
Box( Box(
modifier = modifier =
Modifier Modifier.size(102.dp),
.size(102.dp)
.clip(RoundedCornerShape(24.dp))
.background(accentColor.copy(alpha = if (isDarkTheme) 0.14f else 0.12f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
LottieAnimation( LottieAnimation(

View File

@@ -82,7 +82,8 @@ fun SetPasswordScreen(
SideEffect { SideEffect {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) 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 window.statusBarColor = android.graphics.Color.TRANSPARENT
} }
} }

View File

@@ -374,13 +374,12 @@ fun UnlockScreen(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then( .then(
if (accounts.size > 1) if (accounts.size > 1)
Modifier.clickable { isDropdownExpanded = !isDropdownExpanded } Modifier.clickable { isDropdownExpanded = !isDropdownExpanded }
else Modifier else Modifier
) )
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@@ -429,13 +428,12 @@ fun UnlockScreen(
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 20.dp) .heightIn(max = if (accounts.size > 5) 300.dp else ((accounts.size * 64).dp)),
.heightIn(max = if (accounts.size > 5) 300.dp else ((accounts.size * 64 + 16).dp)),
colors = CardDefaults.cardColors(containerColor = cardBackground), colors = CardDefaults.cardColors(containerColor = cardBackground),
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(14.dp)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) modifier = Modifier.fillMaxWidth()
) { ) {
items(accounts, key = { it.publicKey }) { account -> items(accounts, key = { it.publicKey }) { account ->
val isSelected = account.publicKey == selectedAccount?.publicKey val isSelected = account.publicKey == selectedAccount?.publicKey
@@ -443,7 +441,6 @@ fun UnlockScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { .clickable {
selectedAccount = account selectedAccount = account
isDropdownExpanded = false isDropdownExpanded = false

View File

@@ -247,6 +247,28 @@ fun ChatDetailScreen(
// Триггер для возврата фокуса на инпут после отправки фото из редактора // Триггер для возврата фокуса на инпут после отправки фото из редактора
var inputFocusTrigger by remember { mutableStateOf(0) } 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. // Блокируем swipe-back родительского экрана, пока открыт fullscreen/media overlay.
val shouldLockParentSwipeBack by val shouldLockParentSwipeBack by
remember( remember(
@@ -503,7 +525,10 @@ fun ChatDetailScreen(
// ВАЖНО: Используем maxByOrNull по timestamp, НЕ firstOrNull()! // ВАЖНО: Используем maxByOrNull по timestamp, НЕ firstOrNull()!
// При пагинации старые сообщения добавляются в начало списка, // При пагинации старые сообщения добавляются в начало списка,
// поэтому firstOrNull() возвращает старое сообщение, а не новое. // поэтому firstOrNull() возвращает старое сообщение, а не новое.
val newestMessageId = messages.maxByOrNull { it.timestamp.time }?.id val newestMessageId =
messages
.maxWithOrNull(compareBy<ChatMessage>({ it.timestamp.time }, { it.id }))
?.id
var lastNewestMessageId by remember { mutableStateOf<String?>(null) } var lastNewestMessageId by remember { mutableStateOf<String?>(null) }
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
@@ -640,9 +665,12 @@ fun ChatDetailScreen(
it.id it.id
) )
} }
.sortedBy { .sortedWith(
it.timestamp compareBy<ChatMessage>(
} { it.timestamp.time },
{ it.id }
)
)
.joinToString( .joinToString(
"\n\n" "\n\n"
) { ) {
@@ -1297,9 +1325,12 @@ fun ChatDetailScreen(
it.id it.id
) )
} }
.sortedBy { .sortedWith(
it.timestamp compareBy<ChatMessage>(
} { it.timestamp.time },
{ it.id }
)
)
viewModel viewModel
.setReplyMessages( .setReplyMessages(
selectedMsgs selectedMsgs
@@ -1380,9 +1411,12 @@ fun ChatDetailScreen(
it.id it.id
) )
} }
.sortedBy { .sortedWith(
it.timestamp compareBy<ChatMessage>(
} { it.timestamp.time },
{ it.id }
)
)
val forwardMessages = val forwardMessages =
selectedMsgs selectedMsgs
@@ -1604,7 +1638,9 @@ fun ChatDetailScreen(
myPrivateKey = myPrivateKey =
currentUserPrivateKey, currentUserPrivateKey,
inputFocusTrigger = inputFocusTrigger =
inputFocusTrigger inputFocusTrigger,
suppressKeyboard =
showInAppCamera
) )
} }
} }
@@ -2086,8 +2122,19 @@ fun ChatDetailScreen(
}, },
onOpenCamera = { 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() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus(force = true)
showEmojiPicker = false
showMediaPicker = false
showInAppCamera = true showInAppCamera = true
}, },
onOpenFilePicker = { onOpenFilePicker = {

View File

@@ -42,6 +42,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val chatMessageAscComparator =
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
private val chatMessageDescComparator =
compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id }
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>) // 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
// Ключ включает account для изоляции данных между аккаунтами // Ключ включает account для изоляции данных между аккаунтами
@@ -55,14 +62,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* сообщений для предотвращения OOM * сообщений для предотвращения OOM
*/ */
private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) { private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
val orderedMessages = sortMessagesAsc(messages)
val limitedMessages = val limitedMessages =
if (messages.size > MAX_CACHE_SIZE) { if (orderedMessages.size > MAX_CACHE_SIZE) {
// Оставляем только последние сообщения (по timestamp) // Оставляем только последние сообщения в детерминированном порядке.
messages.sortedByDescending { it.timestamp }.take(MAX_CACHE_SIZE).sortedBy { orderedMessages.takeLast(MAX_CACHE_SIZE)
it.timestamp
}
} else { } else {
messages orderedMessages
} }
dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
} }
@@ -130,7 +136,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
.mapLatest { rawMessages -> .mapLatest { rawMessages ->
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val unique = rawMessages.distinctBy { it.id } val unique = rawMessages.distinctBy { it.id }
val sorted = unique.sortedByDescending { it.timestamp.time } val sorted = unique.sortedWith(chatMessageDescComparator)
val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size) val result = ArrayList<Pair<ChatMessage, Boolean>>(sorted.size)
var prevDateStr: String? = null var prevDateStr: String? = null
for (i in sorted.indices) { for (i in sorted.indices) {
@@ -205,6 +211,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Флаг что read receipt уже отправлен для текущего диалога // Флаг что read receipt уже отправлен для текущего диалога
private var readReceiptSentForCurrentDialog = false private var readReceiptSentForCurrentDialog = false
private fun sortMessagesAscending(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
private fun latestIncomingMessage(messages: List<ChatMessage>): ChatMessage? =
messages.asSequence().filter { !it.isOutgoing }.maxWithOrNull(chatMessageAscComparator)
// 🔥 Флаг что диалог АКТИВЕН (пользователь внутри чата, а не на главной) // 🔥 Флаг что диалог АКТИВЕН (пользователь внутри чата, а не на главной)
// Как currentDialogPublicKeyView в архиве // Как currentDialogPublicKeyView в архиве
private var isDialogActive = false private var isDialogActive = false
@@ -321,7 +333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Добавляем все сразу // Добавляем все сразу
kotlinx.coroutines.withContext(Dispatchers.Main.immediate) { kotlinx.coroutines.withContext(Dispatchers.Main.immediate) {
val currentList = _messages.value 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 _messages.value = newList
} }
@@ -712,7 +724,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val newList = messages + optimisticMessages val newList = messages + optimisticMessages
// 🔍 Финальная дедупликация по ID (на всякий случай) // 🔍 Финальная дедупликация по ID (на всякий случай)
val deduplicatedList = newList.distinctBy { it.id } val deduplicatedList =
sortMessagesAscending(newList.distinctBy { it.id })
if (deduplicatedList.size != newList.size) {} if (deduplicatedList.size != newList.size) {}
@@ -736,7 +749,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Отправляем read receipt собеседнику (НЕ для saved messages!) // Отправляем read receipt собеседнику (НЕ для saved messages!)
if (!isSavedMessages && messages.isNotEmpty()) { if (!isSavedMessages && messages.isNotEmpty()) {
val lastIncoming = messages.lastOrNull { !it.isOutgoing } val lastIncoming = latestIncomingMessage(messages)
if (lastIncoming != null && if (lastIncoming != null &&
lastIncoming.timestamp.time > lastIncoming.timestamp.time >
lastReadMessageTimestamp lastReadMessageTimestamp
@@ -794,7 +807,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем! // 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
// Сортируем по timestamp чтобы новые были в конце // Сортируем по 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( updateCacheWithLimit(
account, account,
dialogKey, dialogKey,
(existingCache + trulyNewMessages).sortedBy { it.timestamp } sortMessagesAscending(existingCache + trulyNewMessages)
) )
} }
@@ -900,13 +914,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateCacheWithLimit( updateCacheWithLimit(
account, account,
dialogKey, dialogKey,
(trulyNewMessages + existingCache).sortedBy { it.timestamp } sortMessagesAscending(trulyNewMessages + existingCache)
) )
} }
// Добавляем в начало списка (старые сообщения) // Добавляем в начало списка (старые сообщения)
withContext(Dispatchers.Main) { 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 с приватным ключом // Fallback на расшифровку plainMessage с приватным ключом
if (privateKey != null && entity.plainMessage.isNotEmpty()) { if (privateKey != null && entity.plainMessage.isNotEmpty()) {
try { try {
val result = val decrypted =
CryptoManager.decryptWithPassword( CryptoManager.decryptWithPassword(
entity.plainMessage, entity.plainMessage,
privateKey privateKey
) )
?: entity.plainMessage if (decrypted != null) {
decryptionCache[entity.messageId] = result decryptionCache[entity.messageId] = decrypted
result decrypted
} else {
safePlainMessageFallback(entity.plainMessage)
}
} catch (e: Exception) { } catch (e: Exception) {
entity.plainMessage safePlainMessageFallback(entity.plainMessage)
} }
} else { } else {
entity.plainMessage safePlainMessageFallback(entity.plainMessage)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -978,16 +998,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey val privateKey = myPrivateKey
if (privateKey != null && entity.plainMessage.isNotEmpty()) { if (privateKey != null && entity.plainMessage.isNotEmpty()) {
try { try {
CryptoManager.decryptWithPassword( val decrypted = CryptoManager.decryptWithPassword(
entity.plainMessage, entity.plainMessage,
privateKey privateKey
) )
?: entity.plainMessage if (decrypted != null) {
decryptionCache[entity.messageId] = decrypted
decrypted
} else {
safePlainMessageFallback(entity.plainMessage)
}
} catch (e2: Exception) { } catch (e2: Exception) {
entity.plainMessage safePlainMessageFallback(entity.plainMessage)
} }
} else { } 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 который обрабатывается отдельно) 💾 Для * Парсинг всех attachments из JSON (кроме MESSAGES который обрабатывается отдельно) 💾 Для
* IMAGE - загружает blob из файловой системы если пустой в БД * IMAGE - загружает blob из файловой системы если пустой в БД
@@ -1991,8 +2038,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = delivered =
if (isSavedMessages) 2 if (isSavedMessages) 1
else 0, // 📁 Saved Messages: сразу DELIVERED (2), иначе SENDING (0) else 0, // 📁 Saved Messages: сразу DELIVERED (1), иначе SENDING (0)
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
@@ -2162,7 +2209,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 2 else 0, delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
@@ -2459,9 +2506,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val finalAttachmentsJson = attachmentsJson // Уже без localUri val finalAttachmentsJson = attachmentsJson // Уже без localUri
if (!isSavedMessages) { if (!isSavedMessages) {
updateMessageStatusAndAttachmentsInDb(messageId, 2, finalAttachmentsJson) updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
} else { } else {
updateMessageStatusAndAttachmentsInDb(messageId, 2, finalAttachmentsJson) updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -2628,14 +2675,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 2 else 0, // SENDING для обычных delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных
attachmentsJson = attachmentsJson, attachmentsJson = attachmentsJson,
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI // 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
if (!isSavedMessages) { if (!isSavedMessages) {
updateMessageStatusInDb(messageId, 2) // SENT updateMessageStatusInDb(messageId, 1) // DELIVERED
} }
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
@@ -2866,14 +2913,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 2 else 0, delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJsonArray.toString(), attachmentsJson = attachmentsJsonArray.toString(),
opponentPublicKey = recipient opponentPublicKey = recipient
) )
// 🔥 Обновляем статус в БД после отправки // 🔥 Обновляем статус в БД после отправки
if (!isSavedMessages) { if (!isSavedMessages) {
updateMessageStatusInDb(messageId, 2) // SENT updateMessageStatusInDb(messageId, 1) // DELIVERED
} }
// Обновляем UI // Обновляем UI
@@ -3023,14 +3070,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = delivered =
if (isSavedMessages) 2 if (isSavedMessages) 1
else 0, // SENDING для обычных, SENT для saved else 0, // SENDING для обычных, DELIVERED для saved
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
// 🔥 После успешной отправки обновляем статус на SENT (2) в БД и UI // 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
if (!isSavedMessages) { if (!isSavedMessages) {
updateMessageStatusInDb(messageId, 2) // SENT updateMessageStatusInDb(messageId, 1) // DELIVERED
} }
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) } withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
@@ -3241,13 +3288,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 2 else 0, // Как в sendImageMessage delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
// 🔥 Обновляем статус в БД после отправки // 🔥 Обновляем статус в БД после отправки
if (!isSavedMessages) { if (!isSavedMessages) {
updateMessageStatusInDb(messageId, 2) // SENT updateMessageStatusInDb(messageId, 1) // DELIVERED
} }
// Обновляем UI // Обновляем UI
@@ -3481,7 +3528,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return val privateKey = myPrivateKey ?: return
// Обновляем timestamp последнего прочитанного // Обновляем timestamp последнего прочитанного
val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing } val lastIncoming = latestIncomingMessage(_messages.value)
if (lastIncoming != null) { if (lastIncoming != null) {
lastReadMessageTimestamp = lastIncoming.timestamp.time lastReadMessageTimestamp = lastIncoming.timestamp.time
} }
@@ -3518,7 +3565,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
// Находим последнее входящее сообщение // Находим последнее входящее сообщение
val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing } val lastIncoming = latestIncomingMessage(_messages.value)
if (lastIncoming == null) return if (lastIncoming == null) return
// Если timestamp не изменился - не отправляем повторно // Если timestamp не изменился - не отправляем повторно

View File

@@ -262,6 +262,7 @@ fun ChatsListScreen(
// Protocol connection state // Protocol connection state
val protocolState by ProtocolManager.state.collectAsState() val protocolState by ProtocolManager.state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают // 🔥 Пользователи, которые сейчас печатают
@@ -291,6 +292,30 @@ fun ChatsListScreen(
// 📬 Requests screen state // 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) } 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) // 📂 Accounts section expanded state (arrow toggle)
var accountsSectionExpanded by remember { mutableStateOf(false) } var accountsSectionExpanded by remember { mutableStateOf(false) }
@@ -542,9 +567,18 @@ fun ChatsListScreen(
top = 16.dp, top = 16.dp,
start = 20.dp, start = 20.dp,
end = 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 // Avatar row with theme toggle
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -612,12 +646,28 @@ fun ChatsListScreen(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Display name // Display name
if (accountName.isNotEmpty()) { if (accountName.isNotEmpty()) {
Text( Row(
text = accountName, verticalAlignment = Alignment.CenterVertically
fontSize = 15.sp, ) {
fontWeight = FontWeight.Bold, Text(
color = Color.White 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 // Username
@@ -841,7 +891,7 @@ fun ChatsListScreen(
drawerState.close() drawerState.close()
kotlinx.coroutines kotlinx.coroutines
.delay(100) .delay(100)
showRequestsScreen = true setInlineRequestsVisible(true)
} }
} }
) )
@@ -1082,8 +1132,9 @@ fun ChatsListScreen(
if (showRequestsScreen) { if (showRequestsScreen) {
IconButton( IconButton(
onClick = { onClick = {
showRequestsScreen = setInlineRequestsVisible(
false false
)
} }
) { ) {
Icon( Icon(
@@ -1172,7 +1223,21 @@ fun ChatsListScreen(
color = Color.White color = Color.White
) )
} else { } 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( Text(
"Rosetta", "Rosetta",
fontWeight = fontWeight =
@@ -1183,13 +1248,6 @@ fun ChatsListScreen(
color = color =
Color.White Color.White
) )
} else {
AnimatedDotsText(
baseText = "Connecting",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
} }
} }
}, },
@@ -1373,8 +1431,10 @@ fun ChatsListScreen(
if (claimed) { if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat() val screenWidth = size.width.toFloat()
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) { if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
showRequestsScreen = false setInlineRequestsVisible(
false
)
} }
} }
} }
@@ -1384,7 +1444,9 @@ fun ChatsListScreen(
requests = requests, requests = requests,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { onBack = {
showRequestsScreen = false setInlineRequestsVisible(
false
)
}, },
onRequestClick = { request -> onRequestClick = { request ->
val user = val user =
@@ -1590,7 +1652,7 @@ fun ChatsListScreen(
isDarkTheme = isDarkTheme =
isDarkTheme, isDarkTheme,
onClick = { onClick = {
onRequestsClick() openRequestsRouteSafely()
} }
) )
Divider( Divider(
@@ -1927,9 +1989,6 @@ private fun DeviceResolveDialog(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val isAccept = action == DeviceResolveAction.ACCEPT val isAccept = action == DeviceResolveAction.ACCEPT
val confirmColor = if (isAccept) PrimaryBlue else Color(0xFFFF3B30) 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( val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes( LottieCompositionSpec.RawRes(
@@ -1967,9 +2026,7 @@ private fun DeviceResolveDialog(
) { ) {
Box( Box(
modifier = modifier =
Modifier.size(96.dp) Modifier.size(96.dp),
.clip(RoundedCornerShape(20.dp))
.background(accentBg),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
LottieAnimation( LottieAnimation(

View File

@@ -72,6 +72,8 @@ import java.io.File
import kotlin.math.min import kotlin.math.min
private const val TAG = "AttachmentComponents" private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val whitespaceRegex = "\\s+".toRegex()
private fun shortDebugId(value: String): String { private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty" if (value.isBlank()) return "empty"
@@ -907,6 +909,8 @@ fun ImageAttachment(
imageBitmap = bitmap imageBitmap = bitmap
// 🔥 Сохраняем в глобальный кэш // 🔥 Сохраняем в глобальный кэш
ImageBitmapCache.put(cacheKey, bitmap) ImageBitmapCache.put(cacheKey, bitmap)
} else {
downloadStatus = DownloadStatus.ERROR
} }
} else if (attachment.localUri.isEmpty()) { } else if (attachment.localUri.isEmpty()) {
// Только если нет localUri - помечаем как NOT_DOWNLOADED // Только если нет 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 downloadProgress = 0.8f
if (decrypted != null) { if (decrypted != null) {
var decodedBitmap: Bitmap? = null
var saved = false
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms") logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
imageBitmap = base64ToBitmap(decrypted) decodedBitmap = base64ToBitmap(decrypted)
if (decodedBitmap != null) {
imageBitmap = decodedBitmap
ImageBitmapCache.put(cacheKey, decodedBitmap!!)
// 💾 Сохраняем в файловую систему (как в Desktop) // 💾 Сохраняем в файловую систему (как в Desktop)
val saved = saved =
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
context = context, context = context,
blob = decrypted, blob = decrypted,
attachmentId = attachment.id, attachmentId = attachment.id,
publicKey = senderPublicKey, publicKey = senderPublicKey,
privateKey = privateKey privateKey = privateKey
) )
logPhotoDebug("Cache save result: id=$idShort, saved=$saved") }
}
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 { } else {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms") logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
@@ -2117,62 +2140,112 @@ private fun parseFilePreview(preview: String): Pair<Long, String> {
/** Декодирование base64 в Bitmap */ /** Декодирование base64 в Bitmap */
internal fun base64ToBitmap(base64: String): Bitmap? { internal fun base64ToBitmap(base64: String): Bitmap? {
return try { return try {
val cleanBase64 = val payload = extractBase64Payload(base64) ?: return null
if (base64.contains(",")) { val bytes = Base64.decode(payload, Base64.DEFAULT)
base64.substringAfter(",") if (bytes.isEmpty()) return null
} else {
base64 val decoded = decodeBitmapWithSampling(bytes) ?: return null
}
val bytes = Base64.decode(cleanBase64, Base64.DEFAULT)
var bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
val orientation = val orientation =
ByteArrayInputStream(bytes).use { stream -> runCatching {
ExifInterface(stream) ByteArrayInputStream(bytes).use { stream ->
.getAttributeInt( ExifInterface(stream)
ExifInterface.TAG_ORIENTATION, .getAttributeInt(
ExifInterface.ORIENTATION_NORMAL ExifInterface.TAG_ORIENTATION,
) ExifInterface.ORIENTATION_NORMAL
} )
}
}
.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
if (orientation != ExifInterface.ORIENTATION_NORMAL && applyExifOrientation(decoded, orientation)
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
} catch (e: Exception) { } catch (e: Exception) {
null 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}" "Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
) )
// Try decryptReplyBlob first (desktop decodeWithPassword) // Primary path for image attachments
var decrypted = try { var decrypted =
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce) MessageCrypto.decryptAttachmentBlobWithPlainKey(
.takeIf { it.isNotEmpty() && it != encryptedContent } encryptedContent,
} catch (_: Exception) { null } plainKeyAndNonce
)
// Fallback: decryptAttachmentBlobWithPlainKey // Fallback for legacy payloads
if (decrypted == null) { if (decrypted.isNullOrEmpty()) {
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey( decrypted =
encryptedContent, plainKeyAndNonce 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 val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
ImageBitmapCache.put(cacheKey, bitmap) ImageBitmapCache.put(cacheKey, bitmap)

View File

@@ -2,7 +2,6 @@ package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -1126,15 +1125,18 @@ fun AnimatedMessageStatus(
) )
var showErrorMenu by remember { mutableStateOf(false) } 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( Crossfade(
targetState = effectiveStatus, targetState = effectiveStatus,
animationSpec = tween(durationMillis = 200), animationSpec = tween(durationMillis = 200),
label = "statusIcon" label = "statusIcon"
) { currentStatus -> ) { currentStatus ->
val iconSize = with(LocalDensity.current) { 14.sp.toDp() }
if (currentStatus == MessageStatus.ERROR) { if (currentStatus == MessageStatus.ERROR) {
Icon( Icon(
imageVector = TablerIcons.AlertCircle, imageVector = TablerIcons.AlertCircle,
@@ -1142,6 +1144,7 @@ fun AnimatedMessageStatus(
tint = animatedColor, tint = animatedColor,
modifier = modifier =
Modifier.size(iconSize) Modifier.size(iconSize)
.align(Alignment.CenterStart)
.scale(scale) .scale(scale)
.clickable { .clickable {
showErrorMenu = true showErrorMenu = true
@@ -1151,7 +1154,7 @@ fun AnimatedMessageStatus(
if (currentStatus == MessageStatus.READ) { if (currentStatus == MessageStatus.READ) {
Box( Box(
modifier = modifier =
Modifier.width(iconSize + 6.dp) Modifier.width(statusSlotWidth)
.height(iconSize) .height(iconSize)
.scale(scale) .scale(scale)
) { ) {
@@ -1989,34 +1992,7 @@ fun ReplyImagePreview(
try { try {
// Пробуем сначала из blob // Пробуем сначала из blob
if (attachment.blob.isNotEmpty()) { if (attachment.blob.isNotEmpty()) {
val decoded = val decoded = base64ToBitmap(attachment.blob)
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
}
if (decoded != null) { if (decoded != null) {
fullImageBitmap = decoded fullImageBitmap = decoded
return@withContext return@withContext
@@ -2033,31 +2009,7 @@ fun ReplyImagePreview(
) )
if (localBlob != null) { if (localBlob != null) {
val decoded = val decoded = base64ToBitmap(localBlob)
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
}
fullImageBitmap = decoded fullImageBitmap = decoded
} }
} catch (e: Exception) {} } catch (e: Exception) {}

View File

@@ -2,8 +2,6 @@ package com.rosetta.messenger.ui.chats.components
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
@@ -968,19 +966,7 @@ private suspend fun loadBitmapForViewerImage(
* Безопасное декодирование base64 в Bitmap * Безопасное декодирование base64 в Bitmap
*/ */
private fun base64ToBitmapSafe(base64String: String): Bitmap? { private fun base64ToBitmapSafe(base64String: String): Bitmap? {
return try { return base64ToBitmap(base64String)
// Убираем возможные префиксы 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
}
} }
/** /**

View File

@@ -4,6 +4,8 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture 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.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -68,6 +72,7 @@ fun InAppCameraScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
// Camera state // Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
@@ -86,7 +91,25 @@ fun InAppCameraScreen(
// Enter animation + hide keyboard // Enter animation + hide keyboard
LaunchedEffect(Unit) { 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() keyboardController?.hide()
focusManager.clearFocus(force = true)
animationProgress.animateTo( animationProgress.animateTo(
targetValue = 1f, targetValue = 1f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
@@ -111,6 +134,9 @@ fun InAppCameraScreen(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
val activity = context as? Activity val activity = context as? Activity
val window = activity?.window 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 originalStatusBarColor = remember { window?.statusBarColor ?: android.graphics.Color.WHITE }
val originalNavigationBarColor = remember { window?.navigationBarColor ?: android.graphics.Color.WHITE } val originalNavigationBarColor = remember { window?.navigationBarColor ?: android.graphics.Color.WHITE }
@@ -138,6 +164,7 @@ fun InAppCameraScreen(
DisposableEffect(window) { DisposableEffect(window) {
onDispose { onDispose {
if (window == null || insetsController == null) return@onDispose if (window == null || insetsController == null) return@onDispose
window.setSoftInputMode(originalSoftInputMode)
window.statusBarColor = originalStatusBarColor window.statusBarColor = originalStatusBarColor
insetsController.isAppearanceLightStatusBars = originalLightStatusBars insetsController.isAppearanceLightStatusBars = originalLightStatusBars

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
@@ -123,9 +124,21 @@ fun MediaPickerBottomSheet(
// Function to hide keyboard // Function to hide keyboard
fun hideKeyboard() { 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 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 // Media items from gallery
@@ -228,6 +241,7 @@ fun MediaPickerBottomSheet(
// 🎬 Анимация появления/закрытия // 🎬 Анимация появления/закрытия
var isClosing by remember { mutableStateOf(false) } var isClosing by remember { mutableStateOf(false) }
var shouldShow by remember { mutableStateOf(false) } var shouldShow by remember { mutableStateOf(false) }
var closeAction by remember { mutableStateOf<(() -> Unit)?>(null) }
// Scope для анимаций // Scope для анимаций
val animationScope = rememberCoroutineScope() val animationScope = rememberCoroutineScope()
@@ -245,10 +259,13 @@ fun MediaPickerBottomSheet(
isClosing = false isClosing = false
shouldShow = false shouldShow = false
isExpanded = false isExpanded = false
val action = closeAction
closeAction = null
// Сбрасываем высоту // Сбрасываем высоту
animationScope.launch { animationScope.launch {
sheetHeightPx.snapTo(collapsedHeightPx) sheetHeightPx.snapTo(collapsedHeightPx)
} }
action?.invoke()
onDismiss() 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) { if (!isClosing) {
isClosing = true isClosing = true
} }
@@ -428,7 +449,7 @@ fun MediaPickerBottomSheet(
Popup( Popup(
alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран
onDismissRequest = animatedClose, onDismissRequest = { animatedClose() },
properties = PopupProperties( properties = PopupProperties(
focusable = true, focusable = true,
dismissOnBackPress = true, dismissOnBackPress = true,
@@ -525,7 +546,7 @@ fun MediaPickerBottomSheet(
// Header with action buttons // Header with action buttons
MediaPickerHeader( MediaPickerHeader(
selectedCount = selectedItems.size, selectedCount = selectedItems.size,
onDismiss = animatedClose, onDismiss = { animatedClose() },
onSend = { onSend = {
val selected = mediaItems.filter { it.id in selectedItems } val selected = mediaItems.filter { it.id in selectedItems }
onMediaSelected(selected, pickerCaption.trim()) onMediaSelected(selected, pickerCaption.trim())
@@ -540,16 +561,16 @@ fun MediaPickerBottomSheet(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onCameraClick = { onCameraClick = {
hideKeyboard() hideKeyboard()
animatedClose() animatedClose {
onOpenCamera() hideKeyboard()
onOpenCamera()
}
}, },
onFileClick = { onFileClick = {
animatedClose() animatedClose { onOpenFilePicker() }
onOpenFilePicker()
}, },
onAvatarClick = { onAvatarClick = {
animatedClose() animatedClose { onAvatarClick() }
onAvatarClick()
} }
) )
@@ -623,8 +644,10 @@ fun MediaPickerBottomSheet(
selectedItems = selectedItems, selectedItems = selectedItems,
onCameraClick = { onCameraClick = {
hideKeyboard() hideKeyboard()
animatedClose() animatedClose {
onOpenCamera() hideKeyboard()
onOpenCamera()
}
}, },
onItemClick = { item, _ -> onItemClick = { item, _ ->
// Telegram-style selection: // Telegram-style selection:

View File

@@ -85,7 +85,8 @@ fun MessageInputBar(
myPublicKey: String = "", myPublicKey: String = "",
opponentPublicKey: String = "", opponentPublicKey: String = "",
myPrivateKey: String = "", myPrivateKey: String = "",
inputFocusTrigger: Int = 0 inputFocusTrigger: Int = 0,
suppressKeyboard: Boolean = false
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val liveReplyMessages = val liveReplyMessages =
@@ -115,7 +116,7 @@ fun MessageInputBar(
// Auto-focus when reply panel opens // Auto-focus when reply panel opens
LaunchedEffect(hasReply, editTextView) { LaunchedEffect(hasReply, editTextView) {
if (hasReply) { if (hasReply && !suppressKeyboard) {
delay(50) delay(50)
editTextView?.let { editText -> editTextView?.let { editText ->
if (!showEmojiPicker) { if (!showEmojiPicker) {
@@ -129,7 +130,7 @@ fun MessageInputBar(
// Return focus to input after closing the photo editor // Return focus to input after closing the photo editor
LaunchedEffect(inputFocusTrigger) { LaunchedEffect(inputFocusTrigger) {
if (inputFocusTrigger > 0) { if (inputFocusTrigger > 0 && !suppressKeyboard) {
delay(100) delay(100)
editTextView?.let { editText -> editTextView?.let { editText ->
editText.requestFocus() 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 val imeInsets = WindowInsets.ime
var isKeyboardVisible by remember { mutableStateOf(false) } var isKeyboardVisible by remember { mutableStateOf(false) }
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
@@ -205,6 +216,8 @@ fun MessageInputBar(
} }
fun toggleEmojiPicker() { fun toggleEmojiPicker() {
if (suppressKeyboard) return
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val timeSinceLastToggle = currentTime - lastToggleTime val timeSinceLastToggle = currentTime - lastToggleTime

View File

@@ -241,11 +241,17 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
} }
private fun emojiToUnified(emoji: String): String { private fun emojiToUnified(emoji: String): String {
return emoji.codePoints() val codePoints = emoji.codePoints().toArray()
.filter { it != 0xFE0F } if (codePoints.isEmpty()) return ""
.mapToObj { String.format("%04x", it) }
.toList() val unifiedParts = ArrayList<String>(codePoints.size)
.joinToString("-") 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 { private fun emojiToUnified(emoji: String): String {
return emoji.codePoints() val codePoints = emoji.codePoints().toArray()
.filter { it != 0xFE0F } if (codePoints.isEmpty()) return ""
.mapToObj { String.format("%04x", it) }
.toList() val unifiedParts = ArrayList<String>(codePoints.size)
.joinToString("-") for (codePoint in codePoints) {
if (codePoint != 0xFE0F) {
unifiedParts.add(String.format("%04x", codePoint))
}
}
return unifiedParts.joinToString("-")
} }
/** /**

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
@@ -405,7 +406,7 @@ private fun ProfileBlurPreview(
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// 🎨 COLOR GRID — сетка выбора цветов (8 в ряду) // 🎨 COLOR GRID — адаптивная сетка выбора цветов
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@Composable @Composable
@@ -415,33 +416,59 @@ private fun ColorSelectionGrid(
onSelect: (String) -> Unit onSelect: (String) -> Unit
) { ) {
val allOptions = BackgroundBlurPresets.allWithDefault 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 modifier = Modifier
.fillMaxWidth() .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 -> allOptions.chunked(columns).forEach { rowItems ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 6.dp), .padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.spacedBy(itemSpacing, Alignment.CenterHorizontally)
) { ) {
rowItems.forEach { option -> rowItems.forEach { option ->
ColorCircleItem( ColorCircleItem(
option = option, option = option,
isSelected = option.id == selectedId, isSelected = option.id == selectedId,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
circleSize = circleSize,
onClick = { onSelect(option.id) } onClick = { onSelect(option.id) }
) )
} }
repeat(columns - rowItems.size) { repeat(columns - rowItems.size) {
Spacer(modifier = Modifier.size(40.dp)) Spacer(modifier = Modifier.size(circleSize))
} }
} }
} }
}
} }
} }
@@ -450,10 +477,11 @@ private fun ColorCircleItem(
option: BackgroundBlurOption, option: BackgroundBlurOption,
isSelected: Boolean, isSelected: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
circleSize: Dp,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 1.0f, targetValue = if (isSelected) 1.08f else 1.0f,
animationSpec = tween(200), animationSpec = tween(200),
label = "scale" label = "scale"
) )
@@ -470,7 +498,7 @@ private fun ColorCircleItem(
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(circleSize)
.scale(scale) .scale(scale)
.clip(CircleShape) .clip(CircleShape)
.border( .border(
@@ -496,7 +524,7 @@ private fun ColorCircleItem(
imageVector = TablerIcons.X, imageVector = TablerIcons.X,
contentDescription = "None", contentDescription = "None",
tint = Color.White.copy(alpha = 0.9f), 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, imageVector = TablerIcons.CircleOff,
contentDescription = "Default", contentDescription = "Default",
tint = Color.White.copy(alpha = 0.9f), 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, painter = TelegramIcons.Done,
contentDescription = "Selected", contentDescription = "Selected",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(18.dp) modifier = Modifier.size((circleSize * 0.45f).coerceAtLeast(14.dp))
) )
} }
} }

View File

@@ -57,6 +57,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
@@ -196,6 +198,9 @@ fun OtherProfileScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme)
val isSafetyProfile = remember(user.publicKey) {
user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
}
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
val window = remember { (view.context as? Activity)?.window } val window = remember { (view.context as? Activity)?.window }
@@ -585,35 +590,37 @@ fun OtherProfileScreen(
) )
} }
// Call if (!isSafetyProfile) {
Button( // Call
onClick = { /* TODO: call action */ }, Button(
modifier = Modifier onClick = { /* TODO: call action */ },
.weight(1f) modifier = Modifier
.height(48.dp), .weight(1f)
shape = RoundedCornerShape(12.dp), .height(48.dp),
colors = ButtonDefaults.buttonColors( shape = RoundedCornerShape(12.dp),
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE8E8ED), colors = ButtonDefaults.buttonColors(
contentColor = if (isDarkTheme) Color.White else Color.Black containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE8E8ED),
), contentColor = if (isDarkTheme) Color.White else Color.Black
elevation = ButtonDefaults.buttonElevation( ),
defaultElevation = 0.dp, elevation = ButtonDefaults.buttonElevation(
pressedElevation = 0.dp defaultElevation = 0.dp,
pressedElevation = 0.dp
)
) {
Icon(
imageVector = TablerIcons.Phone,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (isDarkTheme) Color.White else PrimaryBlue
) )
) { Spacer(modifier = Modifier.width(8.dp))
Icon( Text(
imageVector = TablerIcons.Phone, text = "Call",
contentDescription = null, fontSize = 15.sp,
modifier = Modifier.size(20.dp), fontWeight = FontWeight.SemiBold,
tint = if (isDarkTheme) Color.White else PrimaryBlue 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 // 🔔 NOTIFICATIONS SECTION
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
TelegramToggleItem( if (!isSafetyProfile) {
icon = if (notificationsEnabled) TelegramIcons.Notifications else TelegramIcons.Mute, TelegramToggleItem(
title = "Notifications", icon = if (notificationsEnabled) TelegramIcons.Notifications else TelegramIcons.Mute,
subtitle = if (notificationsEnabled) "On" else "Off", title = "Notifications",
isEnabled = notificationsEnabled, subtitle = if (notificationsEnabled) "On" else "Off",
onToggle = { isEnabled = notificationsEnabled,
notificationsEnabled = !notificationsEnabled onToggle = {
coroutineScope.launch { notificationsEnabled = !notificationsEnabled
preferencesManager.setChatMuted( coroutineScope.launch {
activeAccountPublicKey, preferencesManager.setChatMuted(
user.publicKey, activeAccountPublicKey,
!notificationsEnabled user.publicKey,
) !notificationsEnabled
} )
}, }
isDarkTheme = isDarkTheme },
) isDarkTheme = isDarkTheme
)
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 📚 SHARED CONTENT (без разделителя — сразу табы) // 📚 SHARED CONTENT (без разделителя — сразу табы)
@@ -1862,7 +1871,14 @@ private fun CollapsingOtherProfileHeader(
.background(avatarColors.backgroundColor), .background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center 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( OtherProfileFullSizeAvatar(
publicKey = publicKey, publicKey = publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,

View File

@@ -437,6 +437,7 @@ fun ProfileScreen(
// Scroll state for collapsing header + overscroll avatar expansion // Scroll state for collapsing header + overscroll avatar expansion
val density = LocalDensity.current val density = LocalDensity.current
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
// Header heights // Header heights
val expandedHeightPx = with(density) { (EXPANDED_HEADER_HEIGHT + statusBarHeight).toPx() } val expandedHeightPx = with(density) { (EXPANDED_HEADER_HEIGHT + statusBarHeight).toPx() }
@@ -759,7 +760,11 @@ fun ProfileScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = collapsedHeightDp) contentPadding =
PaddingValues(
top = collapsedHeightDp,
bottom = navigationBarHeight + 28.dp
)
) { ) {
// Item 0: spacer = ровно сколько нужно проскроллить для collapse // Item 0: spacer = ровно сколько нужно проскроллить для collapse
item { item {
@@ -905,7 +910,7 @@ fun ProfileScreen(
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
TelegramLogoutItem(onClick = onLogout, isDarkTheme = isDarkTheme) 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()) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion // Expansion fraction — computed early so gradient can fade during expansion
val expandFraction = expansionProgress.coerceIn(0f, 1f) val expandFraction = expansionProgress.coerceIn(0f, 1f)
val headerBaseColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим // 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим
@@ -1134,12 +1140,13 @@ private fun CollapsingProfileHeader(
// и естественно перекрывает его. Без мерцания. // и естественно перекрывает его. Без мерцания.
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Box(modifier = Modifier.matchParentSize()) { Box(modifier = Modifier.matchParentSize()) {
Box(modifier = Modifier.matchParentSize().background(headerBaseColor))
if (backgroundBlurColorId == "none") { if (backgroundBlurColorId == "none") {
// None — стандартный цвет шапки без blur // None — стандартный цвет шапки без blur
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)) .background(headerBaseColor)
) )
} else { } else {
BlurredAvatarBackground( BlurredAvatarBackground(
@@ -1147,7 +1154,7 @@ private fun CollapsingProfileHeader(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor, fallbackColor = avatarColors.backgroundColor,
blurRadius = 20f, blurRadius = 20f,
alpha = 0.9f, alpha = 1f,
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId), overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
@@ -1606,38 +1613,13 @@ private fun TelegramTextField(
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
val hasError = !errorText.isNullOrBlank() val hasError = !errorText.isNullOrBlank()
val errorColor = Color(0xFFFF3B30) val errorColor = Color(0xFFFF3B30)
val labelText = if (hasError) errorText.orEmpty() else label
val labelColor by val labelColor by
animateColorAsState( animateColorAsState(
targetValue = if (hasError) errorColor else secondaryTextColor, targetValue = if (hasError) errorColor else secondaryTextColor,
label = "profile_field_label_color" label = "profile_field_label_color"
) )
val containerColor by val fieldModifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)
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)
}
Column { Column {
Column(modifier = fieldModifier) { Column(modifier = fieldModifier) {
@@ -1672,17 +1654,7 @@ private fun TelegramTextField(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text(text = label, fontSize = 13.sp, color = labelColor) Text(text = labelText, fontSize = 13.sp, color = labelColor)
if (hasError) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = errorText ?: "",
fontSize = 12.sp,
color = errorColor,
lineHeight = 14.sp
)
}
} }
if (showDivider) { if (showDivider) {

View File

@@ -11,7 +11,6 @@ import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream
private const val TAG = "MediaUtils" private const val TAG = "MediaUtils"
@@ -36,22 +35,34 @@ 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 {
// Читаем EXIF ориентацию // Читаем EXIF ориентацию
val orientation = getExifOrientation(context, uri) val orientation = getExifOrientation(context, uri)
// Открываем InputStream val boundsOptions =
val inputStream: InputStream = context.contentResolver.openInputStream(uri) BitmapFactory.Options().apply { inJustDecodeBounds = true }
?: return@withContext null context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream, null, boundsOptions)
} ?: return@withContext null
// Декодируем изображение if (boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) {
var bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
if (bitmap == null) {
return@withContext null 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 ориентацию (поворот/отражение) // Применяем EXIF ориентацию (поворот/отражение)
bitmap = applyExifOrientation(bitmap, orientation) bitmap = applyExifOrientation(bitmap, orientation)
@@ -74,6 +85,8 @@ object MediaUtils {
base64 base64
} catch (e: Exception) { } catch (e: Exception) {
null null
} catch (e: OutOfMemoryError) {
null
} }
} }
@@ -237,6 +250,14 @@ object MediaUtils {
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) 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 * Конвертировать Bitmap в Base64 PNG
*/ */