Some checks failed
Android Kernel Build / build (push) Failing after 4m6s
Звонки: - 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
594 lines
26 KiB
Kotlin
594 lines
26 KiB
Kotlin
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
|
||
}
|
||
}
|