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.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import android.util.Base64 import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.graphics.drawable.IconCompat 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.database.RosettaDatabase import com.rosetta.messenger.network.CallForegroundService import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallUiState import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.utils.AvatarFileManager 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 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() /** Уникальный 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 { 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() // Резолвим имя и аватарку по publicKey val resolvedName = resolveNameForKey(senderPublicKey) ?: senderName val avatarBitmap = loadAvatarBitmap(senderPublicKey) 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(resolvedName) .setContentText(messagePreview) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setAutoCancel(true) .setContentIntent(pendingIntent) .apply { if (avatarBitmap != null) { setLargeIcon(avatarBitmap) } } .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() // Резолвим имя и аватарку по publicKey val resolvedTitle = if (senderKey.isNotEmpty()) resolveNameForKey(senderKey) ?: title else title val avatarBitmap = if (senderKey.isNotEmpty()) loadAvatarBitmap(senderKey) else null // Используем 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(resolvedTitle) .setContentText(body) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setContentIntent(pendingIntent) .apply { if (avatarBitmap != null) { setLargeIcon(avatarBitmap) } } .build() val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(notifId, notification) } /** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */ private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) { pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title") wakeProtocolFromPush("call") if (!areNotificationsEnabled()) { pushCallLog("SKIP: notifications disabled") return } val normalizedDialog = dialogKey.trim() if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) { pushCallLog("SKIP: dialog muted") return } val currentPhase = CallManager.state.value.phase if (currentPhase != CallPhase.IDLE) { pushCallLog("SKIP: phase=$currentPhase (not 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) { pushCallLog("SKIP: dedup blocked (delta=${now - lastTs}ms)") return } lastNotifTimestamps[dedupKey] = now val resolvedName = resolveNameForKey(normalizedDialog) ?: title pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush") // Сразу ставим CallManager в INCOMING — не ждём WebSocket CallManager.setIncomingFromPush(normalizedDialog, resolvedName) pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}") // Пробуем запустить IncomingCallActivity напрямую из FCM // На Android 10+ может быть заблокировано — тогда fullScreenIntent на нотификации сработает try { val activityIntent = android.content.Intent( applicationContext, com.rosetta.messenger.IncomingCallActivity::class.java ).apply { flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP } applicationContext.startActivity(activityIntent) pushCallLog("IncomingCallActivity started from FCM OK") } catch (e: Throwable) { pushCallLog("IncomingCallActivity start from FCM FAILED: ${e.message} — relying on fullScreenIntent") } } private fun pushCallLog(msg: String) { Log.d(TAG, msg) try { val dir = java.io.File(applicationContext.filesDir, "crash_reports") if (!dir.exists()) dir.mkdirs() val f = java.io.File(dir, "call_notification_log.txt") val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) f.appendText("$ts [FCM] $msg\n") } catch (_: Throwable) {} } /** Пробуждаем сетевой слой по 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) } } /** Сохранить 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, vararg keys: String): String? { for (key in keys) { val value = data[key]?.trim() if (!value.isNullOrEmpty()) return value } return null } private fun isAvatarInNotificationsEnabled(): Boolean { return runCatching { runBlocking(Dispatchers.IO) { PreferencesManager(applicationContext).notificationAvatarEnabled.first() } }.getOrDefault(true) } /** Проверка: замьючен ли диалог для текущего аккаунта */ 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) } /** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */ private fun resolveNameForKey(publicKey: String?): String? { if (publicKey.isNullOrBlank()) return null // 1. In-memory cache ProtocolManager.getCachedUserName(publicKey)?.let { return it } // 2. DB dialogs table return runCatching { val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() if (account.isBlank()) return null val db = RosettaDatabase.getDatabase(applicationContext) val dialog = runBlocking(Dispatchers.IO) { db.dialogDao().getDialog(account, publicKey) } dialog?.opponentTitle?.takeIf { it.isNotBlank() } ?: dialog?.opponentUsername?.takeIf { it.isNotBlank() } }.getOrNull() } /** Получить аватарку как круглый Bitmap для notification по publicKey */ private fun loadAvatarBitmap(publicKey: String?): Bitmap? { if (publicKey.isNullOrBlank()) return null // Проверяем настройку if (!isAvatarInNotificationsEnabled()) return null return runCatching { val db = RosettaDatabase.getDatabase(applicationContext) val entity = runBlocking(Dispatchers.IO) { db.avatarDao().getLatestAvatarByKeys(listOf(publicKey)) } ?: return null val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar) ?: return null val bytes = Base64.decode(base64, Base64.DEFAULT) val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null // Делаем круглый bitmap для notification toCircleBitmap(original) }.getOrNull() } private fun toCircleBitmap(source: Bitmap): Bitmap { val size = minOf(source.width, source.height) val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(output) val paint = android.graphics.Paint().apply { isAntiAlias = true } val rect = android.graphics.Rect(0, 0, size, size) canvas.drawARGB(0, 0, 0, 0) canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN) canvas.drawBitmap(source, rect, rect, paint) return output } }