Хотфиксы чатов: камера, эмодзи и стабильность синхронизации
All checks were successful
Android Kernel Build / build (push) Successful in 20m11s

This commit is contained in:
2026-04-17 14:33:46 +05:00
parent 17f37b06ec
commit 1cf645ea3f
7 changed files with 1060 additions and 266 deletions

View File

@@ -4,8 +4,10 @@ import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -30,11 +32,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.lifecycleScope
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
@@ -106,6 +111,8 @@ class MainActivity : FragmentActivity() {
companion object {
private const val TAG = "MainActivity"
private const val FULL_SCREEN_INTENT_PREFS = "full_screen_intent_prefs"
private const val FULL_SCREEN_INTENT_PROMPT_SHOWN_KEY = "full_screen_intent_prompt_shown"
// Process-memory session cache: lets app return without password while process is alive.
private var cachedDecryptedAccount: DecryptedAccount? = null
@@ -168,6 +175,24 @@ class MainActivity : FragmentActivity() {
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted -> }
)
val lifecycleOwner = LocalLifecycleOwner.current
var canUseFullScreenIntent by remember { mutableStateOf(true) }
var showFullScreenIntentDialog by remember { mutableStateOf(false) }
val refreshFullScreenIntentState = remember {
{
if (Build.VERSION.SDK_INT < 34) {
canUseFullScreenIntent = true
showFullScreenIntentDialog = false
} else {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
val canUse = notificationManager.canUseFullScreenIntent()
canUseFullScreenIntent = canUse
showFullScreenIntentDialog = !canUse && !wasFullScreenIntentPromptShown()
}
}
}
// Запрашиваем разрешение при первом запуске (Android 13+)
LaunchedEffect(Unit) {
@@ -184,20 +209,18 @@ class MainActivity : FragmentActivity() {
)
}
}
// Android 14+: запрос fullScreenIntent для входящих звонков
if (Build.VERSION.SDK_INT >= 34) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
if (!nm.canUseFullScreenIntent()) {
try {
startActivity(
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
android.net.Uri.parse("package:$packageName")
)
)
} catch (_: Throwable) {}
refreshFullScreenIntentState()
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
refreshFullScreenIntentState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
@@ -266,6 +289,61 @@ class MainActivity : FragmentActivity() {
modifier = Modifier.fillMaxSize(),
color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White
) {
if (Build.VERSION.SDK_INT >= 34 && showFullScreenIntentDialog && !canUseFullScreenIntent) {
AlertDialog(
onDismissRequest = {
markFullScreenIntentPromptShown()
showFullScreenIntentDialog = false
},
containerColor = if (isDarkTheme) Color(0xFF1E1E20) else Color.White,
title = {
Text(
text = "Allow full-screen call alerts?",
color = if (isDarkTheme) Color.White else Color.Black
)
},
text = {
Text(
text = "Without this permission incoming calls may show only as a regular notification.",
color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66)
)
},
confirmButton = {
TextButton(
onClick = {
markFullScreenIntentPromptShown()
showFullScreenIntentDialog = false
runCatching {
startActivity(
Intent(
Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:$packageName")
)
)
}
}
) {
Text(
text = "Open settings",
color = if (isDarkTheme) Color(0xFF5FA8FF) else Color(0xFF0D8CF4)
)
}
},
dismissButton = {
TextButton(
onClick = {
markFullScreenIntentPromptShown()
showFullScreenIntentDialog = false
}
) {
Text(
text = "Not now",
color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66)
)
}
}
)
}
AnimatedContent(
targetState =
when {
@@ -734,6 +812,18 @@ class MainActivity : FragmentActivity() {
prefs.edit().putString("fcm_token", token).apply()
}
private fun wasFullScreenIntentPromptShown(): Boolean {
return getSharedPreferences(FULL_SCREEN_INTENT_PREFS, MODE_PRIVATE)
.getBoolean(FULL_SCREEN_INTENT_PROMPT_SHOWN_KEY, false)
}
private fun markFullScreenIntentPromptShown() {
getSharedPreferences(FULL_SCREEN_INTENT_PREFS, MODE_PRIVATE)
.edit()
.putBoolean(FULL_SCREEN_INTENT_PROMPT_SHOWN_KEY, true)
.apply()
}
}
private fun buildInitials(displayName: String): String =

View File

@@ -290,13 +290,17 @@ private fun GroupRejoinRequiredState(
text = "Join Group Again",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = titleColor
color = titleColor,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Group sync key is missing. Rejoin this group to load messages.",
fontSize = 14.sp,
color = subtitleColor
color = subtitleColor,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@@ -18,6 +18,7 @@ import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger
import com.rosetta.messenger.utils.MessageThrottleManager
import java.io.File
import java.util.Date
import java.util.Locale
import java.util.UUID
@@ -51,6 +52,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
private val chatMessageDescComparator =
compareByDescending<ChatMessage> { it.timestamp.time }
private val forwardUuidRegex =
Regex(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
@@ -1616,28 +1621,169 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun parseAttachmentType(attachment: JSONObject): AttachmentType {
val rawType = attachment.opt("type")
val typeValue =
when (rawType) {
val typeValue = parseAttachmentTypeValue(attachment.opt("type"))
val parsedType = AttachmentType.fromInt(typeValue)
if (parsedType != AttachmentType.UNKNOWN) {
return parsedType
}
val preview = attachment.optString("preview", "")
val blob = attachment.optString("blob", "")
val width = attachment.optInt("width", 0)
val height = attachment.optInt("height", 0)
val attachmentId = attachment.optString("id", "")
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
return inferLegacyAttachmentType(
attachmentId = attachmentId,
preview = preview,
blob = blob,
width = width,
height = height,
transportTag = transportTag
)
}
private fun parseAttachmentTypeValue(rawType: Any?): Int {
return when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"image" -> AttachmentType.IMAGE.value
"messages", "reply", "forward" -> AttachmentType.MESSAGES.value
"file" -> AttachmentType.FILE.value
"avatar" -> AttachmentType.AVATAR.value
"call" -> AttachmentType.CALL.value
"voice" -> AttachmentType.VOICE.value
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
?: run {
val token =
normalized.lowercase(Locale.ROOT)
.replace('-', '_')
.replace(' ', '_')
when (token) {
"image", "photo", "picture" -> AttachmentType.IMAGE.value
"messages", "message", "reply", "forward", "forwarded" ->
AttachmentType.MESSAGES.value
"file", "document", "doc" -> AttachmentType.FILE.value
"avatar", "profile_photo", "profile_avatar" -> AttachmentType.AVATAR.value
"call", "phone_call" -> AttachmentType.CALL.value
"voice", "voice_message", "voice_note", "audio", "audio_message", "audio_note", "audiomessage", "audionote" ->
AttachmentType.VOICE.value
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video_message", "video" ->
AttachmentType.VIDEO_CIRCLE.value
else -> -1
}
}
}
else -> -1
}
return AttachmentType.fromInt(typeValue)
}
private fun inferLegacyAttachmentType(
attachmentId: String,
preview: String,
blob: String,
width: Int,
height: Int,
transportTag: String
): AttachmentType {
if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) {
return AttachmentType.MESSAGES
}
if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) {
return AttachmentType.CALL
}
if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) {
return AttachmentType.VIDEO_CIRCLE
}
if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) {
return AttachmentType.VOICE
}
if (width > 0 || height > 0) {
return AttachmentType.IMAGE
}
if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) {
return AttachmentType.FILE
}
return AttachmentType.UNKNOWN
}
private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
val payload = blob.ifBlank { preview }.trim()
if (payload.isEmpty()) return false
if (!payload.startsWith("{") && !payload.startsWith("[")) return false
val objectCandidate =
runCatching {
if (payload.startsWith("[")) {
JSONArray(payload).optJSONObject(0)
} else {
JSONObject(payload)
}
}
.getOrNull()
?: return false
return objectCandidate.has("message_id") ||
objectCandidate.has("publicKey") ||
objectCandidate.has("message") ||
objectCandidate.has("attachments")
}
private fun isLikelyVoiceAttachmentPreview(preview: String, attachmentId: String): Boolean {
val id = attachmentId.trim().lowercase(Locale.ROOT)
if (id.startsWith("voice_") || id.startsWith("voice-") || id.startsWith("audio_") || id.startsWith("audio-")) {
return true
}
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
if (duration < 0) return false
val tail = normalized.substringAfter("::", "").trim()
if (tail.isEmpty()) return true
if (tail.startsWith("video/", ignoreCase = true)) return false
if (tail.startsWith("audio/", ignoreCase = true)) return true
if (!tail.contains(",")) return false
val values = tail.split(",")
return values.size >= 2 && values.all { it.trim().toFloatOrNull() != null }
}
private fun isLikelyVideoCircleAttachmentPreview(preview: String, attachmentId: String): Boolean {
val id = attachmentId.trim().lowercase(Locale.ROOT)
if (id.startsWith("video_circle_") || id.startsWith("video-circle-")) {
return true
}
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
if (duration < 0) return false
val mime = normalized.substringAfter("::", "").trim().lowercase(Locale.ROOT)
return mime.startsWith("video/")
}
private fun isLikelyFileAttachmentPreview(preview: String): Boolean {
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val parts = normalized.split("::")
if (parts.size < 2) return false
val first = parts[0]
return when {
parts.size >= 3 && forwardUuidRegex.matches(first) && parts[1].toLongOrNull() != null -> true
parts.size >= 2 && first.toLongOrNull() != null -> true
parts.size >= 2 && forwardUuidRegex.matches(first) -> true
else -> false
}
}
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
@@ -2486,6 +2632,249 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
}
private data class ForwardSourceMessage(
val messageId: String,
val senderPublicKey: String,
val chachaKeyPlainHex: String,
val attachments: List<MessageAttachment>
)
private data class ForwardRewriteResult(
val rewrittenAttachments: Map<String, MessageAttachment>,
val rewrittenMessageIds: Set<String>
)
private fun forwardAttachmentRewriteKey(messageId: String, attachmentId: String): String {
return "$messageId::$attachmentId"
}
private fun shouldReuploadForwardAttachment(type: AttachmentType): Boolean {
return type == AttachmentType.IMAGE ||
type == AttachmentType.FILE ||
type == AttachmentType.VOICE ||
type == AttachmentType.VIDEO_CIRCLE
}
private fun decodeHexBytes(value: String): ByteArray? {
val normalized = value.trim().lowercase(Locale.ROOT)
if (normalized.isEmpty() || normalized.length % 2 != 0) return null
return runCatching {
ByteArray(normalized.length / 2) { index ->
normalized.substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}
.getOrNull()
?.takeIf { it.isNotEmpty() }
}
private fun extractForwardFileName(preview: String): String {
val normalized = preview.trim()
if (normalized.isEmpty()) return ""
val parts = normalized.split("::")
return when {
parts.size >= 3 && forwardUuidRegex.matches(parts[0]) -> {
parts.drop(2).joinToString("::").trim()
}
parts.size >= 2 -> {
parts.drop(1).joinToString("::").trim()
}
else -> normalized
}
}
private fun decodeBase64PayloadForForward(value: String): ByteArray? {
val normalized = value.trim()
if (normalized.isEmpty()) return null
val payload =
when {
normalized.contains("base64,", ignoreCase = true) -> {
normalized.substringAfter("base64,", "")
}
normalized.substringBefore(",").contains("base64", ignoreCase = true) -> {
normalized.substringAfter(",", "")
}
else -> normalized
}
if (payload.isEmpty()) return null
return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
}
private suspend fun resolveForwardAttachmentPayload(
context: Application,
sourceMessage: ForwardSourceMessage,
attachment: MessageAttachment,
privateKey: String
): String? {
if (attachment.blob.isNotBlank()) {
return attachment.blob
}
if (attachment.id.isBlank()) {
return null
}
val normalizedPublicKey =
sourceMessage.senderPublicKey.ifBlank {
myPublicKey?.trim().orEmpty()
}
if (normalizedPublicKey.isNotBlank()) {
val cachedPayload =
AttachmentFileManager.readAttachment(
context = context,
attachmentId = attachment.id,
publicKey = normalizedPublicKey,
privateKey = privateKey
)
if (!cachedPayload.isNullOrBlank()) {
return cachedPayload
}
}
if (attachment.type == AttachmentType.FILE) {
val fileName = extractForwardFileName(attachment.preview)
if (fileName.isNotBlank()) {
val localFile = File(context.filesDir, "rosetta_downloads/$fileName")
if (localFile.exists() && localFile.length() > 0L) {
return runCatching {
Base64.encodeToString(localFile.readBytes(), Base64.NO_WRAP)
}
.getOrNull()
}
}
}
val downloadTag = attachment.transportTag.trim()
val plainKey = decodeHexBytes(sourceMessage.chachaKeyPlainHex)
if (downloadTag.isBlank() || plainKey == null) {
return null
}
val encrypted =
runCatching {
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer.ifBlank { null }
)
}
.getOrNull()
.orEmpty()
if (encrypted.isBlank()) {
return null
}
return MessageCrypto.decryptAttachmentBlobWithPlainKey(encrypted, plainKey)
?: MessageCrypto.decryptReplyBlob(encrypted, plainKey).takeIf { it.isNotEmpty() }
}
private suspend fun prepareForwardAttachmentRewrites(
context: Application,
sourceMessages: List<ForwardSourceMessage>,
encryptionContext: OutgoingEncryptionContext,
privateKey: String,
isSavedMessages: Boolean,
timestamp: Long
): ForwardRewriteResult {
if (sourceMessages.isEmpty()) {
return ForwardRewriteResult(emptyMap(), emptySet())
}
val rewritten = mutableMapOf<String, MessageAttachment>()
val rewrittenMessageIds = mutableSetOf<String>()
var forwardAttachmentIndex = 0
for (sourceMessage in sourceMessages) {
val candidates = sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) }
if (candidates.isEmpty()) continue
val stagedForMessage = mutableMapOf<String, MessageAttachment>()
var allRewritten = true
for (attachment in candidates) {
val payload =
resolveForwardAttachmentPayload(
context = context,
sourceMessage = sourceMessage,
attachment = attachment,
privateKey = privateKey
)
if (payload.isNullOrBlank()) {
allRewritten = false
break
}
val encryptedBlob = encryptAttachmentPayload(payload, encryptionContext)
val newAttachmentId = "fwd_${timestamp}_${forwardAttachmentIndex++}"
val uploadTag =
if (!isSavedMessages) {
runCatching { TransportManager.uploadFile(newAttachmentId, encryptedBlob) }
.getOrDefault("")
} else {
""
}
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val normalizedPreview =
if (attachment.type == AttachmentType.IMAGE) {
attachment.preview.substringAfter("::", attachment.preview)
} else {
attachment.preview
}
stagedForMessage[forwardAttachmentRewriteKey(sourceMessage.messageId, attachment.id)] =
attachment.copy(
id = newAttachmentId,
preview = normalizedPreview,
blob = "",
localUri = "",
transportTag = uploadTag,
transportServer = transportServer
)
if (attachment.type == AttachmentType.IMAGE ||
attachment.type == AttachmentType.VOICE ||
attachment.type == AttachmentType.VIDEO_CIRCLE) {
runCatching {
AttachmentFileManager.saveAttachment(
context = context,
blob = payload,
attachmentId = newAttachmentId,
publicKey =
sourceMessage.senderPublicKey.ifBlank {
myPublicKey?.trim().orEmpty()
},
privateKey = privateKey
)
}
}
if (isSavedMessages && attachment.type == AttachmentType.FILE) {
val fileName = extractForwardFileName(attachment.preview)
val payloadBytes = decodeBase64PayloadForForward(payload)
if (fileName.isNotBlank() && payloadBytes != null) {
runCatching {
val downloadsDir = File(context.filesDir, "rosetta_downloads").apply { mkdirs() }
File(downloadsDir, fileName).writeBytes(payloadBytes)
}
}
}
}
if (allRewritten && stagedForMessage.size == candidates.size) {
rewritten.putAll(stagedForMessage)
rewrittenMessageIds.add(sourceMessage.messageId)
}
}
return ForwardRewriteResult(
rewrittenAttachments = rewritten,
rewrittenMessageIds = rewrittenMessageIds
)
}
/** Обновить текст ввода */
fun updateInputText(text: String) {
if (_inputText.value == text) return
@@ -3050,65 +3439,35 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageAttachments = mutableListOf<MessageAttachment>()
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
// Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
val isSavedMessages = (sender == recipient)
val forwardSources =
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
val context = getApplication<Application>()
val isSaved = (sender == recipient)
var fwdIdx = 0
for (msg in replyMsgsToSend) {
for (att in msg.attachments) {
if (att.type == AttachmentType.IMAGE) {
try {
val imageBlob = AttachmentFileManager.readAttachment(
context = context,
attachmentId = att.id,
publicKey = msg.publicKey,
privateKey = privateKey
replyMsgsToSend.map { msg ->
ForwardSourceMessage(
messageId = msg.messageId,
senderPublicKey = msg.publicKey,
chachaKeyPlainHex = msg.chachaKeyPlainHex,
attachments = msg.attachments
)
if (imageBlob != null) {
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = ""
if (!isSaved) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
}
val blurhash = att.preview.substringAfter("::", att.preview)
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
emptyList()
}
forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
val forwardRewriteResult =
prepareForwardAttachmentRewrites(
context = getApplication(),
sourceMessages = forwardSources,
encryptionContext = encryptionContext,
privateKey = privateKey,
isSavedMessages = isSavedMessages,
timestamp = timestamp
)
// Сохраняем локально с новым ID
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
AttachmentFileManager.saveAttachment(
context = context,
blob = imageBlob,
attachmentId = newAttId,
publicKey = msg.publicKey,
privateKey = privateKey
)
}
} catch (e: Exception) { }
}
}
}
}
val forwardedAttMap = forwardRewriteResult.rewrittenAttachments
val rewrittenForwardMessageIds = forwardRewriteResult.rewrittenMessageIds
val outgoingForwardPlainKeyHex =
encryptionContext.plainKeyAndNonce
?.joinToString("") { "%02x".format(it) }
.orEmpty()
if (replyMsgsToSend.isNotEmpty()) {
@@ -3118,7 +3477,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attachmentsArray = JSONArray()
msg.attachments.forEach { att ->
// Для forward IMAGE: подставляем НОВЫЙ id/preview/transport.
val fwdInfo = forwardedAttMap[att.id]
val fwdInfo =
forwardedAttMap[
forwardAttachmentRewriteKey(
msg.messageId,
att.id
)
]
val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
@@ -3159,8 +3524,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isForwardToSend) {
put("forwarded", true)
put("senderName", msg.senderName)
if (msg.chachaKeyPlainHex.isNotEmpty()) {
put("chacha_key_plain", msg.chachaKeyPlainHex)
val effectiveForwardPlainKey =
if (msg.messageId in rewrittenForwardMessageIds &&
outgoingForwardPlainKeyHex.isNotEmpty()
) {
outgoingForwardPlainKeyHex
} else {
msg.chachaKeyPlainHex
}
if (effectiveForwardPlainKey.isNotEmpty()) {
put(
"chacha_key_plain",
effectiveForwardPlainKey
)
}
}
}
@@ -3202,7 +3578,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
packet.attachments.forEachIndexed { idx, att -> }
// 📁 Для Saved Messages - НЕ отправляем пакет на сервер
val isSavedMessages = (sender == recipient)
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
@@ -3327,6 +3702,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val replyAttachmentId = "reply_${timestamp}"
val forwardSources =
forwardMessages.map { message ->
ForwardSourceMessage(
messageId = message.messageId,
senderPublicKey = message.senderPublicKey,
chachaKeyPlainHex = message.chachaKeyPlain,
attachments = message.attachments
)
}
val forwardRewriteResult =
prepareForwardAttachmentRewrites(
context = context,
sourceMessages = forwardSources,
encryptionContext = encryptionContext,
privateKey = privateKey,
isSavedMessages = isSavedMessages,
timestamp = timestamp
)
val forwardedAttMap = forwardRewriteResult.rewrittenAttachments
val rewrittenForwardMessageIds = forwardRewriteResult.rewrittenMessageIds
val outgoingForwardPlainKeyHex =
encryptionContext.plainKeyAndNonce
?.joinToString("") { "%02x".format(it) }
.orEmpty()
fun buildForwardReplyJson(
includeLocalUri: Boolean
@@ -3335,29 +3734,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
val fwdInfo =
forwardedAttMap[
forwardAttachmentRewriteKey(
fm.messageId,
att.id
)
]
val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer =
fwdInfo?.transportServer ?: att.transportServer
val attLocalUri = if (includeLocalUri) fwdInfo?.localUri ?: att.localUri else ""
attachmentsArray.put(
JSONObject().apply {
put("id", att.id)
put("id", attId)
put("type", att.type.value)
put("preview", att.preview)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", "")
put("transportTag", att.transportTag)
put("transportServer", att.transportServer)
put("transportTag", attTransportTag)
put("transportServer", attTransportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", att.transportTag)
put("transport_server", att.transportServer)
put("transport_tag", attTransportTag)
put("transport_server", attTransportServer)
}
)
if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri)
if (includeLocalUri && attLocalUri.isNotEmpty()) {
put("localUri", attLocalUri)
}
}
)
}
val effectiveForwardPlainKey =
if (fm.messageId in rewrittenForwardMessageIds &&
outgoingForwardPlainKeyHex.isNotEmpty()
) {
outgoingForwardPlainKeyHex
} else {
fm.chachaKeyPlain
}
replyJsonArray.put(
JSONObject().apply {
put("message_id", fm.messageId)
@@ -3367,8 +3787,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
if (fm.chachaKeyPlain.isNotEmpty()) {
put("chacha_key_plain", fm.chachaKeyPlain)
if (effectiveForwardPlainKey.isNotEmpty()) {
put("chacha_key_plain", effectiveForwardPlainKey)
}
}
)

View File

@@ -146,6 +146,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
private val groupJoinedMarker = "\$a=Group joined"
private val groupCreatedMarker = "\$a=Group created"
private val attachmentTagUuidRegex =
Regex(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
private fun isGroupKey(value: String): Boolean {
val normalized = value.trim().lowercase()
@@ -665,24 +669,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
if (attachments.length() <= 0) return -1
val first = attachments.optJSONObject(0) ?: return -1
val rawType = first.opt("type")
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"image" -> 0
"messages", "reply", "forward" -> 1
"file" -> 2
"avatar" -> 3
"call" -> 4
"voice" -> 5
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
else -> -1
}
}
else -> -1
val parsedType = parseAttachmentTypeValue(first.opt("type"))
if (parsedType in 0..6) {
parsedType
} else {
inferLegacyAttachmentTypeFromJson(first)
}
} catch (_: Throwable) {
-1
@@ -696,31 +687,146 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (attachments.length() != 1) return false
val first = attachments.optJSONObject(0) ?: return false
val rawType = first.opt("type")
val typeValue =
when (rawType) {
val typeValue = parseAttachmentTypeValue(first.opt("type"))
if (typeValue == 4) return true
val preview = first.optString("preview", "").trim()
isLikelyCallAttachmentPreview(preview)
} catch (_: Throwable) {
false
}
}
private fun parseAttachmentTypeValue(rawType: Any?): Int {
return when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"call" -> 4
?: run {
val token =
normalized.lowercase(Locale.ROOT)
.replace('-', '_')
.replace(' ', '_')
when (token) {
"image", "photo", "picture" -> 0
"messages", "message", "reply", "forward", "forwarded" -> 1
"file", "document", "doc" -> 2
"avatar", "profile_photo", "profile_avatar" -> 3
"call", "phone_call" -> 4
"voice", "voice_message", "voice_note", "audio", "audio_message", "audio_note", "audiomessage", "audionote" -> 5
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video_message", "video" -> 6
else -> -1
}
}
}
else -> -1
}
if (typeValue == 4) return true
}
val preview = first.optString("preview", "").trim()
if (preview.isEmpty()) return false
val tail = preview.substringAfterLast("::", preview).trim()
private fun inferLegacyAttachmentTypeFromJson(attachment: JSONObject): Int {
val preview = attachment.optString("preview", "")
val blob = attachment.optString("blob", "")
val width = attachment.optInt("width", 0)
val height = attachment.optInt("height", 0)
val attachmentId = attachment.optString("id", "")
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) return 1
if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) return 4
if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 6
if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 5
if (width > 0 || height > 0) return 0
if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) return 2
return -1
}
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
if (preview.isBlank()) return false
val normalized = preview.trim()
val tail = normalized.substringAfterLast("::", normalized).trim()
if (tail.toIntOrNull() != null) return true
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(normalized)
}
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(preview)
} catch (_: Throwable) {
false
private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
val payload = blob.ifBlank { preview }.trim()
if (payload.isEmpty()) return false
if (!payload.startsWith("{") && !payload.startsWith("[")) return false
val objectCandidate =
runCatching {
if (payload.startsWith("[")) JSONArray(payload).optJSONObject(0)
else JSONObject(payload)
}
.getOrNull()
?: return false
return objectCandidate.has("message_id") ||
objectCandidate.has("publicKey") ||
objectCandidate.has("message") ||
objectCandidate.has("attachments")
}
private fun isLikelyVoiceAttachmentPreview(preview: String, attachmentId: String): Boolean {
val id = attachmentId.trim().lowercase(Locale.ROOT)
if (id.startsWith("voice_") || id.startsWith("voice-") || id.startsWith("audio_") || id.startsWith("audio-")) {
return true
}
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
if (duration < 0) return false
val tail = normalized.substringAfter("::", "").trim()
if (tail.isEmpty()) return true
if (tail.startsWith("video/", ignoreCase = true)) return false
if (tail.startsWith("audio/", ignoreCase = true)) return true
if (!tail.contains(",")) return false
val values = tail.split(",")
return values.size >= 2 && values.all { it.trim().toFloatOrNull() != null }
}
private fun isLikelyVideoCircleAttachmentPreview(preview: String, attachmentId: String): Boolean {
val id = attachmentId.trim().lowercase(Locale.ROOT)
if (id.startsWith("video_circle_") || id.startsWith("video-circle-")) {
return true
}
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
if (duration < 0) return false
val mime = normalized.substringAfter("::", "").trim().lowercase(Locale.ROOT)
return mime.startsWith("video/")
}
private fun isLikelyFileAttachmentPreview(preview: String): Boolean {
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val parts = normalized.split("::")
if (parts.size < 2) return false
val first = parts[0]
return when {
parts.size >= 3 && attachmentTagUuidRegex.matches(first) && parts[1].toLongOrNull() != null -> true
parts.size >= 2 && first.toLongOrNull() != null -> true
parts.size >= 2 && attachmentTagUuidRegex.matches(first) -> true
else -> false
}
}

