Compare commits

...

5 Commits

18 changed files with 2112 additions and 300 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.7"
val rosettaVersionCode = 19 // Increment on each release
val rosettaVersionName = "1.1.8"
val rosettaVersionCode = 20 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -17,14 +17,22 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Уведомления
- Исправлена регистрация push-токена после переподключений
- Добавлен fallback для нестандартных payload, чтобы push-уведомления не терялись
- Улучшена отправка push-токена сразу после получения FCM токена
Полноэкранное фото из медиапикера
- Переработан fullscreen-оверлей: фото открывается поверх чата и перекрывает интерфейс
- Добавлены свайпы влево/вправо для перехода по фото внутри выбранной галереи
- Добавлено закрытие свайпом вверх/вниз с плавной анимацией
- Убраны рывки, мигание и лишнее уменьшение фото при перелистывании
Интерфейс
- Улучшено поведение сворачивания приложения в стиле Telegram
- Стабилизировано отображение нижней системной панели навигации
Редактирование и отправка
- Инструменты редактирования фото перенесены в полноэкранный оверлей медиапикера
- Улучшена пересылка фото через optimistic UI: сообщение отображается сразу
- Исправлена множественная пересылка сообщений, включая сценарий после смены forwarding options
- Исправлено копирование пересланных сообщений: теперь корректно копируется текст forward/reply
Группы
- В списках участников групп отображается только статус online/offline
- На экране создания группы у текущего пользователя статус отображается как online
- Поиск участников по username сохранен
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -137,6 +138,28 @@ private data class IncomingRunAvatarUiState(
val overlays: List<IncomingRunAvatarOverlay>
)
private fun extractCopyableMessageText(message: ChatMessage): String {
val directText = message.text.trim()
if (directText.isNotEmpty()) {
return directText
}
val forwardedText =
message.forwardedMessages
.mapNotNull { forwarded -> forwarded.text.trim().takeIf { it.isNotEmpty() } }
.joinToString("\n\n")
if (forwardedText.isNotEmpty()) {
return forwardedText
}
val replyText = message.replyData?.text?.trim().orEmpty()
if (replyText.isNotEmpty()) {
return replyText
}
return ""
}
@OptIn(
ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -162,6 +185,9 @@ fun ChatDetailScreen(
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
@@ -290,6 +316,11 @@ fun ChatDetailScreen(
var imageViewerInitialIndex by remember { mutableStateOf(0) }
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
var imageViewerImages by remember { mutableStateOf<List<ViewableImage>>(emptyList()) }
var simplePickerPreviewUri by remember { mutableStateOf<Uri?>(null) }
var simplePickerPreviewSourceThumb by remember { mutableStateOf<ThumbnailPosition?>(null) }
var simplePickerPreviewCaption by remember { mutableStateOf("") }
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
if (!view.isInEditMode) {
@@ -364,7 +395,8 @@ fun ChatDetailScreen(
showEmojiPicker,
pendingCameraPhotoUri,
pendingGalleryImages,
showInAppCamera
showInAppCamera,
simplePickerPreviewUri
) {
derivedStateOf {
showImageViewer ||
@@ -372,7 +404,8 @@ fun ChatDetailScreen(
showEmojiPicker ||
pendingCameraPhotoUri != null ||
pendingGalleryImages.isNotEmpty() ||
showInAppCamera
showInAppCamera ||
simplePickerPreviewUri != null
}
}
@@ -380,6 +413,13 @@ fun ChatDetailScreen(
onImageViewerChanged(shouldLockParentSwipeBack)
}
LaunchedEffect(simplePickerPreviewUri) {
if (simplePickerPreviewUri != null) {
showContextMenu = false
contextMenuMessage = null
}
}
DisposableEffect(Unit) {
onDispose { onImageViewerChanged(false) }
}
@@ -1189,30 +1229,41 @@ fun ChatDetailScreen(
{ it.id }
)
)
.joinToString(
"\n\n"
) {
.mapNotNull {
msg
->
val time =
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}"
val messageText =
extractCopyableMessageText(
msg
)
if (messageText.isBlank()) {
null
} else {
val time =
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
}
}
clipboardManager
.setText(
androidx.compose
.ui
.text
.AnnotatedString(
textToCopy
)
)
.joinToString(
"\n\n"
)
if (textToCopy.isNotBlank()) {
clipboardManager
.setText(
androidx.compose
.ui
.text
.AnnotatedString(
textToCopy
)
)
}
selectedMessages =
emptySet()
}
@@ -1801,7 +1852,10 @@ fun ChatDetailScreen(
bottom =
16.dp
)
.navigationBarsPadding()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
.graphicsLayer {
scaleX =
buttonScale
@@ -1975,14 +2029,7 @@ fun ChatDetailScreen(
it.type !=
AttachmentType
.MESSAGES
}
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
}
)
}
} else {
@@ -2014,14 +2061,7 @@ fun ChatDetailScreen(
it.type !=
AttachmentType
.MESSAGES
}
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
}
))
}
}
@@ -2130,6 +2170,47 @@ fun ChatDetailScreen(
viewModel
.clearReplyMessages()
},
onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) {
return@MessageInputBar
}
val forwardMessages =
panelMessages.map { msg ->
ForwardManager.ForwardMessage(
messageId =
msg.messageId,
text = msg.text,
timestamp =
msg.timestamp,
isOutgoing =
msg.isOutgoing,
senderPublicKey =
msg.publicKey.ifEmpty {
if (msg.isOutgoing) currentUserPublicKey
else user.publicKey
},
originalChatPublicKey =
user.publicKey,
senderName =
msg.senderName.ifEmpty {
if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
else user.title.ifEmpty { user.username.ifEmpty { "User" } }
},
attachments =
msg.attachments
.filter {
it.type != AttachmentType.MESSAGES
}
.map { it.copy(localUri = "") }
)
}
ForwardManager.setForwardMessages(
forwardMessages,
showPicker = false
)
showForwardPicker = true
},
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker =
@@ -2168,7 +2249,9 @@ fun ChatDetailScreen(
inputFocusTrigger =
inputFocusTrigger,
suppressKeyboard =
showInAppCamera
showInAppCamera,
hasNativeNavigationBar =
hasNativeNavigationBar
)
}
}
@@ -2513,6 +2596,9 @@ fun ChatDetailScreen(
avatarRepository =
avatarRepository,
onLongClick = {
if (simplePickerPreviewUri != null) {
return@MessageBubble
}
// 📳 Haptic feedback при долгом нажатии
// Не разрешаем выделять avatar-сообщения
val hasAvatar =
@@ -2553,6 +2639,9 @@ fun ChatDetailScreen(
)
},
onClick = {
if (simplePickerPreviewUri != null) {
return@MessageBubble
}
if (shouldIgnoreTapAfterLongPress(
selectionKey
)
@@ -2734,7 +2823,7 @@ fun ChatDetailScreen(
// 💬 Context menu anchored to this bubble
if (showContextMenu && contextMenuMessage?.id == message.id) {
val msg = contextMenuMessage!!
MessageContextMenu(
MessageContextMenu(
expanded = true,
onDismiss = {
showContextMenu = false
@@ -2743,7 +2832,7 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme,
isPinned = contextMenuIsPinned,
isOutgoing = msg.isOutgoing,
hasText = msg.text.isNotBlank(),
hasText = extractCopyableMessageText(msg).isNotBlank(),
isSystemAccount = isSystemAccount,
onReply = {
viewModel.setReplyMessages(listOf(msg))
@@ -2752,7 +2841,7 @@ fun ChatDetailScreen(
},
onCopy = {
clipboardManager.setText(
androidx.compose.ui.text.AnnotatedString(msg.text)
androidx.compose.ui.text.AnnotatedString(extractCopyableMessageText(msg))
)
showContextMenu = false
contextMenuMessage = null
@@ -3005,7 +3094,24 @@ fun ChatDetailScreen(
onAvatarClick = {
viewModel.sendAvatarMessage()
},
recipientName = user.title
recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
hideInputOverlays()
showMediaPicker = false
showContextMenu = false
contextMenuMessage = null
simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri
val normalizedGallery =
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
simplePickerPreviewGalleryUris = normalizedGallery
simplePickerPreviewInitialIndex =
initialIndex.coerceIn(
0,
(normalizedGallery.size - 1).coerceAtLeast(0)
)
}
)
} else {
MediaPickerBottomSheet(
@@ -3049,7 +3155,24 @@ fun ChatDetailScreen(
onAvatarClick = {
viewModel.sendAvatarMessage()
},
recipientName = user.title
recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
hideInputOverlays()
showMediaPicker = false
showContextMenu = false
contextMenuMessage = null
simplePickerPreviewSourceThumb = sourceThumb
simplePickerPreviewCaption = ""
simplePickerPreviewUri = uri
val normalizedGallery =
if (galleryUris.isNotEmpty()) galleryUris else listOf(uri)
simplePickerPreviewGalleryUris = normalizedGallery
simplePickerPreviewInitialIndex =
initialIndex.coerceIn(
0,
(normalizedGallery.size - 1).coerceAtLeast(0)
)
}
)
}
} // Закрытие Box wrapper для Scaffold content
@@ -3282,12 +3405,40 @@ fun ChatDetailScreen(
}
val forwardMessages = ForwardManager.consumeForwardMessages()
ForwardManager.clear()
if (forwardMessages.isEmpty()) {
ForwardManager.clear()
return@ForwardChatPickerBottomSheet
}
// Реальная отправка forward во все выбранные чаты.
// Desktop parity: если выбран один чат, не отправляем сразу.
// Открываем чат с forward-панелью, чтобы пользователь мог
// добавить подпись и отправить вручную.
if (selectedDialogs.size == 1) {
val targetDialog = selectedDialogs.first()
ForwardManager.setForwardMessages(
forwardMessages,
showPicker = false
)
ForwardManager.selectChat(targetDialog.opponentKey)
if (targetDialog.opponentKey != user.publicKey) {
val searchUser =
SearchUser(
title = targetDialog.opponentTitle,
username =
targetDialog.opponentUsername,
publicKey = targetDialog.opponentKey,
verified = targetDialog.verified,
online = targetDialog.isOnline
)
onNavigateToChat(searchUser)
}
return@ForwardChatPickerBottomSheet
}
ForwardManager.clear()
// Мультивыбор оставляем прямой отправкой как раньше.
selectedDialogs.forEach { dialog ->
viewModel.sendForwardDirectly(
dialog.opponentKey,
@@ -3314,6 +3465,38 @@ fun ChatDetailScreen(
} // Закрытие Scaffold content lambda
simplePickerPreviewUri?.let { previewUri ->
SimpleFullscreenPhotoOverlay(
imageUri = previewUri,
sourceThumbnail = simplePickerPreviewSourceThumb,
galleryImageUris = simplePickerPreviewGalleryUris,
initialGalleryIndex = simplePickerPreviewInitialIndex,
modifier = Modifier.fillMaxSize().zIndex(100f),
showCaptionInput = true,
caption = simplePickerPreviewCaption,
onCaptionChange = { simplePickerPreviewCaption = it },
isDarkTheme = isDarkTheme,
onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false
simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null
simplePickerPreviewCaption = ""
simplePickerPreviewGalleryUris = emptyList()
simplePickerPreviewInitialIndex = 0
inputFocusTrigger++
},
onDismiss = {
simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null
simplePickerPreviewCaption = ""
simplePickerPreviewGalleryUris = emptyList()
simplePickerPreviewInitialIndex = 0
inputFocusTrigger++
}
)
}
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(

View File

@@ -1582,10 +1582,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
if (attId.isNotEmpty()) {
fwdAttachments.add(MessageAttachment(
id = attId, type = attType, preview = attPreview,
blob = attBlob, width = attWidth, height = attHeight
blob = attBlob, width = attWidth, height = attHeight,
localUri = attLocalUri
))
}
}
@@ -1662,6 +1664,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0)
val attLocalUri = attJson.optString("localUri", "")
if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add(
@@ -1671,7 +1674,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
preview = attPreview,
blob = attBlob,
width = attWidth,
height = attHeight
height = attHeight,
localUri = attLocalUri
)
)
}
@@ -2239,6 +2243,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey
val privateKey = myPrivateKey
val replyMsgs = _replyMessages.value
val replyMsgsToSend = replyMsgs.toList()
val isForward = _isForwardMode.value
// Разрешаем отправку пустого текста если есть reply/forward
@@ -2267,10 +2272,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val timestamp = System.currentTimeMillis()
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
// Работает и для reply, и для forward
// Используется для обычного reply (не forward).
val replyData: ReplyData? =
if (replyMsgs.isNotEmpty()) {
val firstReply = replyMsgs.first()
if (replyMsgsToSend.isNotEmpty()) {
val firstReply = replyMsgsToSend.first()
// 🖼️ Получаем attachments из текущих сообщений для превью
// Fallback на firstReply.attachments для forward из другого чата
val replyAttachments =
@@ -2298,8 +2303,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
} else null
// Сохраняем reply для отправки ПЕРЕД очисткой
val replyMsgsToSend = replyMsgs.toList()
// 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble,
// а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение.
val optimisticForwardedMessages: List<ReplyData> =
if (isForward && replyMsgsToSend.isNotEmpty()) {
replyMsgsToSend.map { msg ->
val senderDisplayName =
if (msg.isOutgoing) "You"
else msg.senderName.ifEmpty {
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
}
val resolvedAttachments =
_messages.value.find { it.id == msg.messageId }?.attachments
?: msg.attachments.filter { it.type != AttachmentType.MESSAGES }
ReplyData(
messageId = msg.messageId,
senderName = senderDisplayName,
text = msg.text,
isFromMe = msg.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = resolvedAttachments,
senderPublicKey = msg.publicKey.ifEmpty {
if (msg.isOutgoing) myPublicKey ?: "" else opponentKey ?: ""
},
recipientPrivateKey = myPrivateKey ?: ""
)
}
} else {
emptyList()
}
// Сохраняем режим forward для отправки ПЕРЕД очисткой
val isForwardToSend = isForward
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
@@ -2310,7 +2345,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
replyData = replyData // Данные для reply bubble
replyData = if (isForwardToSend) null else replyData,
forwardedMessages = optimisticForwardedMessages
)
// <20> Безопасное добавление с проверкой дубликатов
@@ -2566,12 +2602,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val privateKey = myPrivateKey ?: return
if (forwardMessages.isEmpty()) return
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isCurrentDialogTarget = recipientPublicKey == opponentKey
viewModelScope.launch(Dispatchers.IO) {
try {
val context = getApplication<Application>()
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isSavedMessages = (sender == recipientPublicKey)
val db = RosettaDatabase.getDatabase(context)
val dialogDao = db.dialogDao()
suspend fun refreshTargetDialog() {
if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(sender)
} else {
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
}
}
// Шифрование (пустой текст для forward)
val encryptionContext =
@@ -2584,11 +2632,133 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val replyAttachmentId = "reply_${timestamp}"
val messageAttachments = mutableListOf<MessageAttachment>()
var replyBlobForDatabase = ""
fun buildForwardReplyJson(
forwardedIdMap: Map<String, Pair<String, String>> = emptyMap(),
includeLocalUri: Boolean
): JSONArray {
val replyJsonArray = JSONArray()
forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
// 📸 Forward: сначала загружаем IMAGE на CDN, чтобы обновить ссылки в MESSAGES blob
attachmentsArray.put(
JSONObject().apply {
put("id", attId)
put("type", att.type.value)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
if (includeLocalUri && att.localUri.isNotEmpty()) {
put("localUri", att.localUri)
}
}
)
}
replyJsonArray.put(
JSONObject().apply {
put("message_id", fm.messageId)
put("publicKey", fm.senderPublicKey)
put("message", fm.text)
put("timestamp", fm.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
}
)
}
return replyJsonArray
}
// 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге
if (isCurrentDialogTarget) {
val optimisticForwardedMessages =
forwardMessages.map { fm ->
val senderDisplayName =
fm.senderName.ifEmpty {
if (fm.senderPublicKey == sender) "You" else "User"
}
ReplyData(
messageId = fm.messageId,
senderName = senderDisplayName,
text = fm.text,
isFromMe = fm.senderPublicKey == sender,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES },
senderPublicKey = fm.senderPublicKey,
recipientPrivateKey = privateKey
)
}
withContext(Dispatchers.Main) {
addMessageSafely(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
forwardedMessages = optimisticForwardedMessages
)
)
}
}
// 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу
val optimisticReplyBlobPlaintext =
buildForwardReplyJson(includeLocalUri = true).toString()
val optimisticReplyBlobForDatabase =
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", replyAttachmentId)
put("type", AttachmentType.MESSAGES.value)
put("preview", "")
put("width", 0)
put("height", 0)
put("blob", optimisticReplyBlobForDatabase)
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipientPublicKey
)
refreshTargetDialog()
if (isSavedMessages && isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId → (newAttId, newPreview)
val forwardedAttMap = mutableMapOf<String, Pair<String, String>>()
var fwdIdx = 0
@@ -2631,47 +2801,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
// Формируем MESSAGES attachment (reply/forward JSON) с обновлёнными ссылками
val replyJsonArray = JSONArray()
forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
// Для forward IMAGE: подставляем НОВЫЙ id и preview (CDN tag)
val fwdInfo = forwardedAttMap[att.id]
val attId = fwdInfo?.first ?: att.id
val attPreview = fwdInfo?.second ?: att.preview
attachmentsArray.put(JSONObject().apply {
put("id", attId)
put("type", att.type.value)
put("preview", attPreview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
})
}
replyJsonArray.put(JSONObject().apply {
put("message_id", fm.messageId)
put("publicKey", fm.senderPublicKey)
put("message", fm.text)
put("timestamp", fm.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
})
}
val replyBlobPlaintext = replyJsonArray.toString()
val replyBlobPlaintext =
buildForwardReplyJson(
forwardedIdMap = forwardedAttMap,
includeLocalUri = false
)
.toString()
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val replyAttachmentId = "reply_${timestamp}"
messageAttachments.add(MessageAttachment(
id = replyAttachmentId,
blob = encryptedReplyBlob,
type = AttachmentType.MESSAGES,
preview = ""
))
val finalMessageAttachments =
listOf(
MessageAttachment(
id = replyAttachmentId,
blob = encryptedReplyBlob,
type = AttachmentType.MESSAGES,
preview = ""
)
)
// Отправляем пакет
val packet = PacketMessage().apply {
@@ -2683,58 +2831,57 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = messageAttachments
attachments = finalMessageAttachments
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
// Сохраняем в БД
val attachmentsJson = JSONArray().apply {
messageAttachments.forEach { att ->
put(JSONObject().apply {
put("id", att.id)
put("type", att.type.value)
put("preview", att.preview)
put("width", att.width)
put("height", att.height)
put("blob", when (att.type) {
AttachmentType.MESSAGES -> replyBlobForDatabase
else -> ""
})
})
}
}.toString()
val finalAttachmentsJson =
JSONArray()
.apply {
finalMessageAttachments.forEach { att ->
put(
JSONObject().apply {
put("id", att.id)
put("type", att.type.value)
put("preview", att.preview)
put("width", att.width)
put("height", att.height)
put(
"blob",
when (att.type) {
AttachmentType.MESSAGES ->
replyBlobForDatabase
else -> ""
}
)
}
)
}
}
.toString()
saveMessageToDatabase(
updateMessageStatusAndAttachmentsInDb(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
opponentPublicKey = recipientPublicKey
delivered = 1,
attachmentsJson = finalAttachmentsJson
)
// Обновляем диалог (для списка чатов) из таблицы сообщений.
val db = RosettaDatabase.getDatabase(context)
val dialogDao = db.dialogDao()
if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(sender)
} else {
dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
} catch (e: Exception) { }
refreshTargetDialog()
} catch (e: Exception) {
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
}
}
}

View File

@@ -273,6 +273,9 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -443,10 +446,6 @@ fun ChatsListScreen(
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
// Navigation bar
com.rosetta.messenger.ui.utils.NavigationModeUtils
.applyNavigationBarVisibility(insetsController, context, isDarkTheme)
onDispose { }
}
@@ -754,7 +753,10 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
.navigationBarsPadding()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
ModalNavigationDrawer(
drawerState = drawerState,
@@ -812,6 +814,15 @@ fun ChatsListScreen(
"rosetta",
ignoreCase = true
)
val isFreddyOfficial =
accountName.equals(
"freddy",
ignoreCase = true
) ||
accountUsername.equals(
"freddy",
ignoreCase = true
)
// Avatar row with theme toggle
Row(
modifier = Modifier.fillMaxWidth(),
@@ -925,7 +936,7 @@ fun ChatsListScreen(
fontWeight = FontWeight.Bold,
color = Color.White
)
if (accountVerified > 0 || isRosettaOfficial) {
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(
modifier =
Modifier.width(
@@ -935,7 +946,7 @@ fun ChatsListScreen(
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
size = 15,
badgeTint = PrimaryBlue
badgeTint = if (isDarkTheme) Color.White else PrimaryBlue
)
}
}
@@ -1230,7 +1241,14 @@ fun ChatsListScreen(
// ═══════════════════════════════════════════════════════════
// FOOTER - Version + Update Banner
// ═══════════════════════════════════════════════════════════
Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
Column(
modifier =
Modifier.fillMaxWidth()
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) {
// Telegram-style update banner
val curUpdate = sduUpdateState
val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable ||
@@ -3886,7 +3904,10 @@ fun DialogItemContent(
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) {
val isFreddyVerified = dialog.opponentUsername.equals("freddy", ignoreCase = true) ||
dialog.opponentTitle.equals("freddy", ignoreCase = true) ||
displayName.equals("freddy", ignoreCase = true)
if (dialog.verified > 0 || isRosettaOfficial || isFreddyVerified) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1,

View File

@@ -737,11 +737,7 @@ fun GroupInfoScreen(
info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() }
?: fallbackName
val subtitle = when {
isOnline -> "online"
info?.username?.isNotBlank() == true -> "@${info.username}"
else -> key.take(18)
}
val subtitle = if (isOnline) "online" else "offline"
GroupMemberUi(
publicKey = key,
title = displayTitle,
@@ -761,9 +757,11 @@ fun GroupInfoScreen(
if (query.isBlank()) {
true
} else {
val username = memberInfoByKey[member.publicKey]?.username?.lowercase().orEmpty()
member.title.lowercase().contains(query) ||
member.subtitle.lowercase().contains(query) ||
member.publicKey.lowercase().contains(query)
member.publicKey.lowercase().contains(query) ||
username.contains(query)
}
}
}

View File

@@ -183,7 +183,7 @@ fun GroupSetupScreen(
.ifBlank { normalizedUsername }
.ifBlank { shortPublicKey(accountPublicKey) }
}
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you"
val selfSubtitle = "online"
fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened(

View File

@@ -38,6 +38,7 @@ import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen
import com.rosetta.messenger.ui.chats.components.SimpleFullscreenPhotoViewer
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
@@ -45,6 +46,7 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import android.net.Uri
@@ -121,6 +123,13 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
}
}
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/**
* Telegram-style attach alert (media picker bottom sheet).
*
@@ -146,9 +155,13 @@ fun ChatAttachAlert(
currentUserPublicKey: String = "",
maxSelection: Int = 10,
recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null,
viewModel: AttachAlertViewModel = viewModel()
) {
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val focusManager = LocalFocusManager.current
@@ -195,6 +208,23 @@ fun ChatAttachAlert(
// Keyboard helpers
// ═══════════════════════════════════════════════════════════
fun hideUnderlyingChatKeyboard() {
focusManager.clearFocus(force = true)
activity?.currentFocus?.clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val servedToken =
activity?.currentFocus?.windowToken
?: activity?.window?.decorView?.findFocus()?.windowToken
?: activity?.window?.decorView?.windowToken
servedToken?.let { token ->
imm.hideSoftInputFromWindow(token, 0)
imm.hideSoftInputFromWindow(token, InputMethodManager.HIDE_NOT_ALWAYS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity?.window?.insetsController?.hide(android.view.WindowInsets.Type.ime())
}
}
fun hideKeyboard() {
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
pendingCaptionFocus = false
@@ -268,10 +298,14 @@ fun ChatAttachAlert(
if (coordinator.emojiHeight == 0.dp) {
// Use saved keyboard height minus nav bar (same as spacer)
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
val navBarPx = (context as? Activity)?.window?.decorView?.let { view ->
androidx.core.view.ViewCompat.getRootWindowInsets(view)
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
} ?: 0
val navBarPx = if (hasNativeNavigationBar) {
(context as? Activity)?.window?.decorView?.let { view ->
androidx.core.view.ViewCompat.getRootWindowInsets(view)
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
} ?: 0
} else {
0
}
val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0
coordinator.emojiHeight = if (effectivePx > 0) {
with(density) { effectivePx.toDp() }
@@ -293,7 +327,11 @@ fun ChatAttachAlert(
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() }
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
val navigationBarInsetPx = if (hasNativeNavigationBar) {
WindowInsets.navigationBars.getBottom(density).toFloat()
} else {
0f
}
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
val collapsedHeightPx = screenHeightPx * 0.72f
@@ -456,6 +494,9 @@ fun ChatAttachAlert(
LaunchedEffect(showSheet) {
if (showSheet && state.editingItem == null) {
// Close chat keyboard before showing picker so no IME layer remains under it.
hideUnderlyingChatKeyboard()
kotlinx.coroutines.delay(16)
// Telegram pattern: set ADJUST_NOTHING on Activity before showing popup
// This prevents the system from resizing the layout when focus changes
activity?.window?.let { win ->
@@ -668,22 +709,40 @@ fun ChatAttachAlert(
val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose {
window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
}
} else {
onDispose { }
}
}
LaunchedEffect(shouldShow) {
if (!shouldShow) return@LaunchedEffect
LaunchedEffect(shouldShow, state.editingItem) {
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) }
.collect { (alpha, fullScreen, dark) ->
snapshotFlow {
PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
@@ -693,8 +752,28 @@ fun ChatAttachAlert(
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false
}
window.navigationBarColor = android.graphics.Color.TRANSPARENT
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
}
}
@@ -710,7 +789,7 @@ fun ChatAttachAlert(
// POPUP RENDERING
// ═══════════════════════════════════════════════════════════
if (shouldShow) {
if (shouldShow && state.editingItem == null) {
Popup(
alignment = Alignment.TopStart,
onDismissRequest = {
@@ -823,8 +902,9 @@ fun ChatAttachAlert(
} else keyboardSpacerDp
// When keyboard or emoji is open, nav bar is behind — don't pad for it
val navBarDp = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0.dp
else with(density) { navigationBarInsetPx.toDp() }
val navInsetPxForSheet = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0f
else navigationBarInsetPx
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
Box(
modifier = Modifier
@@ -833,13 +913,12 @@ fun ChatAttachAlert(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { requestClose() }
.padding(bottom = navBarDp),
) { requestClose() },
contentAlignment = Alignment.BottomCenter
) {
// Sheet height stays constant — keyboard space is handled by
// internal Spacer, not by shrinking the container (Telegram approach).
val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx)
val visibleSheetHeightPx = (sheetHeightPx.value + navInsetPxForSheet).coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress =
@@ -976,8 +1055,19 @@ fun ChatAttachAlert(
},
onItemClick = { item, position ->
if (!item.isVideo) {
thumbnailPosition = position
viewModel.setEditingItem(item)
hideKeyboard()
if (onPhotoPreviewRequested != null) {
val photoItems = state.visibleMediaItems.filter { !it.isVideo }
val photoUris = photoItems.map { it.uri }
val currentIndex =
photoItems.indexOfFirst { it.id == item.id }
.takeIf { it >= 0 }
?: photoUris.indexOf(item.uri).coerceAtLeast(0)
onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex)
} else {
thumbnailPosition = position
viewModel.setEditingItem(item)
}
} else {
viewModel.toggleSelection(item.id, maxSelection)
}
@@ -1089,6 +1179,9 @@ fun ChatAttachAlert(
if (!coordinator.isEmojiBoxVisible) {
Spacer(modifier = Modifier.height(keyboardSpacerDp))
}
if (navInsetDpForSheet > 0.dp) {
Spacer(modifier = Modifier.height(navInsetDpForSheet))
}
} // end Column
// ── Floating Send Button ──
@@ -1112,7 +1205,7 @@ fun ChatAttachAlert(
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = bottomInputPadding)
.padding(bottom = bottomInputPadding + navInsetDpForSheet)
)
} // end Box sheet container
@@ -1173,45 +1266,22 @@ fun ChatAttachAlert(
// ═══════════════════════════════════════════════════════════
state.editingItem?.let { item ->
ImageEditorScreen(
val galleryPhotoItems = state.visibleMediaItems.filter { !it.isVideo }
val galleryPhotoUris = galleryPhotoItems.map { it.uri }
val initialGalleryIndex =
galleryPhotoItems.indexOfFirst { it.id == item.id }
.takeIf { it >= 0 }
?: galleryPhotoUris.indexOf(item.uri).coerceAtLeast(0)
SimpleFullscreenPhotoViewer(
imageUri = item.uri,
sourceThumbnail = thumbnailPosition,
galleryImageUris = galleryPhotoUris,
initialGalleryIndex = initialGalleryIndex,
onDismiss = {
viewModel.setEditingItem(null)
thumbnailPosition = null
shouldShow = true
},
onSave = { editedUri ->
viewModel.setEditingItem(null)
thumbnailPosition = null
if (onMediaSelectedWithCaption == null) {
previewPhotoUri = editedUri
} else {
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(mediaItem), "")
onDismiss()
}
},
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
viewModel.setEditingItem(null)
thumbnailPosition = null
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelectedWithCaption(mediaItem, caption)
onDismiss()
} else null,
isDarkTheme = isDarkTheme,
showCaptionInput = onMediaSelectedWithCaption != null,
recipientName = recipientName,
thumbnailPosition = thumbnailPosition
}
)
}

View File

@@ -2460,6 +2460,29 @@ private fun ForwardedImagePreview(
val cached = ImageBitmapCache.get(cacheKey)
if (cached != null) { imageBitmap = cached; return@LaunchedEffect }
// 🚀 Optimistic forward: если есть localUri, показываем сразу локальный файл
if (attachment.localUri.isNotEmpty()) {
val localBitmap =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver
.openInputStream(
android.net.Uri.parse(
attachment.localUri
)
)
?.use { input ->
BitmapFactory.decodeStream(input)
}
}.getOrNull()
}
if (localBitmap != null) {
imageBitmap = localBitmap
ImageBitmapCache.put(cacheKey, localBitmap)
return@LaunchedEffect
}
}
withContext(Dispatchers.IO) {
// Try local file cache first
try {

View File

@@ -829,7 +829,7 @@ fun ImageEditorScreen(
* Telegram-style toolbar - icons only, no labels
*/
@Composable
private fun TelegramToolbar(
internal fun TelegramToolbar(
currentTool: EditorTool,
showCaptionInput: Boolean,
isSaving: Boolean,
@@ -958,7 +958,7 @@ private fun TelegramToolButton(
* Telegram-style color picker with brush size
*/
@Composable
private fun TelegramColorPicker(
internal fun TelegramColorPicker(
selectedColor: Color,
brushSize: Float,
onColorSelected: (Color) -> Unit,
@@ -1044,7 +1044,7 @@ private fun TelegramColorPicker(
* Telegram-style rotate bar
*/
@Composable
private fun TelegramRotateBar(
internal fun TelegramRotateBar(
onRotateLeft: () -> Unit,
onRotateRight: () -> Unit,
onFlipHorizontal: () -> Unit,
@@ -1301,7 +1301,7 @@ private suspend fun saveEditedImageOld(
}
/** Save edited image synchronously (with all editor changes). */
private suspend fun saveEditedImageSync(
internal suspend fun saveEditedImageSync(
context: Context,
photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?,
@@ -1489,7 +1489,7 @@ private fun getOrientedImageDimensions(context: Context, uri: Uri): Pair<Int, In
}
/** Launch UCrop activity */
private fun launchCrop(
internal fun launchCrop(
context: Context,
sourceUri: Uri,
launcher: androidx.activity.result.ActivityResultLauncher<Intent>

View File

@@ -69,6 +69,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import kotlinx.coroutines.Dispatchers
@@ -82,6 +83,13 @@ import kotlin.math.roundToInt
private const val TAG = "MediaPickerBottomSheet"
private const val ALL_MEDIA_ALBUM_ID = 0L
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/**
* Media item from gallery
*/
@@ -125,9 +133,13 @@ fun MediaPickerBottomSheet(
onAvatarClick: () -> Unit = {},
currentUserPublicKey: String = "",
maxSelection: Int = 10,
recipientName: String? = null
recipientName: String? = null,
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?, List<Uri>, Int) -> Unit)? = null
) {
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
@@ -287,7 +299,11 @@ fun MediaPickerBottomSheet(
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() }
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
val navigationBarInsetPx = if (hasNativeNavigationBar) {
WindowInsets.navigationBars.getBottom(density).toFloat()
} else {
0f
}
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
// 🔄 Высоты в пикселях для точного контроля
@@ -376,6 +392,9 @@ fun MediaPickerBottomSheet(
// Запускаем анимацию когда showSheet меняется
LaunchedEffect(showSheet) {
if (showSheet && editingItem == null) {
// Ensure IME from chat is closed before picker opens.
hideKeyboard()
delay(16)
shouldShow = true
isClosing = false
showAlbumMenu = false
@@ -554,12 +573,21 @@ fun MediaPickerBottomSheet(
val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose {
window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
}
} else {
onDispose { }
@@ -567,12 +595,21 @@ fun MediaPickerBottomSheet(
}
// Reactive updates — single snapshotFlow drives ALL system bar colors
LaunchedEffect(shouldShow) {
if (!shouldShow) return@LaunchedEffect
LaunchedEffect(shouldShow, editingItem) {
if (!shouldShow || editingItem != null) return@LaunchedEffect
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) }
.collect { (alpha, fullScreen, dark) ->
snapshotFlow {
PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) {
// Full screen: status bar = picker background, seamless
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
@@ -584,16 +621,33 @@ fun MediaPickerBottomSheet(
)
insetsController?.isAppearanceLightStatusBars = false
}
// Navigation bar always follows scrim
window.navigationBarColor = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
)
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
}
}
// Используем Popup для показа поверх клавиатуры
if (shouldShow) {
if (shouldShow && editingItem == null) {
// BackHandler для закрытия по back
BackHandler {
if (isExpanded) {
@@ -627,7 +681,8 @@ fun MediaPickerBottomSheet(
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
val appliedKeyboardInsetPx =
if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f
val navBarDp = with(density) { navigationBarInsetPx.toDp() }
val navInsetPxForSheet = if (appliedKeyboardInsetPx > 0f) 0f else navigationBarInsetPx
val navInsetDpForSheet = with(density) { navInsetPxForSheet.toDp() }
// Полноэкранный контейнер с мягким затемнением
// background BEFORE padding — scrim covers area behind keyboard too
@@ -638,14 +693,14 @@ fun MediaPickerBottomSheet(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { requestClose() }
.padding(bottom = navBarDp),
) { requestClose() },
contentAlignment = Alignment.BottomCenter
) {
// Subtract keyboard from sheet height so it fits in the resized viewport.
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
val visibleSheetHeightPx =
(sheetHeightPx.value - appliedKeyboardInsetPx).coerceAtLeast(minHeightPx)
(sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet)
.coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress =
@@ -986,8 +1041,19 @@ fun MediaPickerBottomSheet(
},
onItemClick = { item, position ->
if (!item.isVideo) {
thumbnailPosition = position
editingItem = item
hideKeyboard()
if (onPhotoPreviewRequested != null) {
val photoItems = visibleMediaItems.filter { !it.isVideo }
val photoUris = photoItems.map { it.uri }
val currentIndex =
photoItems.indexOfFirst { it.id == item.id }
.takeIf { it >= 0 }
?: photoUris.indexOf(item.uri).coerceAtLeast(0)
onPhotoPreviewRequested(item.uri, position, photoUris, currentIndex)
} else {
thumbnailPosition = position
editingItem = item
}
} else {
// Videos don't have photo editor in this flow.
toggleSelection(item.id)
@@ -1145,6 +1211,9 @@ fun MediaPickerBottomSheet(
}
}
}
if (navInsetDpForSheet > 0.dp) {
Spacer(modifier = Modifier.height(navInsetDpForSheet))
}
} // end Column
// ═══════════════════════════════════════════════════════
@@ -1163,7 +1232,7 @@ fun MediaPickerBottomSheet(
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 14.dp, bottom = 8.dp)
.padding(end = 14.dp, bottom = 8.dp + navInsetDpForSheet)
.graphicsLayer {
scaleX = sendScale
scaleY = sendScale
@@ -1279,48 +1348,16 @@ fun MediaPickerBottomSheet(
)
}
// Image Editor FULLSCREEN overlay для фото из галереи
// ImageEditorScreen wraps itself in a Dialog internally — no external wrapper needed
// Fullscreen preview для выбранной фото из галереи (чистый экран без тулбаров).
editingItem?.let { item ->
ImageEditorScreen(
SimpleFullscreenPhotoViewer(
imageUri = item.uri,
sourceThumbnail = thumbnailPosition,
onDismiss = {
editingItem = null
thumbnailPosition = null
shouldShow = true
},
onSave = { editedUri ->
editingItem = null
thumbnailPosition = null
if (onMediaSelectedWithCaption == null) {
previewPhotoUri = editedUri
} else {
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(mediaItem), "")
onDismiss()
}
},
onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption ->
editingItem = null
thumbnailPosition = null
val mediaItem = MediaItem(
id = System.currentTimeMillis(),
uri = editedUri,
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelectedWithCaption(mediaItem, caption)
onDismiss()
} else null,
isDarkTheme = isDarkTheme,
showCaptionInput = onMediaSelectedWithCaption != null,
recipientName = recipientName,
thumbnailPosition = thumbnailPosition
}
)
}
@@ -2208,6 +2245,9 @@ fun PhotoPreviewWithCaptionScreen(
val backgroundColor = if (isDarkTheme) Color.Black else Color.White
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val view = LocalView.current
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
@@ -2292,7 +2332,8 @@ fun PhotoPreviewWithCaptionScreen(
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден
val shouldUseImePadding = !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding =
hasNativeNavigationBar && !isKeyboardVisible && !coordinator.isEmojiBoxVisible
// Логируем состояние при каждой рекомпозиции
Surface(

View File

@@ -36,6 +36,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import coil.compose.AsyncImage
@@ -88,6 +90,7 @@ fun MessageInputBar(
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false,
onCloseReply: () -> Unit = {},
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit = {},
chatTitle: String = "",
isBlocked: Boolean = false,
showEmojiPicker: Boolean = false,
@@ -104,7 +107,8 @@ fun MessageInputBar(
mentionCandidates: List<MentionCandidate> = emptyList(),
avatarRepository: AvatarRepository? = null,
inputFocusTrigger: Int = 0,
suppressKeyboard: Boolean = false
suppressKeyboard: Boolean = false,
hasNativeNavigationBar: Boolean = true
) {
val hasReply = replyMessages.isNotEmpty()
val liveReplyMessages =
@@ -217,6 +221,11 @@ fun MessageInputBar(
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
var isSending by remember { mutableStateOf(false) }
var showForwardCancelDialog by remember { mutableStateOf(false) }
var forwardCancelDialogCount by remember { mutableIntStateOf(0) }
var forwardCancelDialogMessages by remember {
mutableStateOf<List<ChatViewModel.ReplyMessage>>(emptyList())
}
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
@@ -367,7 +376,10 @@ fun MessageInputBar(
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp)
.padding(bottom = 20.dp)
.navigationBarsPadding(),
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -398,7 +410,10 @@ fun MessageInputBar(
)
)
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding =
hasNativeNavigationBar &&
!isKeyboardVisible &&
!coordinator.isEmojiBoxVisible
Column(
modifier = Modifier
@@ -654,7 +669,18 @@ fun MessageInputBar(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onCloseReply
onClick = {
if (panelIsForwardMode && panelReplyMessages.isNotEmpty()) {
val sourceMessages =
if (replyMessages.isNotEmpty()) replyMessages
else panelReplyMessages
forwardCancelDialogCount = sourceMessages.size
forwardCancelDialogMessages = sourceMessages
showForwardCancelDialog = true
} else {
onCloseReply()
}
}
),
contentAlignment = Alignment.Center
) {
@@ -758,6 +784,122 @@ fun MessageInputBar(
}
}
if (showForwardCancelDialog) {
val titleText =
if (forwardCancelDialogCount == 1) "1 message"
else "$forwardCancelDialogCount messages"
val bodyText =
"What would you like to do with $titleText from your chat with ${chatTitle.ifBlank { "this user" }}?"
val popupInteraction = remember { MutableInteractionSource() }
val cardInteraction = remember { MutableInteractionSource() }
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val bodyColor = if (isDarkTheme) Color(0xFFD1D1D6) else Color(0xFF3C3C43)
Popup(
alignment = Alignment.Center,
onDismissRequest = {
showForwardCancelDialog = false
forwardCancelDialogMessages = emptyList()
},
properties =
PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false
)
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.36f))
.clickable(
interactionSource = popupInteraction,
indication = null
) { showForwardCancelDialog = false },
contentAlignment = Alignment.Center
) {
Surface(
modifier =
Modifier
.fillMaxWidth(0.88f)
.widthIn(max = 360.dp)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = cardInteraction,
indication = null
) {},
color = cardColor,
shadowElevation = 18.dp
) {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(18.dp))
Text(
text = titleText,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White else Color.Black,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = bodyText,
fontSize = 16.sp,
lineHeight = 21.sp,
color = bodyColor,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(14.dp))
Divider(thickness = 0.5.dp, color = dividerColor)
TextButton(
onClick = {
showForwardCancelDialog = false
val panelMessages =
if (replyMessages.isNotEmpty()) {
replyMessages
} else if (forwardCancelDialogMessages.isNotEmpty()) {
forwardCancelDialogMessages
} else if (liveReplyMessages.isNotEmpty()) {
liveReplyMessages
} else {
animatedReplyMessages
}
onShowForwardOptions(panelMessages)
forwardCancelDialogMessages = emptyList()
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text(
text = "Show forwarding options",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = PrimaryBlue
)
}
TextButton(
onClick = {
showForwardCancelDialog = false
forwardCancelDialogMessages = emptyList()
onCloseReply()
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text(
text = "Cancel Forwarding",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF5D73)
)
}
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
// EMOJI PICKER
if (!isBlocked) {
AnimatedKeyboardTransition(

View File

@@ -45,8 +45,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.theme.*
@@ -124,8 +122,11 @@ fun OnboardingScreen(
// Animate navigation bar color starting at 80% of wave animation
val view = LocalView.current
val isGestureNavigation = remember(view.context) {
NavigationModeUtils.isGestureNavigation(view.context)
}
LaunchedEffect(isTransitioning, transitionProgress) {
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
if (!isGestureNavigation && isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window
// Map 0.8-1.0 to 0-1 for smooth interpolation
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
@@ -163,7 +164,10 @@ fun OnboardingScreen(
// Navigation bar: показываем только если есть нативные кнопки
NavigationModeUtils.applyNavigationBarVisibility(
insetsController, view.context, isDarkTheme
window = window,
insetsController = insetsController,
context = view.context,
isDarkTheme = isDarkTheme
)
}
}
@@ -173,10 +177,12 @@ fun OnboardingScreen(
if (!view.isInEditMode) {
val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
window.navigationBarColor =
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
NavigationModeUtils.applyNavigationBarVisibility(
window = window,
insetsController = insetsController,
context = view.context,
isDarkTheme = isDarkTheme
)
}
}

View File

@@ -1897,6 +1897,9 @@ private fun CollapsingOtherProfileHeader(
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
// ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -2151,7 +2154,7 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center
)
if (verified > 0 || isRosettaOfficial) {
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (verified > 0) verified else 1,

View File

@@ -1136,6 +1136,9 @@ private fun CollapsingProfileHeader(
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
val isFreddyOfficial =
name.equals("freddy", ignoreCase = true) ||
username.equals("freddy", ignoreCase = true)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion
@@ -1400,7 +1403,7 @@ private fun CollapsingProfileHeader(
modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center
)
if (verified > 0 || isRosettaOfficial) {
if (verified > 0 || isRosettaOfficial || isFreddyOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (verified > 0) verified else 2,

View File

@@ -68,15 +68,21 @@ fun RosettaAndroidTheme(
val view = LocalView.current
val context = LocalContext.current
if (!view.isInEditMode) {
SideEffect {
DisposableEffect(darkTheme, view, context) {
val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
// Make status bar transparent for wave animation overlay
window.statusBarColor = AndroidColor.TRANSPARENT
// Note: isAppearanceLightStatusBars is managed per-screen, not globally
// Navigation bar: показываем только если есть нативные кнопки
NavigationModeUtils.applyNavigationBarVisibility(insetsController, context, darkTheme)
NavigationModeUtils.applyNavigationBarVisibility(
window = window,
insetsController = insetsController,
context = context,
isDarkTheme = darkTheme
)
onDispose { }
}
}

View File

@@ -1,6 +1,10 @@
package com.rosetta.messenger.ui.utils
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.view.View
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -58,12 +62,42 @@ object NavigationModeUtils {
* Показывает navigation bar на всех устройствах.
*/
fun applyNavigationBarVisibility(
window: Window,
insetsController: WindowInsetsControllerCompat,
context: Context,
isDarkTheme: Boolean
) {
val gestureNavigation = isGestureNavigation(context)
val decorView = window.decorView
insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
if (gestureNavigation) {
// In gesture mode we keep a transparent nav bar and fixed white handle.
// This avoids the "jump" effect during theme switching.
window.navigationBarColor = Color.TRANSPARENT
insetsController.isAppearanceLightNavigationBars = false
val newFlags =
decorView.systemUiVisibility or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (newFlags != decorView.systemUiVisibility) {
decorView.systemUiVisibility = newFlags
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
} else {
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
val newFlags =
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
if (newFlags != decorView.systemUiVisibility) {
decorView.systemUiVisibility = newFlags
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
}
}
}