Files
mobile-android/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt
k1ngsterr1 698a67a923 Фикс: пуш-уведомления не читались при открытии приложения
- cancelAll() в onResume — сбрасывает все уведомления и бейдж при открытии приложения
- showSimpleNotification принимает senderPublicKey и использует тот же ID что cancelNotificationForChat — теперь fallback-уведомления тоже удаляются при входе в чат

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:10:28 +07:00

326 lines
14 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.rosetta.messenger.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.util.Locale
/**
* Firebase Cloud Messaging Service для обработки push-уведомлений
*
* Обрабатывает:
* - Получение нового FCM токена
* - Получение push-уведомлений о новых сообщениях
* - Отображение уведомлений
*/
class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object {
private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages"
// 🔥 Флаг - приложение в foreground (видимо пользователю)
@Volatile var isAppInForeground = false
// Dedup: suppress duplicate pushes from the server within a short window.
// Key = senderPublicKey (or "__simple__"), Value = timestamp of last shown notification.
private const val DEDUP_WINDOW_MS = 10_000L
private val lastNotifTimestamps = java.util.concurrent.ConcurrentHashMap<String, Long>()
/** Уникальный 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
val normalizedKey = senderPublicKey.trim()
if (normalizedKey.isNotEmpty()) {
notificationManager.cancel(getNotificationIdForChat(normalizedKey))
}
// Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat(""))
}
}
/** Вызывается когда получен новый FCM токен Отправляем его на сервер через протокол */
override fun onNewToken(token: String) {
super.onNewToken(token)
// Сохраняем токен локально
saveFcmToken(token)
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
// Используем единую точку отправки в ProtocolManager (с дедупликацией).
if (ProtocolManager.isAuthenticated()) {
runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) }
}
}
/** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
var handledMessageData = false
// Обрабатываем data payload
if (remoteMessage.data.isNotEmpty()) {
val data = remoteMessage.data
val type =
firstNonBlank(data, "type", "event", "action")
?.lowercase(Locale.ROOT)
.orEmpty()
val senderPublicKey =
firstNonBlank(
data,
"sender_public_key",
"from_public_key",
"fromPublicKey",
"public_key",
"publicKey"
)
val senderName =
firstNonBlank(data, "sender_name", "from_title", "sender", "title", "name")
?: senderPublicKey?.take(10)
?: "Rosetta"
val messagePreview =
firstNonBlank(data, "message_preview", "message", "text", "body")
?: "New message"
val isReadEvent = type == "message_read" || type == "read"
val isMessageEvent =
type == "new_message" ||
type == "message" ||
type == "newmessage" ||
type == "msg_new"
when {
isMessageEvent -> {
showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
}
isReadEvent -> {
handledMessageData = true
}
// Fallback for servers sending data-only payload without explicit "type".
senderPublicKey != null || data.containsKey("message_preview") || data.containsKey("message") || data.containsKey("text") -> {
showMessageNotification(senderPublicKey, senderName, messagePreview)
handledMessageData = true
}
}
val looksLikeMessagePayload =
type.contains("message") ||
data.keys.any { key ->
val lower = key.lowercase(Locale.ROOT)
lower.contains("message") ||
lower.contains("text") ||
lower.contains("body")
}
if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview, senderPublicKey)
handledMessageData = true
}
}
// Обрабатываем notification payload (если есть).
// Для new_message используем data-ветку выше, чтобы не показывать дубликаты
// с неуправляемым notification id.
remoteMessage.notification?.let {
if (!handledMessageData) {
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message")
}
}
}
/** Показать уведомление о новом сообщении */
private fun showMessageNotification(
senderPublicKey: String?,
senderName: String,
messagePreview: String
) {
// 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground || !areNotificationsEnabled()) {
return
}
// Dedup: suppress duplicate pushes from the same sender within DEDUP_WINDOW_MS
val dedupKey = senderPublicKey?.trim().orEmpty().ifEmpty { "__no_sender__" }
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
return // duplicate push — skip
}
lastNotifTimestamps[dedupKey] = now
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
}
createNotificationChannel()
val notifId = getNotificationIdForChat(senderPublicKey ?: "")
// Intent для открытия чата
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("open_chat", senderPublicKey)
}
val pendingIntent =
PendingIntent.getActivity(
this,
notifId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
}
/** Показать простое уведомление */
private fun showSimpleNotification(title: String, body: String, senderPublicKey: String? = null) {
// 🔥 Не показываем уведомление если приложение открыто
if (isAppInForeground || !areNotificationsEnabled()) {
return
}
// Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS
val dedupKey = senderPublicKey?.trim()?.ifEmpty { null } ?: "__simple__"
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
return // duplicate push — skip
}
lastNotifTimestamps[dedupKey] = now
createNotificationChannel()
// Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = if (!senderPublicKey.isNullOrBlank()) {
getNotificationIdForChat(senderPublicKey.trim())
} else {
(title + body).hashCode() and 0x7FFFFFFF
}
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent =
PendingIntent.getActivity(
this,
notifId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
}
/** Создать notification channel для Android 8+ */
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = "Notifications for new messages"
enableVibration(true)
}
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply()
}
private fun areNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationsEnabled.first()
}
}.getOrDefault(true)
}
private fun firstNonBlank(data: Map<String, String>, vararg keys: String): String? {
for (key in keys) {
val value = data[key]?.trim()
if (!value.isNullOrEmpty()) return value
}
return null
}
/** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false
return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).isChatMuted(currentAccount, senderPublicKey)
}
}.getOrDefault(false)
}
}