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:
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 для конвертации
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 не изменился - не отправляем повторно
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("-")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user