Files
mobile-android/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt
k1ngsterr1 876c1ab4df
Some checks failed
Android Kernel Build / build (push) Failing after 4m6s
Релиз 1.4.3: полноэкранные входящие звонки, аватарки в уведомлениях, фиксы
Звонки:
- IncomingCallActivity — полноэкранный UI входящего звонка поверх lock screen
- fullScreenIntent на нотификации для Android 12+
- ForegroundService синхронизируется при смене фазы и имени
- Запрос fullScreenIntent permission на Android 14+
- dispose() PeerConnection при завершении звонка
- Защита от CREATE_ROOM без ключей (звонок на другом устройстве)
- Дедупликация push + WebSocket сигналов
- setIncomingFromPush — CallManager сразу в INCOMING по push
- Accept ждёт до 5 сек если WebSocket не доставил сигнал
- Decline работает во всех фазах (не только INCOMING)
- Баннер активного звонка внутри диалога

Уведомления:
- Аватарки и имена по publicKey в уведомлениях (message + call)
- Настройка "Avatars in Notifications" в разделе Notifications

UI:
- Ограничение fontScale до 1.3x (вёрстка не ломается на огромном тексте)
- Новые обои: Light 1-3 для светлой темы, убраны старые back_*
- ContentScale.Crop для превью обоев (без растяжения)

CI/CD:
- NDK/CMake в CI, local.properties, ANDROID_NDK_HOME
- Ограничение JVM heap для CI раннера

Диагностика:
- Логирование call notification flow в crash_reports (rosettadev1)
- FCM токен в crash_reports
2026-04-02 01:18:20 +05:00

594 lines
26 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.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<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()
// Резолвим имя и аватарку по 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<String, String>, 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
}
}