Релиз 1.4.3: полноэкранные входящие звонки, аватарки в уведомлениях, фиксы
Some checks failed
Android Kernel Build / build (push) Failing after 4m6s
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
This commit is contained in:
@@ -5,19 +5,26 @@ 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
|
||||
@@ -41,8 +48,6 @@ 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"
|
||||
@@ -282,6 +287,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
createNotificationChannel()
|
||||
|
||||
// Резолвим имя и аватарку по publicKey
|
||||
val resolvedName = resolveNameForKey(senderPublicKey) ?: senderName
|
||||
val avatarBitmap = loadAvatarBitmap(senderPublicKey)
|
||||
|
||||
val notifId = getNotificationIdForChat(senderPublicKey ?: "")
|
||||
|
||||
// Intent для открытия чата
|
||||
@@ -302,12 +311,17 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(senderName)
|
||||
.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 =
|
||||
@@ -336,6 +350,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
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)
|
||||
@@ -359,11 +377,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentTitle(resolvedTitle)
|
||||
.setContentText(body)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.apply {
|
||||
if (avatarBitmap != null) {
|
||||
setLargeIcon(avatarBitmap)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
val notificationManager =
|
||||
@@ -371,61 +394,69 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
notificationManager.notify(notifId, notification)
|
||||
}
|
||||
|
||||
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */
|
||||
/** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */
|
||||
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) {
|
||||
pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title")
|
||||
wakeProtocolFromPush("call")
|
||||
|
||||
if (isAppInForeground || !areNotificationsEnabled()) return
|
||||
if (!areNotificationsEnabled()) {
|
||||
pushCallLog("SKIP: notifications disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val normalizedDialog = dialogKey.trim()
|
||||
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return
|
||||
if (CallManager.state.value.phase != CallPhase.IDLE) return
|
||||
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) return
|
||||
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
|
||||
pushCallLog("SKIP: dedup blocked (delta=${now - lastTs}ms)")
|
||||
return
|
||||
}
|
||||
lastNotifTimestamps[dedupKey] = now
|
||||
|
||||
createCallNotificationChannel()
|
||||
val resolvedName = resolveNameForKey(normalizedDialog) ?: title
|
||||
pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush")
|
||||
|
||||
val notifId =
|
||||
if (normalizedDialog.isNotEmpty()) {
|
||||
getNotificationIdForChat(normalizedDialog)
|
||||
} else {
|
||||
("call:$title:$body").hashCode() and 0x7FFFFFFF
|
||||
}
|
||||
// Сразу ставим CallManager в INCOMING — не ждём WebSocket
|
||||
CallManager.setIncomingFromPush(normalizedDialog, resolvedName)
|
||||
pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}")
|
||||
|
||||
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
|
||||
)
|
||||
// Пробуем запустить 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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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, чтобы быстрее получить сигнальные пакеты */
|
||||
@@ -463,26 +494,6 @@ 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)
|
||||
@@ -505,6 +516,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
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
|
||||
@@ -519,4 +538,56 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
}.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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user