View File

@@ -1026,12 +1026,22 @@ fun MessageBubble(
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey,
dialogPublicKey = dialogPublicKey,
currentUserPublicKey = currentUserPublicKey,
senderDisplayName = senderName,
timestamp = message.timestamp,
messageStatus = message.status,
linksEnabled = linksEnabled,
onImageClick = onImageClick,
onForwardedSenderClick = onForwardedSenderClick,
onMentionClick = onMentionClick,
onTextSpanPressStart = suppressBubbleTapFromSpan
onTextSpanPressStart = suppressBubbleTapFromSpan,
onVoiceWaveGestureActiveChanged = { active ->
isVoiceWaveGestureActive = active
onVoiceWaveGestureActiveChanged(active)
}
)
Spacer(modifier = Modifier.height(4.dp))
}
@@ -2544,12 +2554,19 @@ fun ForwardedMessagesBubble(
isOutgoing: Boolean,
isDarkTheme: Boolean,
chachaKey: String = "",
chachaKeyPlainHex: String = "",
privateKey: String = "",
dialogPublicKey: String = "",
currentUserPublicKey: String = "",
senderDisplayName: String = "",
timestamp: Date = Date(),
messageStatus: MessageStatus = MessageStatus.READ,
linksEnabled: Boolean = true,
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {},
onTextSpanPressStart: (() -> Unit)? = null
onTextSpanPressStart: (() -> Unit)? = null,
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}
) {
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val backgroundColor =
@@ -2646,6 +2663,37 @@ fun ForwardedMessagesBubble(
}
}
val nonImageAttachments =
fwd.attachments.filter {
it.type != AttachmentType.IMAGE &&
it.type != AttachmentType.MESSAGES
}
if (nonImageAttachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
MessageAttachments(
attachments = nonImageAttachments,
chachaKey = fwd.chachaKey.ifEmpty { chachaKey },
chachaKeyPlainHex =
fwd.chachaKeyPlainHex.ifEmpty {
chachaKeyPlainHex
},
privateKey = fwd.recipientPrivateKey.ifEmpty { privateKey },
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
senderPublicKey = fwd.senderPublicKey,
senderDisplayName =
fwd.forwardedFromName.ifEmpty {
senderDisplayName
},
dialogPublicKey = dialogPublicKey,
timestamp = timestamp,
messageStatus = messageStatus,
currentUserPublicKey = currentUserPublicKey,
onVoiceWaveGestureActiveChanged =
onVoiceWaveGestureActiveChanged
)
}
// Message text (below image, like a caption)
if (fwd.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))

