diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index f32ad54..3efd8b2 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -14,6 +14,9 @@ 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 @@ -38,6 +41,12 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { 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 @@ -114,17 +123,28 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { 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 + var handledByData = false + val data = remoteMessage.data + val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty() + val notificationBody = remoteMessage.notification?.body?.trim().orEmpty() - // Обрабатываем data payload - if (remoteMessage.data.isNotEmpty()) { - 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, @@ -146,57 +166,79 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { "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() } - ?: "New message" + ?: when (type) { + PUSH_TYPE_GROUP_MESSAGE -> "New group message" + PUSH_TYPE_CALL -> "Incoming call" + else -> "New message" + } - val isReadEvent = type == "message_read" || type == "read" + val isReadEvent = type == "message_read" || type == PUSH_TYPE_READ val isMessageEvent = type == "new_message" || type == "message" || type == "newmessage" || - type == "msg_new" + type == "msg_new" || + type == PUSH_TYPE_PERSONAL_MESSAGE || + type == PUSH_TYPE_GROUP_MESSAGE when { - isMessageEvent -> { - showMessageNotification(senderPublicKey, senderName, messagePreview) - handledMessageData = true - } isReadEvent -> { - handledMessageData = true + 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". - senderPublicKey != null || data.containsKey("message_preview") || data.containsKey("message") || data.containsKey("text") -> { - showMessageNotification(senderPublicKey, senderName, messagePreview) - handledMessageData = true + dialogKey != null || senderPublicKey != null || + data.containsKey("message_preview") || + data.containsKey("message") || + data.containsKey("text") -> { + showMessageNotification(dialogKey ?: senderPublicKey, senderName, messagePreview) + handledByData = 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 + 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 (если есть). - // Для new_message используем data-ветку выше, чтобы не показывать дубликаты - // с неуправляемым notification id. + // Обрабатываем notification payload (если data-ветка не сработала). remoteMessage.notification?.let { - if (!handledMessageData) { + if (!handledByData) { val senderPublicKey = firstNonBlank( - remoteMessage.data, + data, + "dialog", "sender_public_key", "from_public_key", "fromPublicKey", @@ -329,6 +371,78 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { 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) { @@ -349,6 +463,26 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { } } + /** Отдельный канал для входящих звонков */ + 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)