Improve push notifications

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-20 19:12:02 +03:00
Unverified
parent 79f6ab57eb
commit d350623f14
5 changed files with 277 additions and 115 deletions
@@ -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 { displayNotifications(context, messages)
createChannel(context)
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 { displayNotifications(context, retryMessages)
createChannel(context)
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,22 +440,37 @@ 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(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
) )
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
)
) )
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE) .setCategory(Notification.CATEGORY_MESSAGE)
@@ -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,117 +512,146 @@ 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
}
if (currentUserId == -1) return@launch val currentUserId = settings.getInt("current_user_id", -1)
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
} }
if (newMessages.isEmpty()) { .sortedBy { it.id }
Logger.d( if (newMessages.isEmpty()) {
"NotificationHelper",
"displayNotifications: no new messages after filters for user=$currentUserId"
)
return@launch
}
newMessages.apply { forEach { shown.add(it.id.toString()) } }
newMessageCount = newMessages.size
Logger.d( Logger.d(
"NotificationHelper", "NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}" "displayNotifications: no new messages after filters for user=$currentUserId"
) )
return@launch
}
newMessages.forEach { shown.add(it.id.toString()) }
notify( newMessageCount = newMessages.size
SUMMARY_NOTIFICATION_ID, Logger.d(
NotificationCompat.Builder(context, CHANNEL_ID) "NotificationHelper",
.setSmallIcon(NotificationSmallIcon.resId(context)) "displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
.setStyle( "newMessages=$newMessageCount conversationTitle=$conversationTitle"
NotificationCompat.MessagingStyle( )
Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { style ->
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
style.addMessage( createChannel(context)
NotificationCompat.MessagingStyle.Message( cancelStaleSystemTrayDuplicates(context)
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(msg.username)
.build()
)
)
}
style val messagingStyle = NotificationCompat.MessagingStyle(
} Person.Builder().setName("FromChat").build()
) )
.setPriority(NotificationCompat.PRIORITY_HIGH) .setConversationTitle(conversationTitle)
.setCategory(Notification.CATEGORY_MESSAGE) .setGroupConversation(true)
.setAutoCancel(true)
for (msg in newMessages.takeLast(10)) {
val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) {
System.currentTimeMillis()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
notificationBodyForMessage(msg, previewStrings),
timestamp,
Person.Builder()
.setName(senderDisplayLabel(msg, currentUserId))
.setKey(msg.user_id.toString())
.build()
)
)
}
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)
.setCategory(Notification.CATEGORY_MESSAGE)
.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,
parentMessageId = newMessages.last().id parentMessageId = newMessages.last().id
) )
) )
.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))
.build() .setShortcutId(GROUP_PUBLIC)
) .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>