mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Improve push notifications
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -30,10 +30,8 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val sender = pushData["sender_display_name"]
|
val sender = pushData["sender_display_name"]
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?: pushData["sender_username"]
|
?: pushData["sender_username"]
|
||||||
?: remoteMessage.data["senderUsername"]
|
?: pushData["senderUsername"]
|
||||||
?: remoteMessage.data["senderDisplayName"]
|
?: pushData["senderDisplayName"]
|
||||||
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
|
|
||||||
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
|
|
||||||
val messageType = pushData["type"] ?: "public_message"
|
val messageType = pushData["type"] ?: "public_message"
|
||||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||||
if (ApiClient.token.isNullOrBlank()) {
|
if (ApiClient.token.isNullOrBlank()) {
|
||||||
@@ -49,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
|
|
||||||
NotificationHelper.showFallbackPushNotification(
|
|
||||||
context = applicationContext,
|
|
||||||
title = title,
|
|
||||||
body = body,
|
|
||||||
sender = sender,
|
|
||||||
messageId = fallbackMessageId,
|
|
||||||
isDirectMessage = false,
|
|
||||||
senderId = senderId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
NotificationHelper.fetchAndNotify(
|
NotificationHelper.fetchAndNotify(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@@ -68,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
dmSenderName = sender,
|
dmSenderName = sender,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
NotificationHelper.fetchAndNotify(applicationContext)
|
// Public: one debounced /messages/new → MessagingStyle. Never post a
|
||||||
|
// per-message fallback (that duplicated FCM tray entries with different labels).
|
||||||
|
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
|
||||||
|
|||||||
@@ -19,15 +19,20 @@ import io.ktor.client.call.body
|
|||||||
import io.ktor.client.plugins.ClientRequestException
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import ru.fromchat.MainActivity
|
import ru.fromchat.MainActivity
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.R
|
import ru.fromchat.R
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||||
import ru.fromchat.api.local.messages.buildChatListPreview
|
import ru.fromchat.api.local.messages.buildChatListPreview
|
||||||
@@ -53,13 +58,20 @@ object NotificationHelper {
|
|||||||
private const val CHAT_TYPE_PUBLIC = "public"
|
private const val CHAT_TYPE_PUBLIC = "public"
|
||||||
private const val CHAT_TYPE_DM = "dm"
|
private const val CHAT_TYPE_DM = "dm"
|
||||||
private const val CHANNEL_ID = "fromchat_messages"
|
private const val CHANNEL_ID = "fromchat_messages"
|
||||||
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary
|
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
|
||||||
|
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 1000000
|
||||||
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
private const val PREF_SHOWN_KEY = "shown_message_ids"
|
||||||
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
private const val PREF_SHOWN_DM_KEY = "shown_dm_message_ids"
|
||||||
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
private const val PREF_LAST_DM_MESSAGE_ID = "last_dm_message_id"
|
||||||
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||||
|
private const val PUBLIC_FETCH_DEBOUNCE_MS = 450L
|
||||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||||
|
|
||||||
|
private val helperScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val publicFetchMutex = Mutex()
|
||||||
|
private var publicFetchJob: Job? = null
|
||||||
|
|
||||||
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
|
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
|
||||||
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
||||||
return ChatListPreviewStrings(
|
return ChatListPreviewStrings(
|
||||||
@@ -72,6 +84,24 @@ object NotificationHelper {
|
|||||||
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
||||||
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
|
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
|
||||||
|
|
||||||
|
private fun publicConversationTitle(context: Context): String =
|
||||||
|
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
|
||||||
|
?: runCatching {
|
||||||
|
PublicChatProfileCache.hydrateFromDiskImmediate(
|
||||||
|
CacheContext.activeInstanceId.value.trim()
|
||||||
|
)?.title?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
?: context.getString(R.string.public_chat)
|
||||||
|
|
||||||
|
private fun senderDisplayLabel(message: Message, currentUserId: Int): String {
|
||||||
|
ProfileCache.get(message.user_id)
|
||||||
|
?.visibleDisplayName(currentUserId)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { return it }
|
||||||
|
message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||||
|
return message.username.trim().ifBlank { "FromChat" }
|
||||||
|
}
|
||||||
|
|
||||||
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
|
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
|
||||||
|
|
||||||
private fun createMessageIntent(
|
private fun createMessageIntent(
|
||||||
@@ -124,7 +154,6 @@ object NotificationHelper {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
fun createChannel(context: Context) {
|
fun createChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
(context
|
(context
|
||||||
@@ -141,6 +170,17 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coalesce rapid public FCM wakes into one /messages/new → MessagingStyle refresh. */
|
||||||
|
fun schedulePublicFetchAndNotify(context: Context) {
|
||||||
|
publicFetchJob?.cancel()
|
||||||
|
publicFetchJob = helperScope.launch {
|
||||||
|
delay(PUBLIC_FETCH_DEBOUNCE_MS)
|
||||||
|
publicFetchMutex.withLock {
|
||||||
|
fetchAndNotify(context.applicationContext, includeDmMessages = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun fetchAndNotify(
|
suspend fun fetchAndNotify(
|
||||||
context: Context,
|
context: Context,
|
||||||
includeDmMessages: Boolean = false,
|
includeDmMessages: Boolean = false,
|
||||||
@@ -168,10 +208,7 @@ object NotificationHelper {
|
|||||||
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
createChannel(context)
|
|
||||||
displayNotifications(context, messages)
|
displayNotifications(context, messages)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||||
}
|
}
|
||||||
@@ -194,13 +231,15 @@ object NotificationHelper {
|
|||||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||||
)
|
)
|
||||||
if (retryMessages.isNotEmpty()) {
|
if (retryMessages.isNotEmpty()) {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
createChannel(context)
|
|
||||||
displayNotifications(context, retryMessages)
|
displayNotifications(context, retryMessages)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (includeDmMessages) {
|
if (includeDmMessages) {
|
||||||
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName)
|
fetchAndNotifyDirectMessages(
|
||||||
|
context,
|
||||||
|
settings.getInt("current_user_id", -1),
|
||||||
|
dmMessageId,
|
||||||
|
dmSenderName
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -321,9 +360,9 @@ object NotificationHelper {
|
|||||||
showFallbackPushNotification(
|
showFallbackPushNotification(
|
||||||
context = context,
|
context = context,
|
||||||
title = if (senderName.isNotBlank()) {
|
title = if (senderName.isNotBlank()) {
|
||||||
"Direct message from $senderName"
|
context.getString(R.string.notification_direct_message_from, senderName)
|
||||||
} else {
|
} else {
|
||||||
"Direct message"
|
context.getString(R.string.notification_direct_message)
|
||||||
},
|
},
|
||||||
body = notificationBody,
|
body = notificationBody,
|
||||||
sender = senderName,
|
sender = senderName,
|
||||||
@@ -331,7 +370,7 @@ object NotificationHelper {
|
|||||||
allowWhenPublicChatVisible = true,
|
allowWhenPublicChatVisible = true,
|
||||||
isDirectMessage = true,
|
isDirectMessage = true,
|
||||||
targetDmUserId = dmConversationUserId,
|
targetDmUserId = dmConversationUserId,
|
||||||
conversationTitle = "Direct Messages"
|
conversationTitle = context.getString(R.string.notification_direct_messages_title)
|
||||||
)
|
)
|
||||||
shownDm.add(shownDmKey)
|
shownDm.add(shownDmKey)
|
||||||
}
|
}
|
||||||
@@ -352,10 +391,10 @@ object NotificationHelper {
|
|||||||
allowWhenPublicChatVisible: Boolean = false,
|
allowWhenPublicChatVisible: Boolean = false,
|
||||||
isDirectMessage: Boolean = false,
|
isDirectMessage: Boolean = false,
|
||||||
targetDmUserId: Int? = null,
|
targetDmUserId: Int? = null,
|
||||||
conversationTitle: String = "Public Chat",
|
conversationTitle: String = context.getString(R.string.public_chat),
|
||||||
senderId: Int? = null,
|
senderId: Int? = null,
|
||||||
) {
|
) {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
helperScope.launch(Dispatchers.Main) {
|
||||||
createChannel(context)
|
createChannel(context)
|
||||||
|
|
||||||
val currentUserId = settings.getInt("current_user_id", -1)
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
@@ -401,16 +440,31 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
|
val senderName = sender?.ifBlank { "FromChat" } ?: "FromChat"
|
||||||
|
val groupKey = if (isDirectMessage && targetDmUserId != null) {
|
||||||
|
GROUP_DM_PREFIX + targetDmUserId
|
||||||
|
} else {
|
||||||
|
GROUP_PUBLIC
|
||||||
|
}
|
||||||
|
val notificationId = if (isDirectMessage && targetDmUserId != null) {
|
||||||
|
SUMMARY_NOTIFICATION_ID + targetDmUserId
|
||||||
|
} else {
|
||||||
|
SUMMARY_NOTIFICATION_ID
|
||||||
|
}
|
||||||
|
cancelStaleSystemTrayDuplicates(context)
|
||||||
notify(
|
notify(
|
||||||
SUMMARY_NOTIFICATION_ID,
|
notificationId,
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
|
.setGroup(groupKey)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
NotificationCompat.MessagingStyle(
|
NotificationCompat.MessagingStyle(
|
||||||
Person.Builder().setName("FromChat").build()
|
Person.Builder().setName("FromChat").build()
|
||||||
).setConversationTitle(conversationTitle).addMessage(
|
)
|
||||||
|
.setConversationTitle(conversationTitle)
|
||||||
|
.setGroupConversation(true)
|
||||||
|
.addMessage(
|
||||||
NotificationCompat.MessagingStyle.Message(
|
NotificationCompat.MessagingStyle.Message(
|
||||||
body,
|
body,
|
||||||
System.currentTimeMillis(),
|
System.currentTimeMillis(),
|
||||||
@@ -424,7 +478,7 @@ object NotificationHelper {
|
|||||||
.addAction(
|
.addAction(
|
||||||
NotificationCompat.Action.Builder(
|
NotificationCompat.Action.Builder(
|
||||||
android.R.drawable.ic_menu_send,
|
android.R.drawable.ic_menu_send,
|
||||||
"Reply",
|
context.getString(R.string.notification_reply),
|
||||||
createReplyIntent(
|
createReplyIntent(
|
||||||
context = context,
|
context = context,
|
||||||
isDirectMessage = isDirectMessage,
|
isDirectMessage = isDirectMessage,
|
||||||
@@ -434,7 +488,7 @@ object NotificationHelper {
|
|||||||
)
|
)
|
||||||
.addRemoteInput(
|
.addRemoteInput(
|
||||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||||
.setLabel("Reply to chat...")
|
.setLabel(context.getString(R.string.notification_reply_hint))
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setAllowGeneratedReplies(true)
|
.setAllowGeneratedReplies(true)
|
||||||
@@ -458,37 +512,44 @@ object NotificationHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
private fun displayNotifications(context: Context, messages: List<Message>) {
|
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||||
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||||
|
|
||||||
// Don't show notifications if user is currently viewing the public chat
|
|
||||||
if (isPublicChatVisible) {
|
if (isPublicChatVisible) {
|
||||||
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalScope.launch {
|
helperScope.launch(Dispatchers.Main.immediate) {
|
||||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||||
var newMessageCount = 0
|
var newMessageCount = 0
|
||||||
val previewStrings = listPreviewStrings(context)
|
val previewStrings = listPreviewStrings(context)
|
||||||
|
val conversationTitle = publicConversationTitle(context)
|
||||||
|
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
|
||||||
|
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
if (
|
if (
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
// Find new messages that are not from the current user
|
Logger.w(
|
||||||
val currentUserId = settings.getInt("current_user_id", -1)
|
"NotificationHelper",
|
||||||
|
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentUserId = settings.getInt("current_user_id", -1)
|
||||||
if (currentUserId == -1) return@launch
|
if (currentUserId == -1) return@launch
|
||||||
|
|
||||||
val newMessages = messages.filter { msg ->
|
val newMessages = messages
|
||||||
!shown.contains(msg.id.toString()) && // Not already shown
|
.filter { msg ->
|
||||||
msg.user_id != currentUserId // Not from current user
|
!shown.contains(msg.id.toString()) && msg.user_id != currentUserId
|
||||||
}
|
}
|
||||||
|
.sortedBy { it.id }
|
||||||
if (newMessages.isEmpty()) {
|
if (newMessages.isEmpty()) {
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
@@ -496,50 +557,57 @@ object NotificationHelper {
|
|||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
newMessages.forEach { shown.add(it.id.toString()) }
|
||||||
|
|
||||||
newMessageCount = newMessages.size
|
newMessageCount = newMessages.size
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"NotificationHelper",
|
"NotificationHelper",
|
||||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
|
||||||
|
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
|
||||||
)
|
)
|
||||||
|
|
||||||
notify(
|
createChannel(context)
|
||||||
SUMMARY_NOTIFICATION_ID,
|
cancelStaleSystemTrayDuplicates(context)
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
val messagingStyle = NotificationCompat.MessagingStyle(
|
||||||
.setStyle(
|
|
||||||
NotificationCompat.MessagingStyle(
|
|
||||||
Person.Builder().setName("FromChat").build()
|
Person.Builder().setName("FromChat").build()
|
||||||
).setConversationTitle("Public Chat").let { style ->
|
)
|
||||||
|
.setConversationTitle(conversationTitle)
|
||||||
|
.setGroupConversation(true)
|
||||||
|
|
||||||
for (msg in newMessages.takeLast(10)) {
|
for (msg in newMessages.takeLast(10)) {
|
||||||
val timestamp = try {
|
val timestamp = try {
|
||||||
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
System.currentTimeMillis()
|
System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
messagingStyle.addMessage(
|
||||||
style.addMessage(
|
|
||||||
NotificationCompat.MessagingStyle.Message(
|
NotificationCompat.MessagingStyle.Message(
|
||||||
notificationBodyForMessage(msg, previewStrings),
|
notificationBodyForMessage(msg, previewStrings),
|
||||||
timestamp,
|
timestamp,
|
||||||
Person.Builder()
|
Person.Builder()
|
||||||
.setName(msg.username)
|
.setName(senderDisplayLabel(msg, currentUserId))
|
||||||
|
.setKey(msg.user_id.toString())
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
style
|
notify(
|
||||||
}
|
SUMMARY_NOTIFICATION_ID,
|
||||||
)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||||
|
.setLargeIcon(avatar)
|
||||||
|
.setContentTitle(conversationTitle)
|
||||||
|
.setStyle(messagingStyle)
|
||||||
|
.setGroup(GROUP_PUBLIC)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.addAction(
|
.addAction(
|
||||||
NotificationCompat.Action.Builder(
|
NotificationCompat.Action.Builder(
|
||||||
android.R.drawable.ic_menu_send,
|
android.R.drawable.ic_menu_send,
|
||||||
"Reply",
|
context.getString(R.string.notification_reply),
|
||||||
createReplyIntent(
|
createReplyIntent(
|
||||||
context = context,
|
context = context,
|
||||||
isDirectMessage = false,
|
isDirectMessage = false,
|
||||||
@@ -548,27 +616,42 @@ object NotificationHelper {
|
|||||||
)
|
)
|
||||||
.addRemoteInput(
|
.addRemoteInput(
|
||||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||||
.setLabel("Reply to chat...")
|
.setLabel(context.getString(R.string.notification_reply_hint))
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setAllowGeneratedReplies(true)
|
.setAllowGeneratedReplies(true)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
||||||
|
.setShortcutId(GROUP_PUBLIC)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Logger.w(
|
|
||||||
"NotificationHelper",
|
|
||||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
settings.putStringSet(PREF_SHOWN_KEY, shown)
|
||||||
Logger.i("NotificationHelper", "displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}")
|
Logger.i(
|
||||||
}
|
"NotificationHelper",
|
||||||
|
"displayNotifications: shown $newMessageCount new messages, total shown=${shown.size}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clears FCM auto-posted tray entries (notification payload) that duplicate our MessagingStyle. */
|
||||||
|
private fun cancelStaleSystemTrayDuplicates(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
// Legacy FCM auto notifications used id 0 / fcm_fallback_notification_channel.
|
||||||
|
manager.cancel(0)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
manager.activeNotifications
|
||||||
|
.filter { status ->
|
||||||
|
status.notification.channelId == "fcm_fallback_notification_channel" ||
|
||||||
|
status.id == 0
|
||||||
|
}
|
||||||
|
.forEach { status ->
|
||||||
|
manager.cancel(status.tag, status.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a circular initials avatar matching the in-app public-chat row (title initials +
|
||||||
|
* name-hash gradient), for use as a notification large icon.
|
||||||
|
*/
|
||||||
|
internal object PublicChatNotificationAvatar {
|
||||||
|
private const val SIZE_PX = 192
|
||||||
|
|
||||||
|
fun create(title: String): Bitmap {
|
||||||
|
val seed = title.ifBlank { "FromChat" }
|
||||||
|
val hash = seed.hashCode()
|
||||||
|
val r = abs(hash % 256)
|
||||||
|
val g = abs((hash / 256) % 256)
|
||||||
|
val b = abs((hash / 65536) % 256)
|
||||||
|
val colorStart = android.graphics.Color.rgb(
|
||||||
|
(r + 100).coerceIn(0, 255),
|
||||||
|
(g + 100).coerceIn(0, 255),
|
||||||
|
(b + 100).coerceIn(0, 255),
|
||||||
|
)
|
||||||
|
val colorEnd = android.graphics.Color.rgb(
|
||||||
|
(r + 50).coerceIn(0, 255),
|
||||||
|
(g + 50).coerceIn(0, 255),
|
||||||
|
(b + 50).coerceIn(0, 255),
|
||||||
|
)
|
||||||
|
|
||||||
|
val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
shader = LinearGradient(
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
SIZE_PX.toFloat(),
|
||||||
|
SIZE_PX.toFloat(),
|
||||||
|
colorStart,
|
||||||
|
colorEnd,
|
||||||
|
Shader.TileMode.CLAMP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val radius = SIZE_PX / 2f
|
||||||
|
canvas.drawCircle(radius, radius, radius, paint)
|
||||||
|
|
||||||
|
val initials = initialsFrom(seed)
|
||||||
|
if (initials.isNotBlank()) {
|
||||||
|
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = android.graphics.Color.WHITE
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
textSize = radius * 0.7f
|
||||||
|
}
|
||||||
|
val textY = radius - (textPaint.descent() + textPaint.ascent()) / 2f
|
||||||
|
canvas.drawText(initials, radius, textY, textPaint)
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialsFrom(displayName: String): String {
|
||||||
|
val words = displayName.trim().split("\\s+".toRegex()).filter { it.isNotBlank() }
|
||||||
|
return when {
|
||||||
|
words.isEmpty() -> ""
|
||||||
|
words.size == 1 -> {
|
||||||
|
val word = words[0]
|
||||||
|
if (word.length >= 2) word.take(2).uppercase() else (word + word).take(2).uppercase()
|
||||||
|
}
|
||||||
|
else -> words.take(2).joinToString("") {
|
||||||
|
it.firstOrNull()?.uppercaseChar()?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
<string name="public_chat">Общий чат</string>
|
||||||
<string name="chat_preview_attachment">Вложение</string>
|
<string name="chat_preview_attachment">Вложение</string>
|
||||||
<string name="chat_preview_image_emoji">📷</string>
|
<string name="chat_preview_image_emoji">📷</string>
|
||||||
<string name="chat_preview_image">%1$s 1 фото</string>
|
<string name="chat_preview_image">%1$s 1 фото</string>
|
||||||
|
<string name="notification_reply">Ответить</string>
|
||||||
|
<string name="notification_reply_hint">Ответ в чат…</string>
|
||||||
|
<string name="notification_direct_message">Личное сообщение</string>
|
||||||
|
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
|
||||||
|
<string name="notification_direct_messages_title">Личные сообщения</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">FromChat</string>
|
<string name="app_name" translatable="false">FromChat</string>
|
||||||
|
<string name="public_chat">Main chat</string>
|
||||||
<string name="chat_preview_attachment">Attachment</string>
|
<string name="chat_preview_attachment">Attachment</string>
|
||||||
<string name="chat_preview_image_emoji">📷</string>
|
<string name="chat_preview_image_emoji">📷</string>
|
||||||
<string name="chat_preview_image">%1$s 1 photo</string>
|
<string name="chat_preview_image">%1$s 1 photo</string>
|
||||||
|
<string name="notification_reply">Reply</string>
|
||||||
|
<string name="notification_reply_hint">Reply to chat…</string>
|
||||||
|
<string name="notification_direct_message">Direct message</string>
|
||||||
|
<string name="notification_direct_message_from">Direct message from %1$s</string>
|
||||||
|
<string name="notification_direct_messages_title">Direct Messages</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user