View File

@@ -1,13 +1,20 @@
package com.rosetta.messenger.ui.chats.components
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.util.Log
import android.view.ScaleGestureDetector
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
@@ -48,7 +55,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
@@ -56,9 +67,7 @@ import java.text.SimpleDateFormat
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import android.app.Activity
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
@@ -76,6 +85,7 @@ fun InAppCameraScreen(
onPhotoTaken: (Uri) -> Unit // Вызывается с URI сделанного фото
) {
val context = LocalContext.current
val activity = context as? Activity
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val view = LocalView.current
@@ -89,11 +99,69 @@ fun InAppCameraScreen(
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) }
var isCapturing by remember { mutableStateOf(false) }
var hasCameraPermission by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var hasRequestedCameraPermission by remember { mutableStateOf(false) }
val cameraPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
granted ->
hasRequestedCameraPermission = true
hasCameraPermission = granted
}
val isPermissionPermanentlyDenied =
remember(hasCameraPermission, hasRequestedCameraPermission, activity) {
hasRequestedCameraPermission &&
!hasCameraPermission &&
activity != null &&
!ActivityCompat.shouldShowRequestPermissionRationale(
activity,
Manifest.permission.CAMERA
)
}
fun requestCameraPermission() {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
fun openAppSettings() {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
runCatching { context.startActivity(intent) }
}
LaunchedEffect(preferencesManager) {
flashMode = preferencesManager.getCameraFlashMode()
}
LaunchedEffect(Unit) {
if (!hasCameraPermission) {
requestCameraPermission()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasCameraPermission =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Camera references
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
@@ -151,7 +219,6 @@ fun InAppCameraScreen(
// ═══════════════════════════════════════════════════════════════
// 🎨 Status bar (черный как в ImageEditorScreen)
// ═══════════════════════════════════════════════════════════════
val activity = context as? Activity
val window = activity?.window
val originalSoftInputMode = remember(window) {
window?.attributes?.softInputMode ?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
@@ -207,6 +274,10 @@ fun InAppCameraScreen(
// Take photo function
fun takePhoto() {
if (!hasCameraPermission) {
requestCameraPermission()
return
}
val capture = imageCapture ?: return
if (isCapturing) return
@@ -245,7 +316,15 @@ fun InAppCameraScreen(
var previewView by remember { mutableStateOf<PreviewView?>(null) }
// Bind camera when previewView or lensFacing changes (NOT flashMode — that causes flicker)
LaunchedEffect(previewView, lensFacing) {
LaunchedEffect(previewView, lensFacing, hasCameraPermission) {
if (!hasCameraPermission) {
cameraProvider?.unbindAll()
cameraProvider = null
camera = null
imageCapture = null
return@LaunchedEffect
}
val pv = previewView ?: return@LaunchedEffect
val provider = context.getCameraProvider()
@@ -296,6 +375,7 @@ fun InAppCameraScreen(
.graphicsLayer { alpha = animationProgress.value }
.background(Color.Black)
) {
if (hasCameraPermission) {
// Camera Preview with pinch-to-zoom
AndroidView(
factory = { ctx ->
@@ -336,6 +416,13 @@ fun InAppCameraScreen(
},
modifier = Modifier.fillMaxSize()
)
} else {
CameraPermissionContent(
permanentlyDenied = isPermissionPermanentlyDenied,
onGrantAccess = { requestCameraPermission() },
onOpenSettings = { openAppSettings() }
)
}
// Top controls (Close + Flash)
Row(
@@ -360,6 +447,7 @@ fun InAppCameraScreen(
}
// Flash button
if (hasCameraPermission) {
IconButton(
onClick = {
val nextFlashMode = when (flashMode) {
@@ -386,6 +474,9 @@ fun InAppCameraScreen(
tint = Color.White
)
}
} else {
Spacer(modifier = Modifier.size(44.dp))
}
}
// ── Zoom slider with /+ buttons (Telegram-style) ──
@@ -396,7 +487,7 @@ fun InAppCameraScreen(
val zoomLabel = "%.1fx".format(currentZoomRatio)
// Only show slider if camera supports zoom range > 1
if (maxZoomRatio > minZoomRatio + 0.1f) {
if (hasCameraPermission && maxZoomRatio > minZoomRatio + 0.1f) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -494,6 +585,7 @@ fun InAppCameraScreen(
}
// Bottom controls (Shutter + Flip)
if (hasCameraPermission) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -551,6 +643,45 @@ fun InAppCameraScreen(
}
}
}
}
@Composable
private fun CameraPermissionContent(
permanentlyDenied: Boolean,
onGrantAccess: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Camera access is required",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text =
if (permanentlyDenied)
"Permission is denied permanently. Open settings and allow camera access."
else "Allow camera permission to use in-app camera.",
color = Color.White.copy(alpha = 0.78f),
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = if (permanentlyDenied) onOpenSettings else onGrantAccess,
shape = RoundedCornerShape(24.dp)
) {
Text(if (permanentlyDenied) "Open settings" else "Grant access")
}
}
}
/**
* Получить CameraProvider с suspend

View File

@@ -32,7 +32,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@@ -60,13 +59,10 @@ fun OptimizedEmojiPicker(
onClose: () -> Unit = {},
modifier: Modifier = Modifier
) {
val savedKeyboardHeight = rememberSavedKeyboardHeight()
// 🔥 Рендерим напрямую без лишних обёрток
EmojiPickerContent(
isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected,
keyboardHeight = savedKeyboardHeight,
modifier = modifier
)
}
@@ -85,7 +81,6 @@ private class StableCallback(val onClick: (String) -> Unit)
private fun EmojiPickerContent(
isDarkTheme: Boolean,
onEmojiSelected: (String) -> Unit,
keyboardHeight: Dp,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -130,7 +125,7 @@ private fun EmojiPickerContent(
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(max = keyboardHeight)
.fillMaxHeight()
.background(panelBackground)
) {
// ============ КАТЕГОРИИ (синхронизированы с pager) ============