Хотфиксы чатов: камера, эмодзи и стабильность синхронизации
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.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -30,11 +32,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.firebase.FirebaseApp import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
@@ -106,6 +111,8 @@ class MainActivity : FragmentActivity() {
companion object { companion object {
private const val TAG = "MainActivity" 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. // Process-memory session cache: lets app return without password while process is alive.
private var cachedDecryptedAccount: DecryptedAccount? = null private var cachedDecryptedAccount: DecryptedAccount? = null
@@ -168,6 +175,24 @@ class MainActivity : FragmentActivity() {
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted -> } 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+) // Запрашиваем разрешение при первом запуске (Android 13+)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -184,21 +209,19 @@ class MainActivity : FragmentActivity() {
) )
} }
} }
refreshFullScreenIntentState()
}
// Android 14+: запрос fullScreenIntent для входящих звонков DisposableEffect(lifecycleOwner) {
if (Build.VERSION.SDK_INT >= 34) { val observer = LifecycleEventObserver { _, event ->
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager if (event == Lifecycle.Event.ON_RESUME) {
if (!nm.canUseFullScreenIntent()) { refreshFullScreenIntentState()
try {
startActivity(
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
android.net.Uri.parse("package:$packageName")
)
)
} catch (_: Throwable) {}
} }
} }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -266,6 +289,61 @@ class MainActivity : FragmentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White 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( AnimatedContent(
targetState = targetState =
when { when {
@@ -734,6 +812,18 @@ class MainActivity : FragmentActivity() {
prefs.edit().putString("fcm_token", token).apply() 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 = private fun buildInitials(displayName: String): String =

View File

@@ -290,13 +290,17 @@ private fun GroupRejoinRequiredState(
text = "Join Group Again", text = "Join Group Again",
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = titleColor color = titleColor,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Group sync key is missing. Rejoin this group to load messages.", text = "Group sync key is missing. Rejoin this group to load messages.",
fontSize = 14.sp, 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.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageLogger
import com.rosetta.messenger.utils.MessageThrottleManager import com.rosetta.messenger.utils.MessageThrottleManager
import java.io.File
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
@@ -51,6 +52,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time } private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
private val chatMessageDescComparator = private val chatMessageDescComparator =
compareByDescending<ChatMessage> { it.timestamp.time } 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> = private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator) messages.sortedWith(chatMessageAscComparator)
@@ -1616,28 +1621,169 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
private fun parseAttachmentType(attachment: JSONObject): AttachmentType { private fun parseAttachmentType(attachment: JSONObject): AttachmentType {
val rawType = attachment.opt("type") val typeValue = parseAttachmentTypeValue(attachment.opt("type"))
val typeValue = val parsedType = AttachmentType.fromInt(typeValue)
when (rawType) { if (parsedType != AttachmentType.UNKNOWN) {
is Number -> rawType.toInt() return parsedType
is String -> { }
val normalized = rawType.trim()
normalized.toIntOrNull() val preview = attachment.optString("preview", "")
?: when (normalized.lowercase(Locale.ROOT)) { val blob = attachment.optString("blob", "")
"image" -> AttachmentType.IMAGE.value val width = attachment.optInt("width", 0)
"messages", "reply", "forward" -> AttachmentType.MESSAGES.value val height = attachment.optInt("height", 0)
"file" -> AttachmentType.FILE.value val attachmentId = attachment.optString("id", "")
"avatar" -> AttachmentType.AVATAR.value val transportObj = attachment.optJSONObject("transport")
"call" -> AttachmentType.CALL.value val transportTag =
"voice" -> AttachmentType.VOICE.value attachment.optString(
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> "transportTag",
AttachmentType.VIDEO_CIRCLE.value attachment.optString(
else -> -1 "transport_tag",
} transportObj?.optString("transport_tag", "") ?: ""
} )
else -> -1 )
}
return AttachmentType.fromInt(typeValue) 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 { private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
@@ -2486,6 +2632,249 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return CryptoManager.encryptWithPassword(payload, context.attachmentPassword) 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) { fun updateInputText(text: String) {
if (_inputText.value == text) return if (_inputText.value == text) return
@@ -3050,65 +3439,35 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val messageAttachments = mutableListOf<MessageAttachment>() val messageAttachments = mutableListOf<MessageAttachment>()
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob val isSavedMessages = (sender == recipient)
// Map: originalAttId -> updated attachment metadata. val forwardSources =
val forwardedAttMap = mutableMapOf<String, MessageAttachment>() if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) { replyMsgsToSend.map { msg ->
val context = getApplication<Application>() ForwardSourceMessage(
val isSaved = (sender == recipient) messageId = msg.messageId,
var fwdIdx = 0 senderPublicKey = msg.publicKey,
chachaKeyPlainHex = msg.chachaKeyPlainHex,
for (msg in replyMsgsToSend) { attachments = msg.attachments
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) { }
} }
} 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()) { if (replyMsgsToSend.isNotEmpty()) {
@@ -3118,7 +3477,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attachmentsArray = JSONArray() val attachmentsArray = JSONArray()
msg.attachments.forEach { att -> msg.attachments.forEach { att ->
// Для forward IMAGE: подставляем НОВЫЙ id/preview/transport. // Для 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 attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.preview ?: att.preview val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
@@ -3159,8 +3524,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isForwardToSend) { if (isForwardToSend) {
put("forwarded", true) put("forwarded", true)
put("senderName", msg.senderName) put("senderName", msg.senderName)
if (msg.chachaKeyPlainHex.isNotEmpty()) { val effectiveForwardPlainKey =
put("chacha_key_plain", msg.chachaKeyPlainHex) 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 -> } packet.attachments.forEachIndexed { idx, att -> }
// 📁 Для Saved Messages - НЕ отправляем пакет на сервер // 📁 Для Saved Messages - НЕ отправляем пакет на сервер
val isSavedMessages = (sender == recipient)
if (!isSavedMessages) { if (!isSavedMessages) {
ProtocolManager.send(packet) ProtocolManager.send(packet)
} }
@@ -3327,6 +3702,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val aesChachaKey = encryptionContext.aesChachaKey val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val replyAttachmentId = "reply_${timestamp}" 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( fun buildForwardReplyJson(
includeLocalUri: Boolean includeLocalUri: Boolean
@@ -3335,29 +3734,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardMessages.forEach { fm -> forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray() val attachmentsArray = JSONArray()
fm.attachments.forEach { att -> 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( attachmentsArray.put(
JSONObject().apply { JSONObject().apply {
put("id", att.id) put("id", attId)
put("type", att.type.value) put("type", att.type.value)
put("preview", att.preview) put("preview", attPreview)
put("width", att.width) put("width", att.width)
put("height", att.height) put("height", att.height)
put("blob", "") put("blob", "")
put("transportTag", att.transportTag) put("transportTag", attTransportTag)
put("transportServer", att.transportServer) put("transportServer", attTransportServer)
put( put(
"transport", "transport",
JSONObject().apply { JSONObject().apply {
put("transport_tag", att.transportTag) put("transport_tag", attTransportTag)
put("transport_server", att.transportServer) put("transport_server", attTransportServer)
} }
) )
if (includeLocalUri && att.localUri.isNotEmpty()) { if (includeLocalUri && attLocalUri.isNotEmpty()) {
put("localUri", att.localUri) put("localUri", attLocalUri)
} }
} }
) )
} }
val effectiveForwardPlainKey =
if (fm.messageId in rewrittenForwardMessageIds &&
outgoingForwardPlainKeyHex.isNotEmpty()
) {
outgoingForwardPlainKeyHex
} else {
fm.chachaKeyPlain
}
replyJsonArray.put( replyJsonArray.put(
JSONObject().apply { JSONObject().apply {
put("message_id", fm.messageId) put("message_id", fm.messageId)
@@ -3367,8 +3787,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("attachments", attachmentsArray) put("attachments", attachmentsArray)
put("forwarded", true) put("forwarded", true)
put("senderName", fm.senderName) put("senderName", fm.senderName)
if (fm.chachaKeyPlain.isNotEmpty()) { if (effectiveForwardPlainKey.isNotEmpty()) {
put("chacha_key_plain", fm.chachaKeyPlain) 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 groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
private val groupJoinedMarker = "\$a=Group joined" private val groupJoinedMarker = "\$a=Group joined"
private val groupCreatedMarker = "\$a=Group created" 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 { private fun isGroupKey(value: String): Boolean {
val normalized = value.trim().lowercase() val normalized = value.trim().lowercase()
@@ -665,24 +669,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1 val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
if (attachments.length() <= 0) return -1 if (attachments.length() <= 0) return -1
val first = attachments.optJSONObject(0) ?: return -1 val first = attachments.optJSONObject(0) ?: return -1
val rawType = first.opt("type") val parsedType = parseAttachmentTypeValue(first.opt("type"))
when (rawType) { if (parsedType in 0..6) {
is Number -> rawType.toInt() parsedType
is String -> { } else {
val normalized = rawType.trim() inferLegacyAttachmentTypeFromJson(first)
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
} }
} catch (_: Throwable) { } catch (_: Throwable) {
-1 -1
@@ -696,34 +687,149 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (attachments.length() != 1) return false if (attachments.length() != 1) return false
val first = attachments.optJSONObject(0) ?: return false val first = attachments.optJSONObject(0) ?: return false
val rawType = first.opt("type") val typeValue = parseAttachmentTypeValue(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
}
if (typeValue == 4) return true if (typeValue == 4) return true
val preview = first.optString("preview", "").trim() val preview = first.optString("preview", "").trim()
if (preview.isEmpty()) return false isLikelyCallAttachmentPreview(preview)
val tail = preview.substringAfterLast("::", preview).trim()
if (tail.toIntOrNull() != null) return true
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(preview)
} catch (_: Throwable) { } catch (_: Throwable) {
false 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? { private fun parseAttachmentsJsonArray(rawAttachments: String): JSONArray? {
val normalized = rawAttachments.trim() val normalized = rawAttachments.trim()
if (normalized.isEmpty() || normalized == "[]") return null if (normalized.isEmpty() || normalized == "[]") return null

View File

@@ -1026,12 +1026,22 @@ fun MessageBubble(
isOutgoing = message.isOutgoing, isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey, chachaKey = message.chachaKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey, privateKey = privateKey,
dialogPublicKey = dialogPublicKey,
currentUserPublicKey = currentUserPublicKey,
senderDisplayName = senderName,
timestamp = message.timestamp,
messageStatus = message.status,
linksEnabled = linksEnabled, linksEnabled = linksEnabled,
onImageClick = onImageClick, onImageClick = onImageClick,
onForwardedSenderClick = onForwardedSenderClick, onForwardedSenderClick = onForwardedSenderClick,
onMentionClick = onMentionClick, onMentionClick = onMentionClick,
onTextSpanPressStart = suppressBubbleTapFromSpan onTextSpanPressStart = suppressBubbleTapFromSpan,
onVoiceWaveGestureActiveChanged = { active ->
isVoiceWaveGestureActive = active
onVoiceWaveGestureActiveChanged(active)
}
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
@@ -2544,12 +2554,19 @@ fun ForwardedMessagesBubble(
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
chachaKey: String = "", chachaKey: String = "",
chachaKeyPlainHex: String = "",
privateKey: String = "", privateKey: String = "",
dialogPublicKey: String = "",
currentUserPublicKey: String = "",
senderDisplayName: String = "",
timestamp: Date = Date(),
messageStatus: MessageStatus = MessageStatus.READ,
linksEnabled: Boolean = true, linksEnabled: Boolean = true,
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {},
onTextSpanPressStart: (() -> Unit)? = null onTextSpanPressStart: (() -> Unit)? = null,
onVoiceWaveGestureActiveChanged: (Boolean) -> Unit = {}
) { ) {
val configuration = androidx.compose.ui.platform.LocalConfiguration.current val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val backgroundColor = 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) // Message text (below image, like a caption)
if (fwd.text.isNotEmpty()) { if (fwd.text.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))

View File

@@ -1,13 +1,20 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.Manifest
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler 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.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -56,9 +67,7 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import android.app.Activity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
@@ -76,6 +85,7 @@ fun InAppCameraScreen(
onPhotoTaken: (Uri) -> Unit // Вызывается с URI сделанного фото onPhotoTaken: (Uri) -> Unit // Вызывается с URI сделанного фото
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? Activity
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current val view = LocalView.current
@@ -89,11 +99,69 @@ fun InAppCameraScreen(
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) } var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) }
var isCapturing by remember { mutableStateOf(false) } 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) { LaunchedEffect(preferencesManager) {
flashMode = preferencesManager.getCameraFlashMode() 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 // Camera references
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) } var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) } var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
@@ -151,7 +219,6 @@ fun InAppCameraScreen(
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// 🎨 Status bar (черный как в ImageEditorScreen) // 🎨 Status bar (черный как в ImageEditorScreen)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
val activity = context as? Activity
val window = activity?.window val window = activity?.window
val originalSoftInputMode = remember(window) { val originalSoftInputMode = remember(window) {
window?.attributes?.softInputMode ?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE window?.attributes?.softInputMode ?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
@@ -207,6 +274,10 @@ fun InAppCameraScreen(
// Take photo function // Take photo function
fun takePhoto() { fun takePhoto() {
if (!hasCameraPermission) {
requestCameraPermission()
return
}
val capture = imageCapture ?: return val capture = imageCapture ?: return
if (isCapturing) return if (isCapturing) return
@@ -245,7 +316,15 @@ fun InAppCameraScreen(
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
// Bind camera when previewView or lensFacing changes (NOT flashMode — that causes flicker) // 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 pv = previewView ?: return@LaunchedEffect
val provider = context.getCameraProvider() val provider = context.getCameraProvider()
@@ -296,46 +375,54 @@ fun InAppCameraScreen(
.graphicsLayer { alpha = animationProgress.value } .graphicsLayer { alpha = animationProgress.value }
.background(Color.Black) .background(Color.Black)
) { ) {
// Camera Preview with pinch-to-zoom if (hasCameraPermission) {
AndroidView( // Camera Preview with pinch-to-zoom
factory = { ctx -> AndroidView(
PreviewView(ctx).apply { factory = { ctx ->
layoutParams = ViewGroup.LayoutParams( PreviewView(ctx).apply {
ViewGroup.LayoutParams.MATCH_PARENT, layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT,
) ViewGroup.LayoutParams.MATCH_PARENT
scaleType = PreviewView.ScaleType.FILL_CENTER )
implementationMode = PreviewView.ImplementationMode.COMPATIBLE scaleType = PreviewView.ScaleType.FILL_CENTER
previewView = this implementationMode = PreviewView.ImplementationMode.COMPATIBLE
previewView = this
// Pinch-to-zoom via ScaleGestureDetector // Pinch-to-zoom via ScaleGestureDetector
val scaleGestureDetector = ScaleGestureDetector(ctx, val scaleGestureDetector = ScaleGestureDetector(ctx,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() { object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean { override fun onScale(detector: ScaleGestureDetector): Boolean {
val cam = camera ?: return true val cam = camera ?: return true
val zoomState = cam.cameraInfo.zoomState.value ?: return true val zoomState = cam.cameraInfo.zoomState.value ?: return true
val currentZoom = zoomState.zoomRatio val currentZoom = zoomState.zoomRatio
val newZoom = currentZoom * detector.scaleFactor val newZoom = currentZoom * detector.scaleFactor
cam.cameraControl.setZoomRatio(newZoom) cam.cameraControl.setZoomRatio(newZoom)
// Sync slider with pinch: convert ratio to linear 0..1 // Sync slider with pinch: convert ratio to linear 0..1
val minZoom = zoomState.minZoomRatio val minZoom = zoomState.minZoomRatio
val maxZoom = zoomState.maxZoomRatio val maxZoom = zoomState.maxZoomRatio
val clampedZoom = newZoom.coerceIn(minZoom, maxZoom) val clampedZoom = newZoom.coerceIn(minZoom, maxZoom)
zoomLinearProgress = if (maxZoom > minZoom) zoomLinearProgress = if (maxZoom > minZoom)
((clampedZoom - minZoom) / (maxZoom - minZoom)).coerceIn(0f, 1f) ((clampedZoom - minZoom) / (maxZoom - minZoom)).coerceIn(0f, 1f)
else 0f else 0f
return true 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) // Top controls (Close + Flash)
Row( Row(
@@ -360,31 +447,35 @@ fun InAppCameraScreen(
} }
// Flash button // Flash button
IconButton( if (hasCameraPermission) {
onClick = { IconButton(
val nextFlashMode = when (flashMode) { onClick = {
ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO val nextFlashMode = when (flashMode) {
ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO
else -> ImageCapture.FLASH_MODE_OFF ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON
} else -> ImageCapture.FLASH_MODE_OFF
flashMode = nextFlashMode }
scope.launch { flashMode = nextFlashMode
preferencesManager.setCameraFlashMode(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
}, },
contentDescription = "Flash", modifier = Modifier
tint = Color.White .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) val zoomLabel = "%.1fx".format(currentZoomRatio)
// Only show slider if camera supports zoom range > 1 // Only show slider if camera supports zoom range > 1
if (maxZoomRatio > minZoomRatio + 0.1f) { if (hasCameraPermission && maxZoomRatio > minZoomRatio + 0.1f) {
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -494,64 +585,104 @@ fun InAppCameraScreen(
} }
// Bottom controls (Shutter + Flip) // Bottom controls (Shutter + Flip)
Row( if (hasCameraPermission) {
modifier = Modifier Row(
.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 modifier = Modifier
.size(80.dp) .fillMaxWidth()
.scale(shutterScale) .align(Alignment.BottomCenter)
.clip(CircleShape) .navigationBarsPadding()
.background(Color.White.copy(alpha = 0.2f)) .padding(bottom = 32.dp),
.border(4.dp, Color.White, CircleShape) horizontalArrangement = Arrangement.SpaceEvenly,
.clickable( verticalAlignment = Alignment.CenterVertically
interactionSource = remember { MutableInteractionSource() },
indication = null
) { takePhoto() },
contentAlignment = Alignment.Center
) { ) {
// Placeholder for symmetry
Spacer(modifier = Modifier.size(60.dp))
// Shutter button
Box( Box(
modifier = Modifier modifier = Modifier
.size(64.dp) .size(80.dp)
.scale(shutterScale)
.clip(CircleShape) .clip(CircleShape)
.background(Color.White) .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 // Flip camera button
IconButton( IconButton(
onClick = { onClick = {
lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) { lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
CameraSelector.LENS_FACING_FRONT CameraSelector.LENS_FACING_FRONT
} else { } else {
CameraSelector.LENS_FACING_BACK CameraSelector.LENS_FACING_BACK
} }
}, },
modifier = Modifier modifier = Modifier
.size(60.dp) .size(60.dp)
.background(Color.Black.copy(alpha = 0.3f), CircleShape) .background(Color.Black.copy(alpha = 0.3f), CircleShape)
) { ) {
Icon( Icon(
Icons.Default.FlipCameraAndroid, Icons.Default.FlipCameraAndroid,
contentDescription = "Flip camera", contentDescription = "Flip camera",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(28.dp) 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 * Получить 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.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -60,13 +59,10 @@ fun OptimizedEmojiPicker(
onClose: () -> Unit = {}, onClose: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val savedKeyboardHeight = rememberSavedKeyboardHeight()
// 🔥 Рендерим напрямую без лишних обёрток // 🔥 Рендерим напрямую без лишних обёрток
EmojiPickerContent( EmojiPickerContent(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onEmojiSelected = onEmojiSelected, onEmojiSelected = onEmojiSelected,
keyboardHeight = savedKeyboardHeight,
modifier = modifier modifier = modifier
) )
} }
@@ -85,7 +81,6 @@ private class StableCallback(val onClick: (String) -> Unit)
private fun EmojiPickerContent( private fun EmojiPickerContent(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onEmojiSelected: (String) -> Unit, onEmojiSelected: (String) -> Unit,
keyboardHeight: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -130,7 +125,7 @@ private fun EmojiPickerContent(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = keyboardHeight) .fillMaxHeight()
.background(panelBackground) .background(panelBackground)
) { ) {
// ============ КАТЕГОРИИ (синхронизированы с pager) ============ // ============ КАТЕГОРИИ (синхронизированы с pager) ============