feat: update notification handling and improve media selection with captions

This commit is contained in:
2026-02-16 03:42:43 +05:00
parent 0a461c2cf2
commit c92cb0779a
8 changed files with 163 additions and 56 deletions

View File

@@ -57,8 +57,8 @@ android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

View File

@@ -95,10 +95,10 @@
# ============================================================
# R8 VerifyError fix: prevent R8 from generating invalid
# bytecode (instance-of on unexpected class Integer) in
# app UI lambdas with primitive boxing/unboxing
# bytecode in app UI lambdas (Compose/coroutine) that interact
# with third-party libraries involving primitive boxing/unboxing
# ============================================================
-keep,allowobfuscation class com.rosetta.messenger.ui.** { *; }
-keep class com.rosetta.messenger.ui.** { *; }
# ============================================================
# Data Models
@@ -174,3 +174,11 @@
# ============================================================
-keep class coil.** { *; }
-dontwarn coil.**
# ============================================================
# PhotoEditor (com.burhanrashid52:photoeditor)
# Prevents R8 from generating invalid bytecode (VerifyError:
# instance-of on unexpected class Integer) in PhotoEditorImpl
# ============================================================
-keep class ja.burhanrashid52.photoeditor.** { *; }
-dontwarn ja.burhanrashid52.photoeditor.**

View File

@@ -34,10 +34,21 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages"
private const val NOTIFICATION_ID = 1
// 🔥 Флаг - приложение в foreground (видимо пользователю)
@Volatile var isAppInForeground = false
/** Уникальный notification ID для каждого чата (по publicKey) */
fun getNotificationIdForChat(senderPublicKey: String): Int {
return senderPublicKey.hashCode() and 0x7FFFFFFF // positive int
}
/** Убрать уведомление конкретного чата из шторки */
fun cancelNotificationForChat(context: Context, senderPublicKey: String) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(getNotificationIdForChat(senderPublicKey))
}
}
/** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */
@@ -97,6 +108,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel()
val notifId = getNotificationIdForChat(senderPublicKey ?: "")
// Intent для открытия чата
val intent =
Intent(this, MainActivity::class.java).apply {
@@ -107,7 +120,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val pendingIntent =
PendingIntent.getActivity(
this,
0,
notifId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -125,7 +138,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
notificationManager.notify(notifId, notification)
}
/** Показать простое уведомление */
@@ -162,7 +175,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
}
/** Создать notification channel для Android 8+ */

View File

