From 1cf645ea3fce6cf44f857ee44d083166424cdd8a Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 17 Apr 2026 14:33:46 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A5=D0=BE=D1=82=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D1=8B=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2:=20=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=B0,=20=D1=8D=D0=BC=D0=BE=D0=B4=D0=B7=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 114 +++- .../messenger/ui/chats/ChatDetailScreen.kt | 8 +- .../messenger/ui/chats/ChatViewModel.kt | 606 +++++++++++++++--- .../messenger/ui/chats/ChatsListViewModel.kt | 182 ++++-- .../chats/components/ChatDetailComponents.kt | 52 +- .../ui/chats/components/InAppCameraScreen.kt | 357 +++++++---- .../ui/components/OptimizedEmojiPicker.kt | 7 +- 7 files changed, 1060 insertions(+), 266 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index b5a9f61..f40769a 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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,21 +209,19 @@ class MainActivity : FragmentActivity() { ) } } + refreshFullScreenIntentState() + } - // 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) {} + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + refreshFullScreenIntentState() } } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } } val scope = rememberCoroutineScope() @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 03caafb..1add3e6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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() ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 94e1a7a..b6e18ff 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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 { it.timestamp.time } private val chatMessageDescComparator = compareByDescending { 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): List = 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) { - 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" -> - AttachmentType.VIDEO_CIRCLE.value - else -> -1 - } - } - else -> -1 - } - return AttachmentType.fromInt(typeValue) + 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() + ?: 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 + } + } + + 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 + ) + + private data class ForwardRewriteResult( + val rewrittenAttachments: Map, + val rewrittenMessageIds: Set + ) + + 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, + encryptionContext: OutgoingEncryptionContext, + privateKey: String, + isSavedMessages: Boolean, + timestamp: Long + ): ForwardRewriteResult { + if (sourceMessages.isEmpty()) { + return ForwardRewriteResult(emptyMap(), emptySet()) + } + + val rewritten = mutableMapOf() + val rewrittenMessageIds = mutableSetOf() + var forwardAttachmentIndex = 0 + + for (sourceMessage in sourceMessages) { + val candidates = sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) } + if (candidates.isEmpty()) continue + + val stagedForMessage = mutableMapOf() + 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() var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) - // 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob - // Map: originalAttId -> updated attachment metadata. - val forwardedAttMap = mutableMapOf() - if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { - val context = getApplication() - 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 - ) - 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 { - "" - } - - forwardedAttMap[att.id] = - att.copy( - id = newAttId, - preview = blurhash, - blob = "", - transportTag = uploadTag, - transportServer = transportServer - ) - - // Сохраняем локально с новым ID - // publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments - AttachmentFileManager.saveAttachment( - context = context, - blob = imageBlob, - attachmentId = newAttId, - publicKey = msg.publicKey, - privateKey = privateKey - ) - } - } catch (e: Exception) { } + val isSavedMessages = (sender == recipient) + val forwardSources = + if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { + replyMsgsToSend.map { msg -> + ForwardSourceMessage( + messageId = msg.messageId, + senderPublicKey = msg.publicKey, + chachaKeyPlainHex = msg.chachaKeyPlainHex, + attachments = msg.attachments + ) } + } else { + emptyList() } - } - } + val forwardRewriteResult = + prepareForwardAttachmentRewrites( + context = getApplication(), + 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() 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) } } ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 0f038e7..e88cb37 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -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,34 +687,149 @@ 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) { - is Number -> rawType.toInt() - is String -> { - val normalized = rawType.trim() - normalized.toIntOrNull() - ?: when (normalized.lowercase(Locale.ROOT)) { - "call" -> 4 - else -> -1 - } - } - else -> -1 - } + val typeValue = parseAttachmentTypeValue(first.opt("type")) if (typeValue == 4) return true val preview = first.optString("preview", "").trim() - if (preview.isEmpty()) return false - val tail = preview.substringAfterLast("::", preview).trim() - if (tail.toIntOrNull() != null) return true - - Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE) - .containsMatchIn(preview) + 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() + ?: 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 + } + } + + 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) + } + + 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 + } + } + private fun parseAttachmentsJsonArray(rawAttachments: String): JSONArray? { val normalized = rawAttachments.trim() if (normalized.isEmpty() || normalized == "[]") return null diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 9738eb3..a1f1d90 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index 15ace5b..35ebd6c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -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,10 +99,68 @@ 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(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(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,46 +375,54 @@ fun InAppCameraScreen( .graphicsLayer { alpha = animationProgress.value } .background(Color.Black) ) { - // Camera Preview with pinch-to-zoom - AndroidView( - factory = { ctx -> - PreviewView(ctx).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - scaleType = PreviewView.ScaleType.FILL_CENTER - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - previewView = this + if (hasCameraPermission) { + // Camera Preview with pinch-to-zoom + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + previewView = this - // Pinch-to-zoom via ScaleGestureDetector - val scaleGestureDetector = ScaleGestureDetector(ctx, - object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScale(detector: ScaleGestureDetector): Boolean { - val cam = camera ?: return true - val zoomState = cam.cameraInfo.zoomState.value ?: return true - val currentZoom = zoomState.zoomRatio - val newZoom = currentZoom * detector.scaleFactor - cam.cameraControl.setZoomRatio(newZoom) - // Sync slider with pinch: convert ratio to linear 0..1 - val minZoom = zoomState.minZoomRatio - val maxZoom = zoomState.maxZoomRatio - val clampedZoom = newZoom.coerceIn(minZoom, maxZoom) - zoomLinearProgress = if (maxZoom > minZoom) - ((clampedZoom - minZoom) / (maxZoom - minZoom)).coerceIn(0f, 1f) - else 0f - return true + // Pinch-to-zoom via ScaleGestureDetector + val scaleGestureDetector = ScaleGestureDetector(ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val cam = camera ?: return true + val zoomState = cam.cameraInfo.zoomState.value ?: return true + val currentZoom = zoomState.zoomRatio + val newZoom = currentZoom * detector.scaleFactor + cam.cameraControl.setZoomRatio(newZoom) + // Sync slider with pinch: convert ratio to linear 0..1 + val minZoom = zoomState.minZoomRatio + val maxZoom = zoomState.maxZoomRatio + val clampedZoom = newZoom.coerceIn(minZoom, maxZoom) + zoomLinearProgress = if (maxZoom > minZoom) + ((clampedZoom - minZoom) / (maxZoom - minZoom)).coerceIn(0f, 1f) + else 0f + return true + } } + ) + setOnTouchListener { _, event -> + scaleGestureDetector.onTouchEvent(event) + true } - ) - setOnTouchListener { _, event -> - scaleGestureDetector.onTouchEvent(event) - true } - } - }, - modifier = Modifier.fillMaxSize() - ) + }, + modifier = Modifier.fillMaxSize() + ) + } else { + CameraPermissionContent( + permanentlyDenied = isPermissionPermanentlyDenied, + onGrantAccess = { requestCameraPermission() }, + onOpenSettings = { openAppSettings() } + ) + } // Top controls (Close + Flash) Row( @@ -360,31 +447,35 @@ fun InAppCameraScreen( } // Flash button - IconButton( - onClick = { - val nextFlashMode = when (flashMode) { - ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO - ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON - else -> ImageCapture.FLASH_MODE_OFF - } - flashMode = nextFlashMode - scope.launch { - preferencesManager.setCameraFlashMode(nextFlashMode) - } - }, - modifier = Modifier - .size(44.dp) - .background(Color.Black.copy(alpha = 0.3f), CircleShape) - ) { - Icon( - when (flashMode) { - ImageCapture.FLASH_MODE_OFF -> Icons.Default.FlashOff - ImageCapture.FLASH_MODE_AUTO -> Icons.Default.FlashAuto - else -> Icons.Default.FlashOn + if (hasCameraPermission) { + IconButton( + onClick = { + val nextFlashMode = when (flashMode) { + ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO + ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON + else -> ImageCapture.FLASH_MODE_OFF + } + flashMode = nextFlashMode + scope.launch { + preferencesManager.setCameraFlashMode(nextFlashMode) + } }, - contentDescription = "Flash", - tint = Color.White - ) + modifier = Modifier + .size(44.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + when (flashMode) { + ImageCapture.FLASH_MODE_OFF -> Icons.Default.FlashOff + ImageCapture.FLASH_MODE_AUTO -> Icons.Default.FlashAuto + else -> Icons.Default.FlashOn + }, + contentDescription = "Flash", + tint = Color.White + ) + } + } else { + Spacer(modifier = Modifier.size(44.dp)) } } @@ -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,64 +585,104 @@ fun InAppCameraScreen( } // Bottom controls (Shutter + Flip) - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 32.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // Placeholder for symmetry - Spacer(modifier = Modifier.size(60.dp)) - - // Shutter button - Box( + if (hasCameraPermission) { + Row( modifier = Modifier - .size(80.dp) - .scale(shutterScale) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.2f)) - .border(4.dp, Color.White, CircleShape) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { takePhoto() }, - contentAlignment = Alignment.Center + .fillMaxWidth() + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 32.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { + // Placeholder for symmetry + Spacer(modifier = Modifier.size(60.dp)) + + // Shutter button Box( modifier = Modifier - .size(64.dp) + .size(80.dp) + .scale(shutterScale) .clip(CircleShape) - .background(Color.White) - ) - } - - // Flip camera button - IconButton( - onClick = { - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { - CameraSelector.LENS_FACING_FRONT - } else { - CameraSelector.LENS_FACING_BACK - } - }, - modifier = Modifier - .size(60.dp) - .background(Color.Black.copy(alpha = 0.3f), CircleShape) - ) { - Icon( - Icons.Default.FlipCameraAndroid, - contentDescription = "Flip camera", - tint = Color.White, - modifier = Modifier.size(28.dp) - ) + .background(Color.White.copy(alpha = 0.2f)) + .border(4.dp, Color.White, CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { takePhoto() }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(Color.White) + ) + } + + // Flip camera button + IconButton( + onClick = { + lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { + CameraSelector.LENS_FACING_FRONT + } else { + CameraSelector.LENS_FACING_BACK + } + }, + modifier = Modifier + .size(60.dp) + .background(Color.Black.copy(alpha = 0.3f), CircleShape) + ) { + Icon( + Icons.Default.FlipCameraAndroid, + contentDescription = "Flip camera", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } } } } } +@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 */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt index 7c8f2e4..b90feae 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt @@ -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) ============