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"]
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: pushData["sender_username"]
|
||||
?: remoteMessage.data["senderUsername"]
|
||||
?: remoteMessage.data["senderDisplayName"]
|
||||
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
|
||||
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
|
||||
?: pushData["senderUsername"]
|
||||
?: pushData["senderDisplayName"]
|
||||
val messageType = pushData["type"] ?: "public_message"
|
||||
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
|
||||
if (ApiClient.token.isNullOrBlank()) {
|
||||
@@ -49,17 +47,6 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
Logger.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
|
||||
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) {
|
||||
NotificationHelper.fetchAndNotify(
|
||||
applicationContext,
|
||||
@@ -68,7 +55,9 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
dmSenderName = sender,
|
||||
)
|
||||
} 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) {
|
||||
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.request.get
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.fromchat.MainActivity
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.R
|
||||
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.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||
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_DM = "dm"
|
||||
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_DM_KEY = "shown_dm_message_ids"
|
||||
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 PUBLIC_FETCH_DEBOUNCE_MS = 450L
|
||||
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 {
|
||||
val emoji = context.getString(R.string.chat_preview_image_emoji)
|
||||
return ChatListPreviewStrings(
|
||||
@@ -72,6 +84,24 @@ object NotificationHelper {
|
||||
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
|
||||
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
|
||||
|
||||
private fun createMessageIntent(
|
||||
@@ -124,7 +154,6 @@ object NotificationHelper {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
(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(
|
||||
context: Context,
|
||||
includeDmMessages: Boolean = false,
|
||||
@@ -168,10 +208,7 @@ object NotificationHelper {
|
||||
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
|
||||
if (messages.isNotEmpty()) {
|
||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
createChannel(context)
|
||||
displayNotifications(context, messages)
|
||||
}
|
||||
} else {
|
||||
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
|
||||
}
|
||||
@@ -194,13 +231,15 @@ object NotificationHelper {
|
||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||
)
|
||||
if (retryMessages.isNotEmpty()) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
createChannel(context)
|
||||
displayNotifications(context, retryMessages)
|
||||
}
|
||||
}
|
||||
if (includeDmMessages) {
|
||||
fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName)
|
||||
fetchAndNotifyDirectMessages(
|
||||
context,
|
||||
settings.getInt("current_user_id", -1),
|
||||
dmMessageId,
|
||||
dmSenderName
|
||||
)
|
||||
}
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
@@ -321,9 +360,9 @@ object NotificationHelper {
|
||||
showFallbackPushNotification(
|
||||
context = context,
|
||||
title = if (senderName.isNotBlank()) {
|
||||
"Direct message from $senderName"
|
||||
context.getString(R.string.notification_direct_message_from, senderName)
|
||||
} else {
|
||||
"Direct message"
|
||||
context.getString(R.string.notification_direct_message)
|
||||
},
|
||||
body = notificationBody,
|
||||
sender = senderName,
|
||||
@@ -331,7 +370,7 @@ object NotificationHelper {
|
||||
allowWhenPublicChatVisible = true,
|
||||
isDirectMessage = true,
|
||||
targetDmUserId = dmConversationUserId,
|
||||
conversationTitle = "Direct Messages"
|
||||
conversationTitle = context.getString(R.string.notification_direct_messages_title)
|
||||
)
|
||||
shownDm.add(shownDmKey)
|
||||
}
|
||||
@@ -352,10 +391,10 @@ object NotificationHelper {
|
||||
allowWhenPublicChatVisible: Boolean = false,
|
||||
isDirectMessage: Boolean = false,
|
||||
targetDmUserId: Int? = null,
|
||||
conversationTitle: String = "Public Chat",
|
||||
conversationTitle: String = context.getString(R.string.public_chat),
|
||||
senderId: Int? = null,
|
||||
) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
helperScope.launch(Dispatchers.Main) {
|
||||
createChannel(context)
|
||||
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
@@ -401,16 +440,31 @@ object NotificationHelper {
|
||||
}
|
||||
|
||||
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(
|
||||
SUMMARY_NOTIFICATION_ID,
|
||||
notificationId,
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setGroup(groupKey)
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
).setConversationTitle(conversationTitle).addMessage(
|
||||
)
|
||||
.setConversationTitle(conversationTitle)
|
||||
.setGroupConversation(true)
|
||||
.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
body,
|
||||
System.currentTimeMillis(),
|
||||
@@ -424,7 +478,7 @@ object NotificationHelper {
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
"Reply",
|
||||
context.getString(R.string.notification_reply),
|
||||
createReplyIntent(
|
||||
context = context,
|
||||
isDirectMessage = isDirectMessage,
|
||||
@@ -434,7 +488,7 @@ object NotificationHelper {
|
||||
)
|
||||
.addRemoteInput(
|
||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel("Reply to chat...")
|
||||
.setLabel(context.getString(R.string.notification_reply_hint))
|
||||
.build()
|
||||
)
|
||||
.setAllowGeneratedReplies(true)
|
||||
@@ -458,37 +512,44 @@ object NotificationHelper {
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
|
||||
private fun displayNotifications(context: Context, messages: List<Message>) {
|
||||
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
|
||||
|
||||
// Don't show notifications if user is currently viewing the public chat
|
||||
if (isPublicChatVisible) {
|
||||
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
|
||||
return
|
||||
}
|
||||
|
||||
GlobalScope.launch {
|
||||
helperScope.launch(Dispatchers.Main.immediate) {
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
var newMessageCount = 0
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
val conversationTitle = publicConversationTitle(context)
|
||||
val avatar = PublicChatNotificationAvatar.create(conversationTitle)
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// Find new messages that are not from the current user
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
if (currentUserId == -1) return@launch
|
||||
|
||||
val newMessages = messages.filter { msg ->
|
||||
!shown.contains(msg.id.toString()) && // Not already shown
|
||||
msg.user_id != currentUserId // Not from current user
|
||||
val newMessages = messages
|
||||
.filter { msg ->
|
||||
!shown.contains(msg.id.toString()) && msg.user_id != currentUserId
|
||||
}
|
||||
.sortedBy { it.id }
|
||||
if (newMessages.isEmpty()) {
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
@@ -496,50 +557,57 @@ object NotificationHelper {
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
newMessages.apply { forEach { shown.add(it.id.toString()) } }
|
||||
newMessages.forEach { shown.add(it.id.toString()) }
|
||||
|
||||
newMessageCount = newMessages.size
|
||||
Logger.d(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
|
||||
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
|
||||
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
|
||||
)
|
||||
|
||||
notify(
|
||||
SUMMARY_NOTIFICATION_ID,
|
||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(NotificationSmallIcon.resId(context))
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(
|
||||
createChannel(context)
|
||||
cancelStaleSystemTrayDuplicates(context)
|
||||
|
||||
val messagingStyle = NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
).setConversationTitle("Public Chat").let { style ->
|
||||
)
|
||||
.setConversationTitle(conversationTitle)
|
||||
.setGroupConversation(true)
|
||||
|
||||
for (msg in newMessages.takeLast(10)) {
|
||||
val timestamp = try {
|
||||
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
style.addMessage(
|
||||
messagingStyle.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
notificationBodyForMessage(msg, previewStrings),
|
||||
timestamp,
|
||||
Person.Builder()
|
||||
.setName(msg.username)
|
||||
.setName(senderDisplayLabel(msg, currentUserId))
|
||||
.setKey(msg.user_id.toString())
|
||||
.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)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
"Reply",
|
||||
context.getString(R.string.notification_reply),
|
||||
createReplyIntent(
|
||||
context = context,
|
||||
isDirectMessage = false,
|
||||
@@ -548,27 +616,42 @@ object NotificationHelper {
|
||||
)
|
||||
.addRemoteInput(
|
||||
RemoteInput.Builder(KEY_TEXT_REPLY)
|
||||
.setLabel("Reply to chat...")
|
||||
.setLabel(context.getString(R.string.notification_reply_hint))
|
||||
.build()
|
||||
)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
)
|
||||
.setContentIntent(createMessageIntent(context, newMessages.last().id))
|
||||
.setShortcutId(GROUP_PUBLIC)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
Logger.w(
|
||||
"NotificationHelper",
|
||||
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<string name="public_chat">Общий чат</string>
|
||||
<string name="chat_preview_attachment">Вложение</string>
|
||||
<string name="chat_preview_image_emoji">📷</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>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<resources>
|
||||
<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_image_emoji">📷</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>
|
||||
Reference in New Issue
Block a user