diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt index 9594afa..ffc3aa0 100644 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt +++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt @@ -30,10 +30,8 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() { val sender = pushData["sender_display_name"] ?.takeIf { it.isNotBlank() } ?: pushData["sender_username"] - ?: remoteMessage.data["senderUsername"] - ?: remoteMessage.data["senderDisplayName"] - val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" - val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" + ?: pushData["senderUsername"] + ?: pushData["senderDisplayName"] val messageType = pushData["type"] ?: "public_message" val isDirectMessage = messageType.equals("dm", ignoreCase = true) if (ApiClient.token.isNullOrBlank()) { @@ -49,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() { Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId") return@launch } - if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) { - NotificationHelper.showFallbackPushNotification( - context = applicationContext, - title = title, - body = body, - sender = sender, - messageId = fallbackMessageId, - isDirectMessage = false, - senderId = senderId, - ) - } if (isDirectMessage) { NotificationHelper.fetchAndNotify( applicationContext, @@ -68,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() { dmSenderName = sender, ) } else { - NotificationHelper.fetchAndNotify(applicationContext) + // Public: one debounced /messages/new → MessagingStyle. Never post a + // per-message fallback (that duplicated FCM tray entries with different labels). + NotificationHelper.schedulePublicFetchAndNotify(applicationContext) } } catch (e: Exception) { Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e) diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index 8498d21..d135620 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -19,15 +19,20 @@ import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.get import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import ru.fromchat.MainActivity import ru.fromchat.Logger import ru.fromchat.R import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.buildChatListPreview @@ -53,13 +58,20 @@ object NotificationHelper { private const val CHAT_TYPE_PUBLIC = "public" private const val CHAT_TYPE_DM = "dm" private const val CHANNEL_ID = "fromchat_messages" - private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary + private const val GROUP_PUBLIC = "ru.fromchat.notifications.public" + private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm." + private const val SUMMARY_NOTIFICATION_ID = 1000000 private const val PREF_SHOWN_KEY = "shown_message_ids" private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids" private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id" private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time" + private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L const val KEY_TEXT_REPLY = "key_text_reply" + private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val publicFetchMutex = Mutex() + private var publicFetchJob: Job? = null + private fun listPreviewStrings(context: Context): ChatListPreviewStrings { val emoji = context.getString(R.string.chat_preview_image_emoji) return ChatListPreviewStrings( @@ -72,6 +84,24 @@ object NotificationHelper { private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String = buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content + private fun publicConversationTitle(context: Context): String = + PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() } + ?: runCatching { + PublicChatProfileCache.hydrateFromDiskImmediate( + CacheContext.activeInstanceId.value.trim() + )?.title?.takeIf { it.isNotBlank() } + }.getOrNull() + ?: context.getString(R.string.public_chat) + + private fun senderDisplayLabel(message: Message, currentUserId: Int): String { + ProfileCache.get(message.user_id) + ?.visibleDisplayName(currentUserId) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + return message.username.trim().ifBlank { "FromChat" } + } + fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID private fun createMessageIntent( @@ -123,7 +153,6 @@ object NotificationHelper { }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE ) - fun createChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -141,6 +170,17 @@ object NotificationHelper { } } + /** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */ + fun schedulePublicFetchAndNotify(context: Context) { + publicFetchJob?.cancel() + publicFetchJob = helperScope.launch { + delay(PUBLIC_FETCH_DEBOUNCE_MS) + publicFetchMutex.withLock { + fetchAndNotify(context.applicationContext, includeDmMessages = false) + } + } + } + suspend fun fetchAndNotify( context: Context, includeDmMessages: Boolean = false, @@ -168,10 +208,7 @@ object NotificationHelper { Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)") if (messages.isNotEmpty()) { settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) - CoroutineScope(Dispatchers.Main).launch { - createChannel(context) - displayNotifications(context, messages) - } + displayNotifications(context, messages) } else { Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned") } @@ -194,13 +231,15 @@ object NotificationHelper { "fetchAndNotify retry: fetched ${retryMessages.size} public messages" ) if (retryMessages.isNotEmpty()) { - CoroutineScope(Dispatchers.Main).launch { - createChannel(context) - displayNotifications(context, retryMessages) - } + displayNotifications(context, retryMessages) } if (includeDmMessages) { - fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName) + fetchAndNotifyDirectMessages( + context, + settings.getInt("current_user_id", -1), + dmMessageId, + dmSenderName + ) } return } catch (_: Exception) { @@ -321,9 +360,9 @@ object NotificationHelper { showFallbackPushNotification( context = context, title = if (senderName.isNotBlank()) { - "Direct message from $senderName" + context.getString(R.string.notification_direct_message_from, senderName) } else { - "Direct message" + context.getString(R.string.notification_direct_message) }, body = notificationBody, sender = senderName, @@ -331,7 +370,7 @@ object NotificationHelper { allowWhenPublicChatVisible = true, isDirectMessage = true, targetDmUserId = dmConversationUserId, - conversationTitle = "Direct Messages" + conversationTitle = context.getString(R.string.notification_direct_messages_title) ) shownDm.add(shownDmKey) } @@ -352,10 +391,10 @@ object NotificationHelper { allowWhenPublicChatVisible: Boolean = false, isDirectMessage: Boolean = false, targetDmUserId: Int? = null, - conversationTitle: String = "Public Chat", + conversationTitle: String = context.getString(R.string.public_chat), senderId: Int? = null, ) { - CoroutineScope(Dispatchers.Main).launch { + helperScope.launch(Dispatchers.Main) { createChannel(context) val currentUserId = settings.getInt("current_user_id", -1) @@ -401,22 +440,37 @@ object NotificationHelper { } val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat" + val groupKey = if (isDirectMessage && targetDmUserId != null) { + GROUP_DM_PREFIX + targetDmUserId + } else { + GROUP_PUBLIC + } + val notificationId = if (isDirectMessage && targetDmUserId != null) { + SUMMARY_NOTIFICATION_ID + targetDmUserId + } else { + SUMMARY_NOTIFICATION_ID + } + cancelStaleSystemTrayDuplicates(context) notify( - SUMMARY_NOTIFICATION_ID, + notificationId, NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(NotificationSmallIcon.resId(context)) .setContentTitle(title) .setContentText(body) + .setGroup(groupKey) .setStyle( NotificationCompat.MessagingStyle( Person.Builder().setName("FromChat").build() - ).setConversationTitle(conversationTitle).addMessage( - NotificationCompat.MessagingStyle.Message( - body, - System.currentTimeMillis(), - Person.Builder().setName(senderName).build() - ) ) + .setConversationTitle(conversationTitle) + .setGroupConversation(true) + .addMessage( + NotificationCompat.MessagingStyle.Message( + body, + System.currentTimeMillis(), + Person.Builder().setName(senderName).build() + ) + ) ) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(Notification.CATEGORY_MESSAGE) @@ -424,7 +478,7 @@ object NotificationHelper { .addAction( NotificationCompat.Action.Builder( android.R.drawable.ic_menu_send, - "Reply", + context.getString(R.string.notification_reply), createReplyIntent( context = context, isDirectMessage = isDirectMessage, @@ -434,7 +488,7 @@ object NotificationHelper { ) .addRemoteInput( RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel("Reply to chat...") + .setLabel(context.getString(R.string.notification_reply_hint)) .build() ) .setAllowGeneratedReplies(true) @@ -458,117 +512,146 @@ object NotificationHelper { } } } - @OptIn(DelicateCoroutinesApi::class) + private fun displayNotifications(context: Context, messages: List) { Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages") - // Don't show notifications if user is currently viewing the public chat if (isPublicChatVisible) { Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat") return } - GlobalScope.launch { + helperScope.launch(Dispatchers.Main.immediate) { val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet() var newMessageCount = 0 val previewStrings = listPreviewStrings(context) + val conversationTitle = publicConversationTitle(context) + val avatar = PublicChatNotificationAvatar.create(conversationTitle) with(NotificationManagerCompat.from(context)) { if ( ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED + ) != PackageManager.PERMISSION_GRANTED ) { - // Find new messages that are not from the current user - val currentUserId = settings.getInt("current_user_id", -1) + Logger.w( + "NotificationHelper", + "displayNotifications: POST_NOTIFICATIONS permission missing, skipping" + ) + return@launch + } - if (currentUserId == -1) return@launch + val currentUserId = settings.getInt("current_user_id", -1) + if (currentUserId == -1) return@launch - val newMessages = messages.filter { msg -> - !shown.contains(msg.id.toString()) && // Not already shown - msg.user_id != currentUserId // Not from current user + val newMessages = messages + .filter { msg -> + !shown.contains(msg.id.toString()) && msg.user_id != currentUserId } - if (newMessages.isEmpty()) { - Logger.d( - "NotificationHelper", - "displayNotifications: no new messages after filters for user=$currentUserId" - ) - return@launch - } - newMessages.apply { forEach { shown.add(it.id.toString()) } } - - newMessageCount = newMessages.size + .sortedBy { it.id } + if (newMessages.isEmpty()) { Logger.d( "NotificationHelper", - "displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}" + "displayNotifications: no new messages after filters for user=$currentUserId" ) + return@launch + } + newMessages.forEach { shown.add(it.id.toString()) } - notify( - SUMMARY_NOTIFICATION_ID, - NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(NotificationSmallIcon.resId(context)) - .setStyle( - NotificationCompat.MessagingStyle( - Person.Builder().setName("FromChat").build() - ).setConversationTitle("Public Chat").let { style -> - for (msg in newMessages.takeLast(10)) { - val timestamp = try { - Instant.parse(msg.timestamp).toEpochMilliseconds() - } catch (_: Exception) { - System.currentTimeMillis() - } + newMessageCount = newMessages.size + Logger.d( + "NotificationHelper", + "displayNotifications: user=$currentUserId totalMessages=${messages.size} " + + "newMessages=$newMessageCount conversationTitle=$conversationTitle" + ) - style.addMessage( - NotificationCompat.MessagingStyle.Message( - notificationBodyForMessage(msg, previewStrings), - timestamp, - Person.Builder() - .setName(msg.username) - .build() - ) - ) - } + createChannel(context) + cancelStaleSystemTrayDuplicates(context) - style - } - ) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(Notification.CATEGORY_MESSAGE) - .setAutoCancel(true) + val messagingStyle = NotificationCompat.MessagingStyle( + Person.Builder().setName("FromChat").build() + ) + .setConversationTitle(conversationTitle) + .setGroupConversation(true) + + for (msg in newMessages.takeLast(10)) { + val timestamp = try { + Instant.parse(msg.timestamp).toEpochMilliseconds() + } catch (_: Exception) { + System.currentTimeMillis() + } + messagingStyle.addMessage( + NotificationCompat.MessagingStyle.Message( + notificationBodyForMessage(msg, previewStrings), + timestamp, + Person.Builder() + .setName(senderDisplayLabel(msg, currentUserId)) + .setKey(msg.user_id.toString()) + .build() + ) + ) + } + + notify( + SUMMARY_NOTIFICATION_ID, + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(NotificationSmallIcon.resId(context)) + .setLargeIcon(avatar) + .setContentTitle(conversationTitle) + .setStyle(messagingStyle) + .setGroup(GROUP_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(Notification.CATEGORY_MESSAGE) + .setAutoCancel(true) .addAction( NotificationCompat.Action.Builder( android.R.drawable.ic_menu_send, - "Reply", + context.getString(R.string.notification_reply), createReplyIntent( context = context, isDirectMessage = false, parentMessageId = newMessages.last().id ) ) - .addRemoteInput( - RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel("Reply to chat...") - .build() - ) - .setAllowGeneratedReplies(true) - .build() - ) - .setContentIntent(createMessageIntent(context, newMessages.last().id)) - .build() - ) - } else { - Logger.w( - "NotificationHelper", - "displayNotifications: POST_NOTIFICATIONS permission missing, skipping" - ) - } + .addRemoteInput( + RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(context.getString(R.string.notification_reply_hint)) + .build() + ) + .setAllowGeneratedReplies(true) + .build() + ) + .setContentIntent(createMessageIntent(context, newMessages.last().id)) + .setShortcutId(GROUP_PUBLIC) + .build() + ) } settings.putStringSet(PREF_SHOWN_KEY, shown) - Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}") + Logger.i( + "NotificationHelper", + "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}" + ) + } + } + + /** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */ + private fun cancelStaleSystemTrayDuplicates(context: Context) { + runCatching { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + // Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel. + manager.cancel(0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + manager.activeNotifications + .filter { status -> + status.notification.channelId == "fcm_fallback_notification_channel" || + status.id == 0 + } + .forEach { status -> + manager.cancel(status.tag, status.id) + } + } } } } - - diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt new file mode 100644 index 0000000..f7869b0 --- /dev/null +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt @@ -0,0 +1,78 @@ +package ru.fromchat.notifications + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.Typeface +import kotlin.math.abs + +/** + * Builds a circular initials avatar matching the in-app public-chat row (title initials + + * name-hash gradient), for use as a notification large icon. + */ +internal object PublicChatNotificationAvatar { + private const val SIZE_PX = 192 + + fun create(title: String): Bitmap { + val seed = title.ifBlank { "FromChat" } + val hash = seed.hashCode() + val r = abs(hash % 256) + val g = abs((hash / 256) % 256) + val b = abs((hash / 65536) % 256) + val colorStart = android.graphics.Color.rgb( + (r + 100).coerceIn(0, 255), + (g + 100).coerceIn(0, 255), + (b + 100).coerceIn(0, 255), + ) + val colorEnd = android.graphics.Color.rgb( + (r + 50).coerceIn(0, 255), + (g + 50).coerceIn(0, 255), + (b + 50).coerceIn(0, 255), + ) + + val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient( + 0f, + 0f, + SIZE_PX.toFloat(), + SIZE_PX.toFloat(), + colorStart, + colorEnd, + Shader.TileMode.CLAMP, + ) + } + val radius = SIZE_PX / 2f + canvas.drawCircle(radius, radius, radius, paint) + + val initials = initialsFrom(seed) + if (initials.isNotBlank()) { + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.WHITE + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + textSize = radius * 0.7f + } + val textY = radius - (textPaint.descent() + textPaint.ascent()) / 2f + canvas.drawText(initials, radius, textY, textPaint) + } + return bitmap + } + + private fun initialsFrom(displayName: String): String { + val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() } + return when { + words.isEmpty() -> "" + words.size == 1 -> { + val word = words[0] + if (word.length >= 2) word.take(2).uppercase() else (word + word).take(2).uppercase() + } + else -> words.take(2).joinToString("") { + it.firstOrNull()?.uppercaseChar()?.toString().orEmpty() + } + } + } +} diff --git a/app/android/src/main/res/values-ru/strings.xml b/app/android/src/main/res/values-ru/strings.xml index ec76ae2..16f78ad 100644 --- a/app/android/src/main/res/values-ru/strings.xml +++ b/app/android/src/main/res/values-ru/strings.xml @@ -1,5 +1,11 @@ + Общий чат Вложение 📷 %1$s 1 фото + Ответить + Ответ в чат… + Личное сообщение + Личное сообщение от %1$s + Личные сообщения diff --git a/app/android/src/main/res/values/strings.xml b/app/android/src/main/res/values/strings.xml index b304f47..0803b35 100644 --- a/app/android/src/main/res/values/strings.xml +++ b/app/android/src/main/res/values/strings.xml @@ -1,6 +1,12 @@ FromChat + Main chat Attachment 📷 %1$s 1 photo + Reply + Reply to chat… + Direct message + Direct message from %1$s + Direct Messages \ No newline at end of file