Хотфиксы чатов: камера, эмодзи и стабильность синхронизации
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.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,20 +209,18 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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 Number -> rawType.toInt()
|
||||||
is String -> {
|
is String -> {
|
||||||
val normalized = rawType.trim()
|
val normalized = rawType.trim()
|
||||||
normalized.toIntOrNull()
|
normalized.toIntOrNull()
|
||||||
?: when (normalized.lowercase(Locale.ROOT)) {
|
?: run {
|
||||||
"image" -> AttachmentType.IMAGE.value
|
val token =
|
||||||
"messages", "reply", "forward" -> AttachmentType.MESSAGES.value
|
normalized.lowercase(Locale.ROOT)
|
||||||
"file" -> AttachmentType.FILE.value
|
.replace('-', '_')
|
||||||
"avatar" -> AttachmentType.AVATAR.value
|
.replace(' ', '_')
|
||||||
"call" -> AttachmentType.CALL.value
|
when (token) {
|
||||||
"voice" -> AttachmentType.VOICE.value
|
"image", "photo", "picture" -> AttachmentType.IMAGE.value
|
||||||
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" ->
|
"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
|
AttachmentType.VIDEO_CIRCLE.value
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
return AttachmentType.fromInt(typeValue)
|
}
|
||||||
|
|
||||||
|
private fun inferLegacyAttachmentType(
|
||||||
|
attachmentId: String,
|
||||||
|
preview: String,
|
||||||
|
blob: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
transportTag: String
|
||||||
|
): AttachmentType {
|
||||||
|
if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) {
|
||||||
|
return AttachmentType.MESSAGES
|
||||||
|
}
|
||||||
|
if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) {
|
||||||
|
return AttachmentType.CALL
|
||||||
|
}
|
||||||
|
if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) {
|
||||||
|
return AttachmentType.VIDEO_CIRCLE
|
||||||
|
}
|
||||||
|
if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) {
|
||||||
|
return AttachmentType.VOICE
|
||||||
|
}
|
||||||
|
if (width > 0 || height > 0) {
|
||||||
|
return AttachmentType.IMAGE
|
||||||
|
}
|
||||||
|
if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) {
|
||||||
|
return AttachmentType.FILE
|
||||||
|
}
|
||||||
|
return AttachmentType.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
|
||||||
|
val payload = blob.ifBlank { preview }.trim()
|
||||||
|
if (payload.isEmpty()) return false
|
||||||
|
if (!payload.startsWith("{") && !payload.startsWith("[")) return false
|
||||||
|
|
||||||
|
val objectCandidate =
|
||||||
|
runCatching {
|
||||||
|
if (payload.startsWith("[")) {
|
||||||
|
JSONArray(payload).optJSONObject(0)
|
||||||
|
} else {
|
||||||
|
JSONObject(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
return objectCandidate.has("message_id") ||
|
||||||
|
objectCandidate.has("publicKey") ||
|
||||||
|
objectCandidate.has("message") ||
|
||||||
|
objectCandidate.has("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLikelyVoiceAttachmentPreview(preview: String, attachmentId: String): Boolean {
|
||||||
|
val id = attachmentId.trim().lowercase(Locale.ROOT)
|
||||||
|
if (id.startsWith("voice_") || id.startsWith("voice-") || id.startsWith("audio_") || id.startsWith("audio-")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalized = preview.trim()
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
|
||||||
|
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
|
||||||
|
if (duration < 0) return false
|
||||||
|
|
||||||
|
val tail = normalized.substringAfter("::", "").trim()
|
||||||
|
if (tail.isEmpty()) return true
|
||||||
|
if (tail.startsWith("video/", ignoreCase = true)) return false
|
||||||
|
if (tail.startsWith("audio/", ignoreCase = true)) return true
|
||||||
|
if (!tail.contains(",")) return false
|
||||||
|
|
||||||
|
val values = tail.split(",")
|
||||||
|
return values.size >= 2 && values.all { it.trim().toFloatOrNull() != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLikelyVideoCircleAttachmentPreview(preview: String, attachmentId: String): Boolean {
|
||||||
|
val id = attachmentId.trim().lowercase(Locale.ROOT)
|
||||||
|
if (id.startsWith("video_circle_") || id.startsWith("video-circle-")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalized = preview.trim()
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
|
||||||
|
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
|
||||||
|
if (duration < 0) return false
|
||||||
|
|
||||||
|
val mime = normalized.substringAfter("::", "").trim().lowercase(Locale.ROOT)
|
||||||
|
return mime.startsWith("video/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLikelyFileAttachmentPreview(preview: String): Boolean {
|
||||||
|
val normalized = preview.trim()
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
val parts = normalized.split("::")
|
||||||
|
if (parts.size < 2) return false
|
||||||
|
|
||||||
|
val first = parts[0]
|
||||||
|
return when {
|
||||||
|
parts.size >= 3 && forwardUuidRegex.matches(first) && parts[1].toLongOrNull() != null -> true
|
||||||
|
parts.size >= 2 && first.toLongOrNull() != null -> true
|
||||||
|
parts.size >= 2 && forwardUuidRegex.matches(first) -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
|
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()) {
|
||||||
val context = getApplication<Application>()
|
replyMsgsToSend.map { msg ->
|
||||||
val isSaved = (sender == recipient)
|
ForwardSourceMessage(
|
||||||
var fwdIdx = 0
|
messageId = msg.messageId,
|
||||||
|
senderPublicKey = msg.publicKey,
|
||||||
for (msg in replyMsgsToSend) {
|
chachaKeyPlainHex = msg.chachaKeyPlainHex,
|
||||||
for (att in msg.attachments) {
|
attachments = 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 {
|
} else {
|
||||||
""
|
emptyList()
|
||||||
}
|
}
|
||||||
|
val forwardRewriteResult =
|
||||||
forwardedAttMap[att.id] =
|
prepareForwardAttachmentRewrites(
|
||||||
att.copy(
|
context = getApplication(),
|
||||||
id = newAttId,
|
sourceMessages = forwardSources,
|
||||||
preview = blurhash,
|
encryptionContext = encryptionContext,
|
||||||
blob = "",
|
privateKey = privateKey,
|
||||||
transportTag = uploadTag,
|
isSavedMessages = isSavedMessages,
|
||||||
transportServer = transportServer
|
timestamp = timestamp
|
||||||
)
|
)
|
||||||
|
val forwardedAttMap = forwardRewriteResult.rewrittenAttachments
|
||||||
// Сохраняем локально с новым ID
|
val rewrittenForwardMessageIds = forwardRewriteResult.rewrittenMessageIds
|
||||||
// publicKey = msg.publicKey чтобы совпадал с JSON для parseReplyFromAttachments
|
val outgoingForwardPlainKeyHex =
|
||||||
AttachmentFileManager.saveAttachment(
|
encryptionContext.plainKeyAndNonce
|
||||||
context = context,
|
?.joinToString("") { "%02x".format(it) }
|
||||||
blob = imageBlob,
|
.orEmpty()
|
||||||
attachmentId = newAttId,
|
|
||||||
publicKey = msg.publicKey,
|
|
||||||
privateKey = privateKey
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,31 +687,146 @@ 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 =
|
if (typeValue == 4) return true
|
||||||
when (rawType) {
|
|
||||||
|
val preview = first.optString("preview", "").trim()
|
||||||
|
isLikelyCallAttachmentPreview(preview)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttachmentTypeValue(rawType: Any?): Int {
|
||||||
|
return when (rawType) {
|
||||||
is Number -> rawType.toInt()
|
is Number -> rawType.toInt()
|
||||||
is String -> {
|
is String -> {
|
||||||
val normalized = rawType.trim()
|
val normalized = rawType.trim()
|
||||||
normalized.toIntOrNull()
|
normalized.toIntOrNull()
|
||||||
?: when (normalized.lowercase(Locale.ROOT)) {
|
?: run {
|
||||||
"call" -> 4
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
if (typeValue == 4) return true
|
}
|
||||||
|
|
||||||
val preview = first.optString("preview", "").trim()
|
private fun inferLegacyAttachmentTypeFromJson(attachment: JSONObject): Int {
|
||||||
if (preview.isEmpty()) return false
|
val preview = attachment.optString("preview", "")
|
||||||
val tail = preview.substringAfterLast("::", preview).trim()
|
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
|
if (tail.toIntOrNull() != null) return true
|
||||||
|
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
|
||||||
|
.containsMatchIn(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
|
private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
|
||||||
.containsMatchIn(preview)
|
val payload = blob.ifBlank { preview }.trim()
|
||||||
} catch (_: Throwable) {
|
if (payload.isEmpty()) return false
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,6 +375,7 @@ fun InAppCameraScreen(
|
|||||||
.graphicsLayer { alpha = animationProgress.value }
|
.graphicsLayer { alpha = animationProgress.value }
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
) {
|
) {
|
||||||
|
if (hasCameraPermission) {
|
||||||
// Camera Preview with pinch-to-zoom
|
// Camera Preview with pinch-to-zoom
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
@@ -336,6 +416,13 @@ fun InAppCameraScreen(
|
|||||||
},
|
},
|
||||||
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,6 +447,7 @@ fun InAppCameraScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flash button
|
// Flash button
|
||||||
|
if (hasCameraPermission) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val nextFlashMode = when (flashMode) {
|
val nextFlashMode = when (flashMode) {
|
||||||
@@ -386,6 +474,9 @@ fun InAppCameraScreen(
|
|||||||
tint = Color.White
|
tint = Color.White
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.size(44.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zoom slider with −/+ buttons (Telegram-style) ──
|
// ── Zoom slider with −/+ buttons (Telegram-style) ──
|
||||||
@@ -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,6 +585,7 @@ fun InAppCameraScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bottom controls (Shutter + Flip)
|
// Bottom controls (Shutter + Flip)
|
||||||
|
if (hasCameraPermission) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -551,6 +643,45 @@ fun InAppCameraScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CameraPermissionContent(
|
||||||
|
permanentlyDenied: Boolean,
|
||||||
|
onGrantAccess: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Camera access is required",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (permanentlyDenied)
|
||||||
|
"Permission is denied permanently. Open settings and allow camera access."
|
||||||
|
else "Allow camera permission to use in-app camera.",
|
||||||
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Button(
|
||||||
|
onClick = if (permanentlyDenied) onOpenSettings else onGrantAccess,
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
) {
|
||||||
|
Text(if (permanentlyDenied) "Open settings" else "Grant access")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить CameraProvider с suspend
|
* Получить CameraProvider с suspend
|
||||||
|
|||||||
@@ -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) ============
|
||||||
|
|||||||
Reference in New Issue
Block a user