Хотфиксы чатов: камера, эмодзи и стабильность синхронизации
All checks were successful
Android Kernel Build / build (push) Successful in 20m11s
All checks were successful
Android Kernel Build / build (push) Successful in 20m11s
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
@@ -51,6 +52,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
|
||||
private val chatMessageDescComparator =
|
||||
compareByDescending<ChatMessage> { it.timestamp.time }
|
||||
private val forwardUuidRegex =
|
||||
Regex(
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
|
||||
messages.sortedWith(chatMessageAscComparator)
|
||||
@@ -1616,28 +1621,169 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun parseAttachmentType(attachment: JSONObject): AttachmentType {
|
||||
val rawType = attachment.opt("type")
|
||||
val typeValue =
|
||||
when (rawType) {
|
||||
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<MessageAttachment>
|
||||
)
|
||||
|
||||
private data class ForwardRewriteResult(
|
||||
val rewrittenAttachments: Map<String, MessageAttachment>,
|
||||
val rewrittenMessageIds: Set<String>
|
||||
)
|
||||
|
||||
private fun forwardAttachmentRewriteKey(messageId: String, attachmentId: String): String {
|
||||
return "$messageId::$attachmentId"
|
||||
}
|
||||
|
||||
private fun shouldReuploadForwardAttachment(type: AttachmentType): Boolean {
|
||||
return type == AttachmentType.IMAGE ||
|
||||
type == AttachmentType.FILE ||
|
||||
type == AttachmentType.VOICE ||
|
||||
type == AttachmentType.VIDEO_CIRCLE
|
||||
}
|
||||
|
||||
private fun decodeHexBytes(value: String): ByteArray? {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
if (normalized.isEmpty() || normalized.length % 2 != 0) return null
|
||||
return runCatching {
|
||||
ByteArray(normalized.length / 2) { index ->
|
||||
normalized.substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun extractForwardFileName(preview: String): String {
|
||||
val normalized = preview.trim()
|
||||
if (normalized.isEmpty()) return ""
|
||||
val parts = normalized.split("::")
|
||||
return when {
|
||||
parts.size >= 3 && forwardUuidRegex.matches(parts[0]) -> {
|
||||
parts.drop(2).joinToString("::").trim()
|
||||
}
|
||||
parts.size >= 2 -> {
|
||||
parts.drop(1).joinToString("::").trim()
|
||||
}
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBase64PayloadForForward(value: String): ByteArray? {
|
||||
val normalized = value.trim()
|
||||
if (normalized.isEmpty()) return null
|
||||
val payload =
|
||||
when {
|
||||
normalized.contains("base64,", ignoreCase = true) -> {
|
||||
normalized.substringAfter("base64,", "")
|
||||
}
|
||||
normalized.substringBefore(",").contains("base64", ignoreCase = true) -> {
|
||||
normalized.substringAfter(",", "")
|
||||
}
|
||||
else -> normalized
|
||||
}
|
||||
if (payload.isEmpty()) return null
|
||||
return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun resolveForwardAttachmentPayload(
|
||||
context: Application,
|
||||
sourceMessage: ForwardSourceMessage,
|
||||
attachment: MessageAttachment,
|
||||
privateKey: String
|
||||
): String? {
|
||||
if (attachment.blob.isNotBlank()) {
|
||||
return attachment.blob
|
||||
}
|
||||
if (attachment.id.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val normalizedPublicKey =
|
||||
sourceMessage.senderPublicKey.ifBlank {
|
||||
myPublicKey?.trim().orEmpty()
|
||||
}
|
||||
if (normalizedPublicKey.isNotBlank()) {
|
||||
val cachedPayload =
|
||||
AttachmentFileManager.readAttachment(
|
||||
context = context,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = normalizedPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (!cachedPayload.isNullOrBlank()) {
|
||||
return cachedPayload
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.type == AttachmentType.FILE) {
|
||||
val fileName = extractForwardFileName(attachment.preview)
|
||||
if (fileName.isNotBlank()) {
|
||||
val localFile = File(context.filesDir, "rosetta_downloads/$fileName")
|
||||
if (localFile.exists() && localFile.length() > 0L) {
|
||||
return runCatching {
|
||||
Base64.encodeToString(localFile.readBytes(), Base64.NO_WRAP)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val downloadTag = attachment.transportTag.trim()
|
||||
val plainKey = decodeHexBytes(sourceMessage.chachaKeyPlainHex)
|
||||
if (downloadTag.isBlank() || plainKey == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val encrypted =
|
||||
runCatching {
|
||||
TransportManager.downloadFile(
|
||||
attachment.id,
|
||||
downloadTag,
|
||||
attachment.transportServer.ifBlank { null }
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
if (encrypted.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return MessageCrypto.decryptAttachmentBlobWithPlainKey(encrypted, plainKey)
|
||||
?: MessageCrypto.decryptReplyBlob(encrypted, plainKey).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private suspend fun prepareForwardAttachmentRewrites(
|
||||
context: Application,
|
||||
sourceMessages: List<ForwardSourceMessage>,
|
||||
encryptionContext: OutgoingEncryptionContext,
|
||||
privateKey: String,
|
||||
isSavedMessages: Boolean,
|
||||
timestamp: Long
|
||||
): ForwardRewriteResult {
|
||||
if (sourceMessages.isEmpty()) {
|
||||
return ForwardRewriteResult(emptyMap(), emptySet())
|
||||
}
|
||||
|
||||
val rewritten = mutableMapOf<String, MessageAttachment>()
|
||||
val rewrittenMessageIds = mutableSetOf<String>()
|
||||
var forwardAttachmentIndex = 0
|
||||
|
||||
for (sourceMessage in sourceMessages) {
|
||||
val candidates = sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) }
|
||||
if (candidates.isEmpty()) continue
|
||||
|
||||
val stagedForMessage = mutableMapOf<String, MessageAttachment>()
|
||||
var allRewritten = true
|
||||
|
||||
for (attachment in candidates) {
|
||||
val payload =
|
||||
resolveForwardAttachmentPayload(
|
||||
context = context,
|
||||
sourceMessage = sourceMessage,
|
||||
attachment = attachment,
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (payload.isNullOrBlank()) {
|
||||
allRewritten = false
|
||||
break
|
||||
}
|
||||
|
||||
val encryptedBlob = encryptAttachmentPayload(payload, encryptionContext)
|
||||
val newAttachmentId = "fwd_${timestamp}_${forwardAttachmentIndex++}"
|
||||
val uploadTag =
|
||||
if (!isSavedMessages) {
|
||||
runCatching { TransportManager.uploadFile(newAttachmentId, encryptedBlob) }
|
||||
.getOrDefault("")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val transportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val normalizedPreview =
|
||||
if (attachment.type == AttachmentType.IMAGE) {
|
||||
attachment.preview.substringAfter("::", attachment.preview)
|
||||
} else {
|
||||
attachment.preview
|
||||
}
|
||||
|
||||
stagedForMessage[forwardAttachmentRewriteKey(sourceMessage.messageId, attachment.id)] =
|
||||
attachment.copy(
|
||||
id = newAttachmentId,
|
||||
preview = normalizedPreview,
|
||||
blob = "",
|
||||
localUri = "",
|
||||
transportTag = uploadTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
|
||||
if (attachment.type == AttachmentType.IMAGE ||
|
||||
attachment.type == AttachmentType.VOICE ||
|
||||
attachment.type == AttachmentType.VIDEO_CIRCLE) {
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = payload,
|
||||
attachmentId = newAttachmentId,
|
||||
publicKey =
|
||||
sourceMessage.senderPublicKey.ifBlank {
|
||||
myPublicKey?.trim().orEmpty()
|
||||
},
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSavedMessages && attachment.type == AttachmentType.FILE) {
|
||||
val fileName = extractForwardFileName(attachment.preview)
|
||||
val payloadBytes = decodeBase64PayloadForForward(payload)
|
||||
if (fileName.isNotBlank() && payloadBytes != null) {
|
||||
runCatching {
|
||||
val downloadsDir = File(context.filesDir, "rosetta_downloads").apply { mkdirs() }
|
||||
File(downloadsDir, fileName).writeBytes(payloadBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allRewritten && stagedForMessage.size == candidates.size) {
|
||||
rewritten.putAll(stagedForMessage)
|
||||
rewrittenMessageIds.add(sourceMessage.messageId)
|
||||
}
|
||||
}
|
||||
|
||||
return ForwardRewriteResult(
|
||||
rewrittenAttachments = rewritten,
|
||||
rewrittenMessageIds = rewrittenMessageIds
|
||||
)
|
||||
}
|
||||
|
||||
/** Обновить текст ввода */
|
||||
fun updateInputText(text: String) {
|
||||
if (_inputText.value == text) return
|
||||
@@ -3050,65 +3439,35 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
|
||||
|
||||
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
|
||||
// Map: originalAttId -> updated attachment metadata.
|
||||
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
|
||||
if (isForwardToSend && replyMsgsToSend.isNotEmpty()) {
|
||||
val context = getApplication<Application>()
|
||||
val isSaved = (sender == recipient)
|
||||
var fwdIdx = 0
|
||||
|
||||
for (msg in replyMsgsToSend) {
|
||||
for (att in msg.attachments) {
|
||||
if (att.type == AttachmentType.IMAGE) {
|
||||
try {
|
||||
val imageBlob = AttachmentFileManager.readAttachment(
|
||||
context = context,
|
||||
attachmentId = att.id,
|
||||
publicKey = msg.publicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<ImageCapture?>(null) }
|
||||
@@ -151,7 +219,6 @@ fun InAppCameraScreen(
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 🎨 Status bar (черный как в ImageEditorScreen)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val activity = context as? Activity
|
||||
val window = activity?.window
|
||||
val originalSoftInputMode = remember(window) {
|
||||
window?.attributes?.softInputMode ?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
|
||||
@@ -207,6 +274,10 @@ fun InAppCameraScreen(
|
||||
|
||||
// Take photo function
|
||||
fun takePhoto() {
|
||||
if (!hasCameraPermission) {
|
||||
requestCameraPermission()
|
||||
return
|
||||
}
|
||||
val capture = imageCapture ?: return
|
||||
if (isCapturing) return
|
||||
|
||||
@@ -245,7 +316,15 @@ fun InAppCameraScreen(
|
||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||
|
||||
// Bind camera when previewView or lensFacing changes (NOT flashMode — that causes flicker)
|
||||
LaunchedEffect(previewView, lensFacing) {
|
||||
LaunchedEffect(previewView, lensFacing, hasCameraPermission) {
|
||||
if (!hasCameraPermission) {
|
||||
cameraProvider?.unbindAll()
|
||||
cameraProvider = null
|
||||
camera = null
|
||||
imageCapture = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val pv = previewView ?: return@LaunchedEffect
|
||||
|
||||
val provider = context.getCameraProvider()
|
||||
@@ -296,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
|
||||
*/
|
||||
|
||||
@@ -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) ============
|
||||
|
||||
Reference in New Issue
Block a user