Files
mobile-android/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt
2026-03-30 22:46:59 +05:00

523 lines
23 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.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
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"
private const val CALL_CHANNEL_ID = "rosetta_calls_push"
private const val CALL_CHANNEL_NAME = "Calls"
private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message"
private const val PUSH_TYPE_GROUP_MESSAGE = "group_message"
private const val PUSH_TYPE_CALL = "call"
private const val PUSH_TYPE_READ = "read"
// 🔥 Флаг - приложение в 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 variants = buildDialogKeyVariants(senderPublicKey)
for (key in variants) {
notificationManager.cancel(getNotificationIdForChat(key))
}
// Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat(""))
}
private fun buildDialogKeyVariants(rawKey: String): Set<String> {
val trimmed = rawKey.trim()
if (trimmed.isBlank()) return emptySet()
val variants = linkedSetOf(trimmed)
val lower = trimmed.lowercase(Locale.ROOT)
when {
lower.startsWith("#group:") -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isNotBlank()) {
variants.add("group:$groupId")
variants.add(groupId)
}
}
lower.startsWith("group:") -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isNotBlank()) {
variants.add("#group:$groupId")
variants.add(groupId)
}
}
else -> {
variants.add("#group:$trimmed")
variants.add("group:$trimmed")
}
}
return variants
}
}
/** Вызывается когда получен новый 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 handledByData = false
val data = remoteMessage.data
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
// Обрабатываем data payload (новый server формат + legacy fallback)
if (data.isNotEmpty()) {
val type =
firstNonBlank(data, "type", "event", "action")
?.lowercase(Locale.ROOT)
.orEmpty()
val dialogKey =
firstNonBlank(
data,
"dialog",
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
val senderPublicKey =
firstNonBlank(
data,
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
val senderName =
firstNonBlank(
data,
"sender_name",
"sender_title",
"from_title",
"sender",
"title",
"name"
)
?: notificationTitle.takeIf { it.isNotBlank() }
?: dialogKey?.take(10)
?: senderPublicKey?.take(10)
?: "Rosetta"
val messagePreview =
firstNonBlank(data, "message_preview", "message", "text", "body")
?: notificationBody.takeIf { it.isNotBlank() }
?: when (type) {
PUSH_TYPE_GROUP_MESSAGE -> "New group message"
PUSH_TYPE_CALL -> "Incoming call"
else -> "New message"
}
val isReadEvent = type == "message_read" || type == PUSH_TYPE_READ
val isMessageEvent =
type == "new_message" ||
type == "message" ||
type == "newmessage" ||
type == "msg_new" ||
type == PUSH_TYPE_PERSONAL_MESSAGE ||
type == PUSH_TYPE_GROUP_MESSAGE
when {
isReadEvent -> {
if (!dialogKey.isNullOrBlank()) {
cancelNotificationForChat(applicationContext, dialogKey)
}
handledByData = true
}
type == PUSH_TYPE_CALL -> {
handleIncomingCallPush(
dialogKey = dialogKey ?: senderPublicKey.orEmpty(),
title = senderName,
body = messagePreview
)
handledByData = true
}
isMessageEvent -> {
showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview)
handledByData = true
}
// Fallback for servers sending data-only payload without explicit "type".
dialogKey != null || senderPublicKey != null ||
data.containsKey("message_preview") ||
data.containsKey("message") ||
data.containsKey("text") -> {
showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview)
handledByData = true
}
}
if (!handledByData) {
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 (looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview, dialogKey ?: senderPublicKey)
handledByData = true
}
}
}
// Обрабатываем notification payload (если data-ветка не сработала).
remoteMessage.notification?.let {
if (!handledByData) {
val senderPublicKey =
firstNonBlank(
data,
"dialog",
"sender_public_key",
"from_public_key",
"fromPublicKey",
"from",
"public_key",
"publicKey"
)
showSimpleNotification(
it.title ?: "Rosetta",
it.body ?: "New message",
senderPublicKey
)
}
}
}
/** Показать уведомление о новом сообщении */
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
}
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
}
// Dedup: suppress duplicate pushes within DEDUP_WINDOW_MS
val dedupKey = senderKey.ifEmpty { "__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 (senderKey.isNotEmpty()) {
getNotificationIdForChat(senderKey)
} 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)
}
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) {
wakeProtocolFromPush("call")
if (isAppInForeground || !areNotificationsEnabled()) return
val normalizedDialog = dialogKey.trim()
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return
if (CallManager.state.value.phase != CallPhase.IDLE) return
val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) return
lastNotifTimestamps[dedupKey] = now
createCallNotificationChannel()
val notifId =
if (normalizedDialog.isNotEmpty()) {
getNotificationIdForChat(normalizedDialog)
} else {
("call:$title:$body").hashCode() and 0x7FFFFFFF
}
val openIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("open_chat", normalizedDialog)
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
val pendingIntent =
PendingIntent.getActivity(
this,
notifId,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification =
NotificationCompat.Builder(this, CALL_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title.ifBlank { "Incoming call" })
.setContentText(body.ifBlank { "Incoming call" })
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
}
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
private fun wakeProtocolFromPush(reason: String) {
runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
ProtocolManager.initialize(applicationContext)
CallManager.initialize(applicationContext)
if (account.isNotBlank()) {
CallManager.bindAccount(account)
}
ProtocolManager.reconnectNowIfNeeded("push_$reason")
}.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
}
}
/** Создать 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)
}
}
/** Отдельный канал для входящих звонков */
private fun createCallNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
CALL_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = "Incoming call notifications"
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) {
val preferences = PreferencesManager(applicationContext)
buildDialogKeyVariants(senderPublicKey).any { key ->
preferences.isChatMuted(currentAccount, key)
}
}
}.getOrDefault(false)
}
}