@@ -459,6 +459,9 @@ fun ChatDetailScreen(
Lifecycle.Event.ON_RESUME -> {
isScreenActive = true
viewModel.setDialogActive(true)
// 🔥 Убираем уведомление этого чата из шторки
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
}
Lifecycle.Event.ON_PAUSE -> {
isScreenActive = false
@@ -482,6 +485,9 @@ fun ChatDetailScreen(
LaunchedEffect(user.publicKey, forwardTrigger) {
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
viewModel.openDialog(user.publicKey, user.title, user.username)
// 🔥 Убираем уведомление этого чата из шторки при заходе
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
// Подписываемся на онлайн статус собеседника
if (!isSavedMessages) {
viewModel.subscribeToOnlineStatus()
@@ -2065,13 +2071,15 @@ fun ChatDetailScreen(
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia ->
// 📸 Открываем edit screen для выбранных изображений
onMediaSelected = { selectedMedia, caption ->
// 📸 Отправляем фото напрямую с caption
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
pendingGalleryImages = imageUris
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->

View File

@@ -321,13 +321,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Добавляем все сразу
kotlinx.coroutines.withContext(Dispatchers.Main.immediate) {
val currentList = _messages.value
val newList = (currentList + newMessages).sortedBy { it.timestamp }
// 🔍 DEBUG: Проверка на дублирующиеся ID
val allIds = newList.map { it.id }
val duplicates = allIds.groupBy { it }.filter { it.value.size > 1 }.keys
if (duplicates.isNotEmpty()) {}
val newList = (currentList + newMessages).distinctBy { it.id }.sortedBy { it.timestamp }
_messages.value = newList
}
@@ -800,7 +794,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
// Сортируем по timestamp чтобы новые были в конце
val updatedMessages = (currentMessages + newMessages).sortedBy { it.timestamp }
val updatedMessages = (currentMessages + newMessages).distinctBy { it.id }.sortedBy { it.timestamp }
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
// Объединяем существующий кэш с новыми сообщениями
@@ -912,7 +906,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Добавляем в начало списка (старые сообщения)
withContext(Dispatchers.Main) {
_messages.value = newMessages + _messages.value
_messages.value = (newMessages + _messages.value).distinctBy { it.id }
}
}

View File

@@ -430,7 +430,7 @@ fun ImageCollage(
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
attachments.forEachIndexed { index, attachment ->
Box(modifier = Modifier.weight(1f)) {
Box(modifier = Modifier.weight(1f).aspectRatio(1f)) {
ImageAttachment(
attachment = attachment,
chachaKey = chachaKey,
@@ -441,7 +441,7 @@ fun ImageCollage(
timestamp = timestamp,
messageStatus = messageStatus,
showTimeOverlay = showOverlayOnLast && index == count - 1,
aspectRatio = 1f,
fillMaxSize = true,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick

View File

@@ -1634,7 +1634,14 @@ fun MultiImageEditorScreen(
setPadding(0, 0, 0, 0)
setBackgroundColor(android.graphics.Color.BLACK)
// Загружаем изображение
// Инициализация PhotoEditor синхронно в factory (уже на main thread)
val editor = PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
photoEditors[page] = editor
// Загружаем изображение асинхронно
scope.launch(Dispatchers.IO) {
try {
val bitmap = loadBitmapRespectExif(ctx, imagesWithCaptions[page].uri)
@@ -1648,12 +1655,6 @@ fun MultiImageEditorScreen(
adjustViewBounds = true
setPadding(0, 0, 0, 0)
}
val editor = PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
photoEditors[page] = editor
}
} catch (e: Exception) {
// Handle error
@@ -1737,7 +1738,7 @@ fun MultiImageEditorScreen(
}
}
// Bottom section - без imePadding, фото не сжимается
// Bottom section
Column(
modifier = Modifier
.fillMaxWidth()
@@ -1862,7 +1863,6 @@ fun MultiImageEditorScreen(
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving && !isClosing) {
// 🚀 Сохраняем копию данных перед анимацией
val imagesToSend = imagesWithCaptions.toList()
scope.launch {
@@ -1886,11 +1886,8 @@ fun MultiImageEditorScreen(
}
}
// Вызываем callback (он запустит sendImageGroup с optimistic UI)
onSendAll(savedImages)
isSaving = false
// Закрываем после завершения сохранения/отправки
animatedDismiss()
}
},
@@ -1900,8 +1897,7 @@ fun MultiImageEditorScreen(
TablerIcons.ArrowUp,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier
.size(22.dp)
modifier = Modifier.size(22.dp)
)
}
}

View File

@@ -106,7 +106,7 @@ fun MediaPickerBottomSheet(
isVisible: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
onMediaSelected: (List<MediaItem>) -> Unit,
onMediaSelected: (List<MediaItem>, String) -> Unit,
onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null,
onOpenCamera: () -> Unit = {},
onOpenFilePicker: () -> Unit = {},
@@ -151,6 +151,10 @@ fun MediaPickerBottomSheet(
// Caption для фото
var photoCaption by remember { mutableStateOf("") }
// Caption для группы фото (внизу picker'а)
var pickerCaption by remember { mutableStateOf("") }
var captionEditTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
@@ -426,7 +430,7 @@ fun MediaPickerBottomSheet(
alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран
onDismissRequest = animatedClose,
properties = PopupProperties(
focusable = false,
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = false
)
@@ -444,6 +448,7 @@ fun MediaPickerBottomSheet(
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.background(Color.Black.copy(alpha = scrimAlpha))
.clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -523,7 +528,7 @@ fun MediaPickerBottomSheet(
onDismiss = animatedClose,
onSend = {
val selected = mediaItems.filter { it.id in selectedItems }
onMediaSelected(selected)
onMediaSelected(selected, pickerCaption.trim())
animatedClose()
},
isDarkTheme = isDarkTheme,
@@ -648,6 +653,89 @@ fun MediaPickerBottomSheet(
modifier = Modifier.weight(1f)
)
}
// Caption bar (видна когда есть выбранные фото)
AnimatedVisibility(
visible = selectedItems.isNotEmpty(),
enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom),
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Caption input (pill shape)
Row(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFEFEFF0))
.clickable {
// Программно открываем клавиатуру при клике на pill
captionEditTextView?.let { view ->
view.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
}
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Emoji button
Icon(
painter = TelegramIcons.Smile,
contentDescription = "Emoji",
tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray,
modifier = Modifier.size(22.dp)
)
// Caption text field
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 22.dp, max = 100.dp)
) {
AppleEmojiTextField(
value = pickerCaption,
onValueChange = { pickerCaption = it },
textColor = if (isDarkTheme) Color.White else Color.Black,
textSize = 16f,
hint = "Add a caption...",
hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.35f) else Color.Gray.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth(),
requestFocus = false,
onViewCreated = { captionEditTextView = it },
onFocusChanged = { }
)
}
}
// Send button
Box(
modifier = Modifier
.size(42.dp)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable {
val selected = mediaItems.filter { it.id in selectedItems }
onMediaSelected(selected, pickerCaption.trim())
animatedClose()
},
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Send,
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}
}
@@ -730,7 +818,7 @@ fun MediaPickerBottomSheet(
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(mediaItem))
onMediaSelected(listOf(mediaItem), "")
onDismiss()
}
},
@@ -776,7 +864,7 @@ fun MediaPickerBottomSheet(
mimeType = "image/png",
dateModified = System.currentTimeMillis()
)
onMediaSelected(listOf(mediaItem))
onMediaSelected(listOf(mediaItem), "")
onDismiss()
}
},
@@ -811,7 +899,7 @@ fun MediaPickerBottomSheet(
dateModified = System.currentTimeMillis()
)
// Отправляем фото (caption можно передать отдельно если нужно)
onMediaSelected(listOf(item))
onMediaSelected(listOf(item), "")
previewPhotoUri = null
photoCaption = ""
onDismiss()