macOS desktop notifications

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-08-20 00:43:49 +03:00
Unverified
parent c7590a3d06
commit c903120ed4
54 changed files with 2659 additions and 1107 deletions
+4 -112
View File
@@ -2,132 +2,24 @@ package ru.fromchat
import android.app.Application
import com.pr0gramm3r101.utils.UtilsLibrary
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.notifications.NotificationHelper
class App: Application() {
@OptIn(DelicateCoroutinesApi::class)
private fun fetchAndNotify(
includeDmMessages: Boolean = false,
dmMessageId: Int? = null
) {
GlobalScope.launch(Dispatchers.IO) {
runCatching {
NotificationHelper.fetchAndNotify(
applicationContext,
includeDmMessages = includeDmMessages,
dmMessageId = dmMessageId
)
}
}
}
import ru.fromchat.notifications.MessageNotificationCoordinator
class App : Application() {
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() {
super.onCreate()
UtilsLibrary.init(this)
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
WebSocketManager.addGlobalMessageHandler { msg ->
GlobalScope.launch(Dispatchers.IO) {
runCatching {
val currentUserId = settings.getInt("current_user_id", -1)
fun isOwnPublicMessage(data: JsonObject?) =
data?.get("user_id")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
fun isOwnDmMessage(data: JsonObject?) =
data?.get("senderId")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
when (msg.type) {
"newMessage" -> {
if (!isOwnPublicMessage(msg.data?.jsonObject)) {
fetchAndNotify()
}
}
"dmNew" -> {
if (!isOwnDmMessage(msg.data?.jsonObject)) {
fetchAndNotify(
includeDmMessages = true,
dmMessageId = msg
.data
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content
?.toIntOrNull()
)
}
}
"updates" -> {
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
var shouldFetchPublic = false
var shouldFetchDm = false
var latestDmMessageId: Int? = null
for (item in updates) {
val (type, data) = item.jsonObject.let {
Pair(
it["type"]?.jsonPrimitive?.content,
it["data"]?.jsonObject
)
}
when (type) {
"newMessage" -> {
if (!isOwnPublicMessage(data)) {
shouldFetchPublic = true
}
}
"dmNew" -> {
if (!isOwnDmMessage(data)) {
shouldFetchDm = true
data
?.get("id")
?.jsonPrimitive
?.content
?.toIntOrNull()
?.let {
latestDmMessageId = it.coerceAtLeast(
latestDmMessageId ?: 0
)
}
}
}
}
}
if (shouldFetchPublic || shouldFetchDm) {
fetchAndNotify(
includeDmMessages = shouldFetchDm,
dmMessageId = latestDmMessageId
)
}
}
}
}
}
}
}
MessageNotificationCoordinator.install()
GlobalScope.launch(Dispatchers.IO) {
runCatching { ApiClient.loadPersistedData() }
AttachmentTransferBootstrap.launchOnApplicationStart()
}
}
}
}
@@ -15,18 +15,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.config.ServerConfig
import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.notifications.NotificationLaunchTarget
import ru.fromchat.ui.App
@@ -34,7 +23,6 @@ import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
@@ -81,10 +69,6 @@ class MainActivity : ComponentActivity() {
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
)
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
markMessagesAsRead()
}
val baseState = ProfileDeepLinkResolution(
scrollToMessageId = if (messageId != -1) messageId else null,
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
@@ -183,30 +167,6 @@ class MainActivity : ComponentActivity() {
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun markMessagesAsRead() {
GlobalScope.launch {
try {
// Get all unread messages and mark them as read
val messageIds = ApiClient.http
.get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
.map { it.id }
if (messageIds.isNotEmpty()) {
ApiClient.http.post("${ServerConfig.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json)
setBody(mapOf("messageIds" to messageIds))
}
Logger.i("MainActivity", "Marked ${messageIds.size} messages as read")
}
} catch (e: Exception) {
Logger.e("MainActivity", "Failed to mark messages as read", e)
}
}
}
private fun checkGooglePlayServices(): Boolean {
with (GoogleApiAvailability.getInstance()) {
val resultCode = isGooglePlayServicesAvailable(this@MainActivity)
@@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.notifications.MessageNotificationCoordinator
@OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
@@ -27,20 +27,11 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_display_name"]
?.takeIf { it.isNotBlank() }
?: pushData["sender_username"]
?: pushData["senderUsername"]
?: pushData["senderDisplayName"]
val messageType = pushData["type"] ?: "public_message"
val isDirectMessage = messageType.equals("dm", ignoreCase = true)
if (ApiClient.token.isNullOrBlank()) {
Logger.w("FromChatFCM", "No auth token in memory; loading persisted data before handling push")
ApiClient.loadPersistedData()
Logger.d(
"FromChatFCM",
"Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}",
)
}
val currentUserId = settings.getInt("current_user_id", -1)
if (senderId != null && senderId == currentUserId) {
@@ -48,16 +39,12 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
return@launch
}
if (isDirectMessage) {
NotificationHelper.fetchAndNotify(
applicationContext,
MessageNotificationCoordinator.fetchAndNotify(
includeDmMessages = true,
dmMessageId = fallbackMessageId,
dmSenderName = sender,
)
} else {
// Public: one debounced /messages/new → MessagingStyle. Never post a
// per-message fallback (that duplicated FCM tray entries with different labels).
NotificationHelper.schedulePublicFetchAndNotify(applicationContext)
MessageNotificationCoordinator.schedulePublicFetchAndNotify()
}
} catch (e: Exception) {
Logger.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
@@ -1,657 +0,0 @@
package ru.fromchat.notifications
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.pr0gramm3r101.utils.settings.settings
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.Dispatchers
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
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
import ru.fromchat.config.ServerConfig
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
import kotlin.time.Instant
object NotificationHelper {
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
private const val EXTRA_REPLY_DM_USER_ID = "notification_reply_dm_user_id"
private const val EXTRA_REPLY_PARENT_MESSAGE_ID = "notification_reply_parent_message_id"
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
private const val CHANNEL_ID = "fromchat_messages"
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(
imageEmoji = emoji,
imageOnly = context.getString(R.string.chat_preview_image, emoji),
attachmentOnly = context.getString(R.string.chat_preview_attachment),
)
}
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(
context: Context,
messageId: Int,
targetDmUserId: Int? = null,
markMessageRead: Boolean = true
) = PendingIntent.getActivity(
context,
if (targetDmUserId != null) -messageId else messageId,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_MESSAGE_ID, messageId)
putExtra(
EXTRA_NOTIFICATION_CHAT_TYPE,
if (targetDmUserId != null) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC
)
putExtra(EXTRA_OPEN_DM_USER_ID, targetDmUserId ?: -1)
putExtra(EXTRA_MARK_MESSAGE_READ, markMessageRead)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun notificationReplyAction(context: Context) =
"${context.packageName}.NOTIFICATION_REPLY"
private fun createReplyIntent(
context: Context,
isDirectMessage: Boolean = false,
targetDmUserId: Int? = null,
parentMessageId: Int? = null
) = PendingIntent.getBroadcast(
context,
if (isDirectMessage && targetDmUserId != null) {
-targetDmUserId
} else {
parentMessageId ?: SUMMARY_NOTIFICATION_ID
},
Intent(context, NotificationReplyReceiver::class.java).apply {
action = notificationReplyAction(context)
putExtra("notification_id", SUMMARY_NOTIFICATION_ID)
putExtra(EXTRA_REPLY_CHAT_TYPE, if (isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC)
putExtra(EXTRA_REPLY_DM_USER_ID, targetDmUserId ?: -1)
if (parentMessageId != null) {
putExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, parentMessageId)
}
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
(context
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "FromChat message notifications"
}
)
}
}
/** 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,
dmMessageId: Int? = null,
dmSenderName: String? = null
) {
Logger.i("NotificationHelper", "fetchAndNotify: starting fetch")
try {
val currentUserId = settings.getInt("current_user_id", -1)
Logger.d(
"NotificationHelper",
"fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}"
)
if (currentUserId == -1) {
Logger.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync")
return
}
val messages = ApiClient.http
.get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
.filter { it.user_id != currentUserId }
Logger.i("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
displayNotifications(context, messages)
} else {
Logger.d("NotificationHelper", "fetchAndNotify: no public messages returned")
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, currentUserId, dmMessageId, dmSenderName)
}
} catch (e: Exception) {
if (e is ClientRequestException && e.response.status.value == 401) {
try {
Logger.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying")
ApiClient.loadPersistedData()
val retryMessages = ApiClient.http
.get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>()
.messages
.filter { it.user_id != settings.getInt("current_user_id", -1) }
Logger.i(
"NotificationHelper",
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
)
if (retryMessages.isNotEmpty()) {
displayNotifications(context, retryMessages)
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(
context,
settings.getInt("current_user_id", -1),
dmMessageId,
dmSenderName
)
}
return
} catch (_: Exception) {
Logger.e("NotificationHelper", "fetchAndNotify retry failed", e)
}
}
Logger.e("NotificationHelper", "fetchAndNotify: error ${e.message}", e)
}
}
private suspend fun fetchAndNotifyDirectMessages(
context: Context,
currentUserId: Int,
dmMessageId: Int? = null,
dmSenderName: String? = null
) {
val storedLastDmMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
val sinceId = when {
dmMessageId != null && dmMessageId > storedLastDmMessageId -> dmMessageId - 1
storedLastDmMessageId > 0 -> storedLastDmMessageId
else -> null
}
if (sinceId == null || sinceId < 0) {
Logger.d("NotificationHelper", "fetchAndNotifyDirectMessages: no dm watermark yet, skipping broad dm sync")
return
}
val response = runCatching {
ApiClient.getDmFetch(sinceId)
}.getOrElse { throwable ->
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
throw throwable
}
Logger.e(
"NotificationHelper",
"fetchAndNotifyDirectMessages: failed to fetch dm messages for since=$sinceId: ${throwable.message}",
throwable
)
return
}
processDirectMessages(context, response, currentUserId, dmMessageId, dmSenderName)
}
private suspend fun processDirectMessages(
context: Context,
response: DmHistoryResponse,
currentUserId: Int,
dmMessageId: Int?,
dmSenderName: String?
) {
val dmMessages = response.messages
Logger.i("NotificationHelper", "fetchAndNotifyDirectMessages: fetched ${dmMessages.size} dm messages")
if (dmMessages.isEmpty()) {
return
}
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
val previewStrings = listPreviewStrings(context)
dmMessages
.filter { envelope ->
envelope.id > 0 && envelope.senderId != currentUserId
}
.forEach { envelope ->
val envelopeId = envelope.id
val shownDmKey = "dm:$envelopeId"
if (shownDm.contains(shownDmKey) || envelopeId <= latestMessageId) {
Logger.d(
"NotificationHelper",
"Direct notification skipped: already shown envelopeId=$envelopeId"
)
return@forEach
}
val plaintext = runCatching {
decryptEnvelope(envelope, currentUserId)
}.getOrElse { throwable ->
when (throwable) {
is DmCiphertextCorruptedException -> {
Logger.w(
"NotificationHelper",
"DM decrypt failed for envelopeId=$envelopeId"
)
CorruptedDmMessagePlaceholder
}
else -> {
Logger.w(
"NotificationHelper",
"DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}",
throwable
)
"Encrypted message"
}
}
}
val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?: envelope.senderUsername
}.orEmpty()
val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope(
envelope = envelope,
decryptedPlaintext = plaintext,
strings = previewStrings,
)?.takeIf { it.isNotBlank() } ?: plaintext
showFallbackPushNotification(
context = context,
title = if (senderName.isNotBlank()) {
context.getString(R.string.notification_direct_message_from, senderName)
} else {
context.getString(R.string.notification_direct_message)
},
body = notificationBody,
sender = senderName,
messageId = envelopeId,
allowWhenPublicChatVisible = true,
isDirectMessage = true,
targetDmUserId = dmConversationUserId,
conversationTitle = context.getString(R.string.notification_direct_messages_title)
)
shownDm.add(shownDmKey)
}
val newMaxDmId = dmMessages.maxOfOrNull { it.id } ?: 0
if (newMaxDmId > latestMessageId) {
settings.putInt(PREF_LAST_DM_MESSAGE_ID, newMaxDmId)
}
settings.putStringSet(PREF_SHOWN_DM_KEY, shownDm)
}
fun showFallbackPushNotification(
context: Context,
title: String,
body: String,
sender: String? = null,
messageId: Int? = null,
allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false,
targetDmUserId: Int? = null,
conversationTitle: String = context.getString(R.string.public_chat),
senderId: Int? = null,
) {
helperScope.launch(Dispatchers.Main) {
createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1)
if (!isDirectMessage && senderId != null && senderId == currentUserId) {
Logger.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId")
return@launch
}
if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) {
Logger.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId")
return@launch
}
if (isPublicChatVisible && !allowWhenPublicChatVisible) {
Logger.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
return@launch
}
with(NotificationManagerCompat.from(context)) {
if (
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
Logger.w(
"NotificationHelper",
"Fallback push notification skipped: POST_NOTIFICATIONS permission missing"
)
return@launch
}
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
val shownKey = if (isDirectMessage) "dm:${messageId}" else messageId?.toString()
if (messageId != null && shown.contains(shownKey)) {
Logger.d(
"NotificationHelper",
"Fallback push notification skipped: already shown messageId=$messageId"
)
return@launch
}
if (messageId != null) {
shownKey?.let { shown.add(it) }
}
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(
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)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build()
)
)
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.addAction(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = isDirectMessage,
targetDmUserId = targetDmUserId,
parentMessageId = messageId
)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.setContentIntent(
createMessageIntent(
context = context,
messageId = messageId ?: 0,
targetDmUserId = targetDmUserId,
markMessageRead = !isDirectMessage
)
)
.build()
)
settings.putStringSet(PREF_SHOWN_KEY, shown)
Logger.i(
"NotificationHelper",
"Fallback push notification shown messageId=$messageId"
)
}
}
}
private fun displayNotifications(context: Context, messages: List<Message>) {
Logger.i("NotificationHelper", "displayNotifications: ${messages.size} messages")
if (isPublicChatVisible) {
Logger.d("NotificationHelper", "Skipping notifications: user is viewing public chat")
return
}
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
) {
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()) && msg.user_id != currentUserId
}
.sortedBy { it.id }
if (newMessages.isEmpty()) {
Logger.d(
"NotificationHelper",
"displayNotifications: no new messages after filters for user=$currentUserId"
)
return@launch
}
newMessages.forEach { shown.add(it.id.toString()) }
newMessageCount = newMessages.size
Logger.d(
"NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} " +
"newMessages=$newMessageCount conversationTitle=$conversationTitle"
)
createChannel(context)
cancelStaleSystemTrayDuplicates(context)
val messagingStyle = NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
)
.setConversationTitle(conversationTitle)
.setGroupConversation(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(
NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
context.getString(R.string.notification_reply),
createReplyIntent(
context = context,
isDirectMessage = false,
parentMessageId = newMessages.last().id
)
)
.addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY)
.setLabel(context.getString(R.string.notification_reply_hint))
.build()
)
.setAllowGeneratedReplies(true)
.build()
)
.setContentIntent(createMessageIntent(context, newMessages.last().id))
.setShortcutId(GROUP_PUBLIC)
.build()
)
}
settings.putStringSet(PREF_SHOWN_KEY, shown)
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)
}
}
}
}
}
@@ -27,7 +27,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
)
val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input ->
(input.getCharSequence(NotificationHelper.KEY_TEXT_REPLY)
(input.getCharSequence(KEY_TEXT_REPLY)
?: input.getCharSequence("key_text_reply"))
}
?.toString()
@@ -47,7 +47,11 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val parentMessageId = intent.getIntExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, -1).takeIf { it > 0 }
Logger.d("NotificationReply", "Received reply for $chatType (length=${replyText.length})")
NotificationManagerCompat.from(context).cancel(NotificationHelper.summaryNotificationId())
val notificationId = intent.getIntExtra("notification_id", 0)
if (intent.hasExtra("notification_id")) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
MessageNotificationCoordinator.dismissAll()
GlobalScope.launch(Dispatchers.IO) {
try {
+202 -26
View File
@@ -54,11 +54,18 @@ compose.desktop {
}
macOS {
bundleID = "ru.fromchat.desktop"
dockName = "FromChat"
iconFile.set(desktopWindowIconIcns)
// Tray icons as NSImage templates (menu bar light/dark tint). Also set in Main.kt.
jvmArgs("-Dapple.awt.enableTemplateImages=true")
infoPlist {
extraKeysRawXml = """
<key>CFBundleDisplayName</key>
<string>FromChat</string>
<key>NSUserNotificationAlertStyle</key>
<string>banner</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -81,41 +88,210 @@ kotlin {
jvmToolchain(17)
}
val macNotificationsSource = layout.projectDirectory.file("src/nativeDarwin/MacNotificationCenter.m")
val macNotificationsDylib = layout.buildDirectory.file("natives/libfromchat_notifications.dylib")
val compileMacNotifications = tasks.register<Exec>("compileMacNotifications") {
onlyIf {
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
}
inputs.file(macNotificationsSource)
outputs.file(macNotificationsDylib)
doFirst {
macNotificationsDylib.get().asFile.parentFile.mkdirs()
}
val jniInclude = listOfNotNull(
System.getenv("JAVA_HOME")?.let { "$it/include" },
"${System.getProperty("java.home")}/include",
"/opt/homebrew/opt/openjdk/include",
"/opt/homebrew/opt/openjdk@26/include",
"/Library/Java/JavaVirtualMachines/openjdk.jdk/Contents/Home/include",
).first { file("$it/jni.h").isFile }
commandLine(
"clang",
"-shared",
"-fobjc-arc",
"-fobjc-exceptions",
"-fPIC",
"-mmacosx-version-min=11.0",
"-arch", "arm64",
"-arch", "x86_64",
"-framework", "Foundation",
"-framework", "AppKit",
"-framework", "UserNotifications",
"-framework", "CoreGraphics",
"-framework", "CoreServices",
"-I", jniInclude,
"-I", "$jniInclude/darwin",
"-o", macNotificationsDylib.get().asFile.absolutePath,
macNotificationsSource.asFile.absolutePath,
)
}
tasks.named<Copy>("processResources") {
dependsOn(compileMacNotifications)
from(layout.buildDirectory.dir("natives")) {
include("*.dylib")
into("natives")
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
}
/**
* Compose Desktop puts the whole runtime classpath on the unnamed module path.
* JavaFX 11+ must be loaded as named modules via --module-path / --add-modules,
* and those jars must not also sit on the regular classpath.
*
* On macOS, [UNUserNotificationCenter] aborts a bare `java` process
* (`bundleProxyForCurrentProcess is nil`). :run is therefore launched from a
* FromChat.app wrapper so Notification Center sees `ru.fromchat.desktop`.
*/
fun JavaExec.configureFromChatDesktopJvm() {
val javafxJars = classpath.files.filter { file ->
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
}
classpath = project.files(classpath.files.filterNot { it in javafxJars.toSet() })
val dockIconArgs = buildList {
val os = System.getProperty("os.name").orEmpty().lowercase()
if (os.contains("mac")) {
val png = desktopWindowIconPng.asFile
val icns = desktopWindowIconIcns.asFile
when {
icns.isFile -> add("-Xdock:icon=${icns.absolutePath}")
png.isFile -> add("-Xdock:icon=${png.absolutePath}")
}
add("-Xdock:name=FromChat")
}
}
jvmArgs(
dockIconArgs + listOf(
"-Dapple.awt.enableTemplateImages=true",
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules",
"javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
"--add-opens",
"javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
),
)
}
fun prepareFromChatDevApp(javaHome: File, destApp: File) {
val macosDir = destApp.resolve("Contents/MacOS")
val resourcesDir = destApp.resolve("Contents/Resources")
macosDir.mkdirs()
resourcesDir.mkdirs()
val destExec = macosDir.resolve("FromChat")
val stamp = resourcesDir.resolve("java-home.txt")
val javaHomePath = javaHome.absolutePath
if (!destExec.isFile || stamp.takeIf { it.isFile }?.readText() != javaHomePath) {
javaHome.resolve("bin/java").copyTo(destExec, overwrite = true)
destExec.setExecutable(true, false)
ProcessBuilder(
"install_name_tool",
"-add_rpath",
javaHome.resolve("lib").absolutePath,
destExec.absolutePath,
).inheritIO().start().waitFor()
stamp.writeText(javaHomePath)
}
destApp.resolve("Contents/Info.plist").writeText(
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>FromChat</string>
<key>CFBundleIdentifier</key>
<string>ru.fromchat.desktop</string>
<key>CFBundleName</key>
<string>FromChat</string>
<key>CFBundleDisplayName</key>
<string>FromChat</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleIconFile</key>
<string>app_window_icon.icns</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSUserNotificationAlertStyle</key>
<string>banner</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>FromChat</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fromchat</string>
</array>
</dict>
</array>
</dict>
</plist>
""".trimIndent() + "\n",
)
destApp.resolve("Contents/PkgInfo").writeText("APPL????")
val icns = desktopWindowIconIcns.asFile
if (icns.isFile) {
icns.copyTo(resourcesDir.resolve("app_window_icon.icns"), overwrite = true)
}
ProcessBuilder(
"codesign",
"--force",
"--sign",
"-",
"--identifier",
"ru.fromchat.desktop",
destApp.absolutePath,
).inheritIO().start().waitFor()
}
val runningOnMac = System.getProperty("os.name").orEmpty().lowercase().contains("mac")
tasks.matching { it.name == "run" }.configureEach {
if (this !is JavaExec) return@configureEach
doFirst {
val javafxJars = classpath.files.filter { file ->
javafxModules.any { module -> file.name.startsWith("javafx-$module-") }
if (runningOnMac) return@configureEach
doFirst { configureFromChatDesktopJvm() }
}
afterEvaluate {
if (!runningOnMac) return@afterEvaluate
val runTask = tasks.findByName("run") as? JavaExec ?: return@afterEvaluate
runTask.actions.clear()
runTask.doFirst { runTask.configureFromChatDesktopJvm() }
runTask.doLast {
val javaHomeFile = runTask.javaLauncher.orNull?.metadata?.installationPath?.asFile
?: file(System.getProperty("java.home"))
val app = layout.buildDirectory.get().asFile.resolve("macos-dev-bundle/FromChat.app")
prepareFromChatDevApp(javaHomeFile, app)
val command = buildList {
add(app.resolve("Contents/MacOS/FromChat").absolutePath)
addAll(runTask.allJvmArgs)
add("-classpath")
add(runTask.classpath.asPath)
add(runTask.mainClass.get())
addAll(runTask.args)
}
classpath = files(classpath.files.filterNot { it in javafxJars.toSet() })
val dockIconArgs = buildList {
// Compose only adds -Xdock:icon when macOS.iconFile is set; pin PNG for :run too.
val os = System.getProperty("os.name").orEmpty().lowercase()
if (os.contains("mac")) {
val png = desktopWindowIconPng.asFile
val icns = desktopWindowIconIcns.asFile
when {
icns.isFile -> add("-Xdock:icon=${icns.absolutePath}")
png.isFile -> add("-Xdock:icon=${png.absolutePath}")
}
}
val process = ProcessBuilder(command)
.directory(runTask.workingDir)
.redirectInput(ProcessBuilder.Redirect.INHERIT)
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
.redirectError(ProcessBuilder.Redirect.INHERIT)
process.environment().putAll(runTask.environment.mapValues { it.value.toString() })
process.environment()["JAVA_HOME"] = javaHomeFile.absolutePath
val exit = process.start().waitFor()
if (exit != 0) {
throw GradleException("FromChat exited with $exit")
}
jvmArgs(
dockIconArgs + listOf(
"-Dapple.awt.enableTemplateImages=true",
"--module-path",
javafxJars.joinToString(File.pathSeparator) { it.absolutePath },
"--add-modules",
"javafx.controls,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.base",
"--add-opens",
"javafx.graphics/com.sun.javafx.application=ALL-UNNAMED",
),
)
}
}
@@ -7,11 +7,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
@@ -30,6 +32,7 @@ import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowExceptionHandler
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberTrayState
@@ -72,6 +75,12 @@ import ru.fromchat.desktop_zoom
import ru.fromchat.status_connected
import ru.fromchat.status_connecting
import ru.fromchat.status_disconnected
import ru.fromchat.desktop.DesktopAppVisibility
import ru.fromchat.desktop.DesktopNotificationSettings
import ru.fromchat.desktop.DesktopNotifier
import ru.fromchat.desktop.DesktopTaskbarBadge
import ru.fromchat.desktop.MacNotificationCenter
import ru.fromchat.notifications.MessageNotificationCoordinator
import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
import ru.fromchat.ui.Theme
@@ -285,6 +294,9 @@ fun main(args: Array<String>) {
application {
UtilsLibrary.init()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Logger.e("Desktop", "uncaught on ${thread.name}", throwable)
}
DesktopApplicationBootstrap.launchOnApplicationStart()
val windowState = rememberWindowState(
@@ -345,20 +357,72 @@ fun main(args: Array<String>) {
}
DisposableEffect(trayState) {
DesktopNotifier.sink = { title, body ->
trayState.sendNotification(
Notification(title = title, message = body),
DesktopNotifier.sink = { payload ->
val native = mac &&
MacNotificationCenter.deliver(
title = payload.title,
body = payload.body,
subtitle = payload.subtitle,
)
Logger.i(
"DesktopNotifier",
"sink mac=$mac nativeDelivered=$native titleLen=${payload.title.length} " +
"subtitleLen=${payload.subtitle.length}",
)
if (!native) {
Logger.i("DesktopNotifier", "sink fallback trayState.sendNotification")
trayState.sendNotification(
Notification(title = payload.title, message = payload.displayBody()),
)
}
}
onDispose {
DesktopNotifier.sink = null
MessageNotificationCoordinator.uninstall()
DesktopSingleInstance.release()
}
}
fun showMainWindow() {
LaunchedEffect(isLoggedIn) {
if (isLoggedIn) {
MessageNotificationCoordinator.install()
MessageNotificationCoordinator.refreshChrome()
} else {
MessageNotificationCoordinator.uninstall()
DesktopTaskbarBadge.setUnreadCount(0)
}
}
SideEffect {
DesktopAppVisibility.isWindowVisible = windowVisible
if (!windowVisible) {
DesktopAppVisibility.isWindowFocused = false
AppForeground.setWindowFocused(false)
}
}
fun showMainWindow(notificationIdentifier: String? = null) {
windowVisible = true
AppForeground.setForeground(true)
DesktopNotifier.deliverPendingLaunch(notificationIdentifier)
}
LaunchedEffect(mac, windowVisible) {
if (!mac) return@LaunchedEffect
MacNotificationCenter.onActivated = { identifier ->
SwingUtilities.invokeLater { showMainWindow(identifier) }
}
if (!windowVisible || !DesktopNotificationSettings.enabled) {
Logger.i(
"MacNotificationCenter",
"startup skip requestAuthorization windowVisible=$windowVisible " +
"enabled=${DesktopNotificationSettings.enabled}",
)
return@LaunchedEffect
}
delay(400.milliseconds)
Logger.i("MacNotificationCenter", "startup requestAuthorization")
MacNotificationCenter.registerAndRequestAuthorization()
}
fun openAbout() {
@@ -486,9 +550,46 @@ fun main(args: Array<String>) {
}
}
DisposableEffect(Unit) {
DisposableEffect(window) {
installLoggedWindowExceptionHandler(window)
AppForeground.setForeground(true)
onDispose {}
fun syncWindowFocus(focused: Boolean) {
DesktopAppVisibility.isWindowFocused = focused
AppForeground.setWindowFocused(focused)
if (!focused) MacNotificationCenter.resignActive()
Logger.i(
"DesktopAppVisibility",
"focus=$focused active=${window.isActive} awtFocused=${window.isFocused}",
)
}
val listener = object : java.awt.event.WindowAdapter() {
override fun windowActivated(e: java.awt.event.WindowEvent?) {
syncWindowFocus(true)
}
override fun windowDeactivated(e: java.awt.event.WindowEvent?) {
syncWindowFocus(false)
}
override fun windowGainedFocus(e: java.awt.event.WindowEvent?) {
syncWindowFocus(true)
}
override fun windowLostFocus(e: java.awt.event.WindowEvent?) {
syncWindowFocus(false)
}
override fun windowIconified(e: java.awt.event.WindowEvent?) {
syncWindowFocus(false)
}
}
syncWindowFocus(window.isActive && window.isFocused)
window.addWindowListener(listener)
window.addWindowFocusListener(listener)
onDispose {
window.removeWindowListener(listener)
window.removeWindowFocusListener(listener)
}
}
if (mac) {
@@ -913,6 +1014,13 @@ private fun scaleBufferedImage(source: BufferedImage, size: Int): BufferedImage
return out
}
@OptIn(ExperimentalComposeUiApi::class)
private fun installLoggedWindowExceptionHandler(window: androidx.compose.ui.awt.ComposeWindow) {
window.exceptionHandler = WindowExceptionHandler { throwable ->
Logger.e("DesktopWindow", "uncaught in composition", throwable)
}
}
/** Opaque mark so Tray never substitutes the Compose default for a blank painter. */
private fun solidFallbackIcon(): BufferedImage {
val out = BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB)
@@ -0,0 +1,468 @@
#import <AppKit/AppKit.h>
#import <CoreGraphics/CoreGraphics.h>
#import <CoreServices/CoreServices.h>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
#import <jni.h>
@class FromChatNotificationDelegate;
static JavaVM *fromchatJvm = NULL;
static FromChatNotificationDelegate *fromchatDelegate = nil;
static jclass fromchatMacNotificationCenterClass = NULL;
@interface FromChatNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
@end
static void fromchatCallJavaStaticVoid(const char *methodName, const char *utfArg);
@implementation FromChatNotificationDelegate
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
UNNotificationPresentationOptions options =
UNNotificationPresentationOptionBanner |
UNNotificationPresentationOptionList |
UNNotificationPresentationOptionSound |
UNNotificationPresentationOptionBadge;
NSLog(
@"FromChat UN willPresent id=%@ nsAppActive=%d options=%lu",
notification.request.identifier,
[NSApp isActive],
(unsigned long)options
);
completionHandler(options);
fromchatCallJavaStaticVoid("onNativeWillPresent", notification.request.identifier.UTF8String);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler {
NSString *action = response.actionIdentifier;
NSString *identifier = response.notification.request.identifier;
NSLog(@"FromChat UN response action=%@ id=%@", action, identifier);
if ([action isEqualToString:UNNotificationDismissActionIdentifier]) {
completionHandler();
return;
}
fromchatCallJavaStaticVoid("onNativeActivated", identifier.UTF8String);
completionHandler();
}
@end
static void fromchatCallJavaStaticVoid(const char *methodName, const char *utfArg) {
if (fromchatJvm == NULL || fromchatMacNotificationCenterClass == NULL || methodName == NULL) {
return;
}
JNIEnv *env = NULL;
jint getEnv = (*fromchatJvm)->GetEnv(fromchatJvm, (void **)&env, JNI_VERSION_1_8);
jboolean attachedHere = JNI_FALSE;
if (getEnv == JNI_EDETACHED) {
if ((*fromchatJvm)->AttachCurrentThread(fromchatJvm, (void **)&env, NULL) != JNI_OK) {
return;
}
attachedHere = JNI_TRUE;
}
if (env == NULL) return;
jmethodID mid = (*env)->GetStaticMethodID(
env,
fromchatMacNotificationCenterClass,
methodName,
"(Ljava/lang/String;)V"
);
if (mid != NULL) {
jstring jid = utfArg != NULL ? (*env)->NewStringUTF(env, utfArg) : NULL;
(*env)->CallStaticVoidMethod(env, fromchatMacNotificationCenterClass, mid, jid);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
if (jid != NULL) (*env)->DeleteLocalRef(env, jid);
}
if (attachedHere) {
(*fromchatJvm)->DetachCurrentThread(fromchatJvm);
}
}
static BOOL fromchatIsBundledApp(void) {
NSString *path = [NSBundle mainBundle].bundlePath;
return [path.pathExtension isEqualToString:@"app"];
}
static UNUserNotificationCenter *fromchatNotificationCenter(void) {
NSBundle *bundle = [NSBundle mainBundle];
if (!fromchatIsBundledApp()) {
NSLog(@"FromChat UN skip: not an .app (path=%@ id=%@)", bundle.bundlePath, bundle.bundleIdentifier);
return nil;
}
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
NSLog(@"FromChat UN center=%p path=%@ id=%@", center, bundle.bundlePath, bundle.bundleIdentifier);
return center;
}
static void fromchatRunOnMain(void (^block)(void)) {
if ([NSThread isMainThread]) {
block();
} else {
dispatch_sync(dispatch_get_main_queue(), block);
}
}
static void fromchatEnsureDelegate(UNUserNotificationCenter *center) {
if (center == nil) return;
fromchatRunOnMain(^{
if (fromchatDelegate == nil) {
fromchatDelegate = [FromChatNotificationDelegate new];
}
center.delegate = fromchatDelegate;
NSLog(@"FromChat UN delegate set on %@", center);
});
}
static NSRunningApplication *fromchatOtherFrontApp(void) {
NSString *ours = [NSBundle mainBundle].bundleIdentifier;
pid_t ourPid = [NSRunningApplication currentApplication].processIdentifier;
NSRunningApplication *wsFront = [[NSWorkspace sharedWorkspace] frontmostApplication];
if (wsFront != nil && wsFront.processIdentifier != ourPid &&
(ours == nil || ![wsFront.bundleIdentifier isEqualToString:ours])) {
return wsFront;
}
CFArrayRef info = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
kCGNullWindowID
);
if (info == NULL) return nil;
NSArray *windows = CFBridgingRelease(info);
for (NSDictionary *win in windows) {
NSNumber *layer = win[(id)kCGWindowLayer];
if (layer == nil || layer.intValue != 0) continue;
NSNumber *pidNum = win[(id)kCGWindowOwnerPID];
if (pidNum == nil) continue;
pid_t pid = pidNum.intValue;
if (pid == ourPid) continue;
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if (app == nil) continue;
if (app.activationPolicy != NSApplicationActivationPolicyRegular) continue;
return app;
}
return nil;
}
static BOOL fromchatIsAppFrontmost(void) {
__block BOOL frontmost = NO;
fromchatRunOnMain(^{
NSString *frontId = [[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier;
NSString *ours = [NSBundle mainBundle].bundleIdentifier;
frontmost = frontId != nil && ours != nil && [frontId isEqualToString:ours];
NSLog(
@"FromChat frontmost=%d front=%@ ours=%@ nsAppActive=%d",
frontmost,
frontId,
ours,
[NSApp isActive]
);
});
return frontmost;
}
static void fromchatResignActive(void) {
fromchatRunOnMain(^{
[NSApp deactivate];
NSRunningApplication *other = fromchatOtherFrontApp();
if (other != nil) {
if (@available(macOS 14.0, *)) {
[NSApp yieldActivationToApplication:other];
} else {
[other activateWithOptions:NSApplicationActivateIgnoringOtherApps];
}
}
NSLog(
@"FromChat resignActive nsAppActive=%d front=%@",
[NSApp isActive],
[[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier
);
});
}
static void fromchatYieldIfNotFrontmost(void) {
fromchatResignActive();
}
static BOOL fromchatDeliverNotification(
UNUserNotificationCenter *center,
NSString *title,
NSString *body,
NSString *subtitle,
NSString *identifier,
BOOL playSound,
BOOL windowFocused
) {
if (center == nil) return NO;
fromchatEnsureDelegate(center);
if (windowFocused != YES) {
fromchatResignActive();
}
UNMutableNotificationContent *content = [UNMutableNotificationContent new];
content.title = title;
if (subtitle.length > 0) {
content.subtitle = subtitle;
}
content.body = body;
content.sound = playSound ? [UNNotificationSound defaultSound] : nil;
if (@available(macOS 12.0, *)) {
content.interruptionLevel = UNNotificationInterruptionLevelActive;
}
UNNotificationRequest *request =
[UNNotificationRequest requestWithIdentifier:identifier content:content trigger:nil];
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
__block BOOL ok = NO;
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
ok = error == nil;
NSLog(
@"FromChat UN add id=%@ ok=%d focused=%d nsAppActive=%d error=%@",
identifier,
ok,
windowFocused == YES,
[NSApp isActive],
error
);
dispatch_semaphore_signal(semaphore);
}];
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
return ok;
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
fromchatJvm = vm;
JNIEnv *env = NULL;
if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_8) == JNI_OK && env != NULL) {
jclass local = (*env)->FindClass(env, "ru/fromchat/desktop/MacNotificationCenter");
if (local != NULL) {
fromchatMacNotificationCenterClass = (*env)->NewGlobalRef(env, local);
(*env)->DeleteLocalRef(env, local);
}
}
return JNI_VERSION_1_8;
}
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRequestAuthorization(
JNIEnv *env,
jclass cls
) {
UNUserNotificationCenter *center = fromchatNotificationCenter();
if (center == nil) return JNI_FALSE;
fromchatEnsureDelegate(center);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
__block BOOL grantedResult = NO;
void (^request)(void) = ^{
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert |
UNAuthorizationOptionSound |
UNAuthorizationOptionBadge)
completionHandler:^(BOOL granted, NSError * _Nullable error) {
grantedResult = granted;
NSLog(@"FromChat UN auth granted=%d error=%@", granted, error);
dispatch_semaphore_signal(semaphore);
}];
};
if ([NSThread isMainThread]) {
request();
} else {
dispatch_async(dispatch_get_main_queue(), request);
}
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC));
return grantedResult ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jint JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeAuthorizationStatus(
JNIEnv *env,
jclass cls
) {
UNUserNotificationCenter *center = fromchatNotificationCenter();
if (center == nil) return 0;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
__block NSInteger status = 0;
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
status = settings.authorizationStatus;
if (@available(macOS 12.0, *)) {
NSLog(
@"FromChat UN status=%ld alert=%ld sound=%ld badge=%ld preview=%ld",
(long)settings.authorizationStatus,
(long)settings.alertSetting,
(long)settings.soundSetting,
(long)settings.badgeSetting,
(long)settings.showPreviewsSetting
);
} else {
NSLog(@"FromChat UN status=%ld alert=%ld sound=%ld badge=%ld",
(long)settings.authorizationStatus,
(long)settings.alertSetting,
(long)settings.soundSetting,
(long)settings.badgeSetting);
}
dispatch_semaphore_signal(semaphore);
}];
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
return (jint)status;
}
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeDeliver(
JNIEnv *env,
jclass cls,
jstring jTitle,
jstring jBody,
jstring jSubtitle,
jstring jIdentifier,
jboolean playSound,
jboolean windowFocused
) {
UNUserNotificationCenter *center = fromchatNotificationCenter();
if (center == nil) return JNI_FALSE;
const char *titleChars = (*env)->GetStringUTFChars(env, jTitle, NULL);
const char *bodyChars = (*env)->GetStringUTFChars(env, jBody, NULL);
const char *subtitleChars = jSubtitle ? (*env)->GetStringUTFChars(env, jSubtitle, NULL) : NULL;
const char *idChars = (*env)->GetStringUTFChars(env, jIdentifier, NULL);
NSString *title = titleChars ? [NSString stringWithUTF8String:titleChars] : @"";
NSString *body = bodyChars ? [NSString stringWithUTF8String:bodyChars] : @"";
NSString *subtitle = subtitleChars ? [NSString stringWithUTF8String:subtitleChars] : @"";
NSString *identifier = idChars ? [NSString stringWithUTF8String:idChars] : [[NSUUID UUID] UUIDString];
__block BOOL ok = NO;
void (^deliver)(void) = ^{
ok = fromchatDeliverNotification(
center,
title,
body,
subtitle,
identifier,
playSound == JNI_TRUE,
windowFocused
);
};
if ([NSThread isMainThread]) {
deliver();
} else {
dispatch_sync(dispatch_get_main_queue(), deliver);
}
if (titleChars) (*env)->ReleaseStringUTFChars(env, jTitle, titleChars);
if (bodyChars) (*env)->ReleaseStringUTFChars(env, jBody, bodyChars);
if (subtitleChars) (*env)->ReleaseStringUTFChars(env, jSubtitle, subtitleChars);
if (idChars) (*env)->ReleaseStringUTFChars(env, jIdentifier, idChars);
return ok ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRemove(
JNIEnv *env,
jclass cls,
jobjectArray jIdentifiers
) {
UNUserNotificationCenter *center = fromchatNotificationCenter();
if (center == nil || jIdentifiers == NULL) return;
jsize count = (*env)->GetArrayLength(env, jIdentifiers);
NSMutableArray<NSString *> *ids = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
for (jsize i = 0; i < count; i++) {
jstring jId = (*env)->GetObjectArrayElement(env, jIdentifiers, i);
if (jId == NULL) continue;
const char *chars = (*env)->GetStringUTFChars(env, jId, NULL);
if (chars) {
[ids addObject:[NSString stringWithUTF8String:chars]];
(*env)->ReleaseStringUTFChars(env, jId, chars);
}
(*env)->DeleteLocalRef(env, jId);
}
NSLog(@"FromChat UN remove count=%lu", (unsigned long)ids.count);
[center removeDeliveredNotificationsWithIdentifiers:ids];
[center removePendingNotificationRequestsWithIdentifiers:ids];
}
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRemoveAll(
JNIEnv *env,
jclass cls
) {
UNUserNotificationCenter *center = fromchatNotificationCenter();
if (center == nil) return;
NSLog(@"FromChat UN removeAll");
[center removeAllDeliveredNotifications];
[center removeAllPendingNotificationRequests];
}
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeOpenSettings(
JNIEnv *env,
jclass cls
) {
NSURL *url = [NSURL URLWithString:
@"x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=ru.fromchat.desktop"];
if (url == nil) return JNI_FALSE;
BOOL opened = [[NSWorkspace sharedWorkspace] openURL:url];
if (!opened) {
url = [NSURL URLWithString:@"x-apple.systempreferences:com.apple.preference.notifications"];
opened = url != nil && [[NSWorkspace sharedWorkspace] openURL:url];
}
return opened ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jstring JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeDebugInfo(
JNIEnv *env,
jclass cls
) {
__block NSString *info = @"";
fromchatRunOnMain(^{
NSBundle *bundle = [NSBundle mainBundle];
NSString *frontId = [[NSWorkspace sharedWorkspace] frontmostApplication].bundleIdentifier;
NSString *ours = bundle.bundleIdentifier;
BOOL frontmost = frontId != nil && ours != nil && [frontId isEqualToString:ours];
info = [NSString stringWithFormat:
@"bundlePath=%@ bundleId=%@ bundled=%d frontmost=%d front=%@ nsAppActive=%d",
bundle.bundlePath,
ours ?: @"(null)",
fromchatIsBundledApp() ? 1 : 0,
frontmost ? 1 : 0,
frontId ?: @"(null)",
[NSApp isActive] ? 1 : 0];
});
return (*env)->NewStringUTF(env, info.UTF8String);
}
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeIsAppFrontmost(
JNIEnv *env,
jclass cls
) {
return fromchatIsAppFrontmost() ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeResignActive(
JNIEnv *env,
jclass cls
) {
fromchatResignActive();
}
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeYieldActivation(
JNIEnv *env,
jclass cls
) {
fromchatYieldIfNotFrontmost();
}
JNIEXPORT jboolean JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeIsBundled(
JNIEnv *env,
jclass cls
) {
return fromchatIsBundledApp() ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_ru_fromchat_desktop_MacNotificationCenter_nativeRegisterBundle(
JNIEnv *env,
jclass cls
) {
if (!fromchatIsBundledApp()) return;
NSURL *url = [NSBundle mainBundle].bundleURL;
if (url == nil) return;
LSRegisterURL((__bridge CFURLRef)url, true);
}
@@ -1,27 +0,0 @@
package ru.fromchat.notifications
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import ru.fromchat.Logger
actual object ChatNotificationDismissals {
/** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
private const val SUMMARY_NOTIFICATION_ID = 1_000_000
@Volatile
private var appContext: Context? = null
fun install(context: Context) {
appContext = context.applicationContext
}
actual fun dismissAllMessageNotifications() {
val context = appContext ?: return
runCatching {
NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
}.onFailure {
Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
}
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.notifications
internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) = Unit
@@ -0,0 +1,222 @@
package ru.fromchat.notifications
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.pr0gramm3r101.utils.UtilsLibrary
import org.jetbrains.compose.resources.getString
import ru.fromchat.AppForeground
import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.notification_direct_message_from
import ru.fromchat.notification_reply
import ru.fromchat.notification_reply_hint
import ru.fromchat.public_chat
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
const val KEY_TEXT_REPLY = "key_text_reply"
private const val TAG = "MessageNotificationSink"
private const val CHANNEL_ID = "fromchat_messages"
private const val GROUP_PUBLIC = "ru.fromchat.notifications.public"
private const val GROUP_DM_PREFIX = "ru.fromchat.notifications.dm."
private const val EXTRA_NOTIFICATION_CHAT_TYPE = "notification_chat_type"
private const val EXTRA_OPEN_DM_USER_ID = "open_dm_user_id"
private const val EXTRA_REPLY_CHAT_TYPE = "notification_reply_chat_type"
private const val EXTRA_REPLY_DM_USER_ID = "notification_reply_dm_user_id"
private const val EXTRA_REPLY_PARENT_MESSAGE_ID = "notification_reply_parent_message_id"
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
internal actual object MessageNotificationSink {
actual fun areEnabled(): Boolean = true
actual fun shouldSuppressPublic(): Boolean =
AppForeground.isInForeground.value && isPublicChatVisible
actual fun shouldSuppressDm(peerUserId: Int): Boolean =
AppForeground.isInForeground.value && ActiveDmChatTracker.isActive(peerUserId)
actual suspend fun present(notification: PresentedMessageNotification) {
val context = appContext() ?: return
createChannel(context)
if (
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
Logger.w(TAG, "present skip: POST_NOTIFICATIONS missing")
return
}
val conversationTitle = if (notification.isDirectMessage) {
getString(Res.string.notification_direct_message_from, notification.senderName)
} else {
publicConversationTitle()
}
val groupKey = if (notification.isDirectMessage && notification.peerUserId != null) {
GROUP_DM_PREFIX + notification.peerUserId
} else {
GROUP_PUBLIC
}
val senderName = notification.senderName.ifBlank { "FromChat" }
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(
if (notification.isDirectMessage) senderName else conversationTitle
)
.setContentText(notification.body)
.setGroup(groupKey)
.setStyle(
NotificationCompat.MessagingStyle(Person.Builder().setName("FromChat").build())
.setConversationTitle(conversationTitle)
.setGroupConversation(true)
.addMessage(
NotificationCompat.MessagingStyle.Message(
notification.body,
System.currentTimeMillis(),
Person.Builder().setName(senderName).build(),
)
)
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setOnlyAlertOnce(notification.isUpdate)
.addAction(replyAction(context, notification))
.setContentIntent(contentIntent(context, notification))
.setShortcutId(groupKey)
if (!notification.isDirectMessage) {
builder.setLargeIcon(PublicChatNotificationAvatar.create(conversationTitle))
}
NotificationManagerCompat.from(context).notify(notification.androidNotifyId, builder.build())
Logger.i(
TAG,
"present id=${notification.identifier} androidId=${notification.androidNotifyId} " +
"update=${notification.isUpdate}",
)
}
actual fun dismiss(identifier: String) {
val context = appContext() ?: return
val notifyId = notifyIdFor(identifier)
NotificationManagerCompat.from(context).cancel(notifyId)
Logger.i(TAG, "dismiss identifier=$identifier androidId=$notifyId")
}
actual fun dismissAll() {
val context = appContext() ?: return
runCatching {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
NotificationManagerCompat.from(context).cancel(1_000_000)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.filter { it.notification.channelId == CHANNEL_ID }
.forEach { NotificationManagerCompat.from(context).cancel(it.tag, it.id) }
}
Logger.d(TAG, "dismissAll")
}.onFailure {
Logger.w(TAG, "dismissAll failed: ${it.message}", it)
}
}
actual fun refreshChrome() = Unit
}
private fun appContext(): Context? =
runCatching { UtilsLibrary.context }.getOrNull()
private fun notifyIdFor(identifier: String): Int = notificationAndroidId(identifier)
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "FromChat message notifications" }
)
}
private suspend fun publicConversationTitle(): String =
PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
?: runCatching {
PublicChatProfileCache.hydrateFromDiskImmediate(
CacheContext.activeInstanceId.value.trim(),
)?.title?.takeIf { it.isNotBlank() }
}.getOrNull()
?: getString(Res.string.public_chat)
private suspend fun replyAction(
context: Context,
notification: PresentedMessageNotification,
): NotificationCompat.Action {
val replyLabel = getString(Res.string.notification_reply)
val replyHint = getString(Res.string.notification_reply_hint)
return NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
replyLabel,
replyIntent(context, notification),
)
.addRemoteInput(RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyHint).build())
.setAllowGeneratedReplies(true)
.build()
}
private fun contentIntent(
context: Context,
notification: PresentedMessageNotification,
): PendingIntent {
val intent = Intent().setClassName(context.packageName, "ru.fromchat.MainActivity").apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_MESSAGE_ID, notification.messageId)
putExtra(
EXTRA_NOTIFICATION_CHAT_TYPE,
if (notification.isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC,
)
putExtra(EXTRA_OPEN_DM_USER_ID, notification.peerUserId ?: -1)
}
return PendingIntent.getActivity(
context,
notification.androidNotifyId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun replyIntent(
context: Context,
notification: PresentedMessageNotification,
): PendingIntent {
val intent = Intent().setClassName(
context.packageName,
"ru.fromchat.notifications.NotificationReplyReceiver",
).apply {
action = "${context.packageName}.NOTIFICATION_REPLY"
putExtra("notification_id", notification.androidNotifyId)
putExtra(EXTRA_REPLY_CHAT_TYPE, if (notification.isDirectMessage) CHAT_TYPE_DM else CHAT_TYPE_PUBLIC)
putExtra(EXTRA_REPLY_DM_USER_ID, notification.peerUserId ?: -1)
putExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, notification.messageId)
}
return PendingIntent.getBroadcast(
context,
notification.androidNotifyId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)
}
@@ -43,3 +43,11 @@ actual fun areAppNotificationsEnabled(): Boolean {
}
actual fun arePushNotificationsSupported(): Boolean = true
actual fun areDesktopMessageNotificationsSupported(): Boolean = false
actual fun areDesktopMessageNotificationsEnabled(): Boolean = false
actual fun setDesktopMessageNotificationsEnabled(enabled: Boolean) = Unit
actual fun requestDesktopNotificationPermission(): Boolean = true
@@ -349,6 +349,15 @@
<string name="settings_push_notifications">Push-уведомления</string>
<string name="settings_push_notifications_d">Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто.</string>
<string name="settings_push_notifications_unavailable">Недоступны на этой платформе. Новые сообщения видны только пока приложение открыто.</string>
<string name="settings_desktop_notifications">Уведомления о сообщениях</string>
<string name="settings_desktop_notifications_d">Показывать оповещения в системном трее, когда окно скрыто или вы в другом чате.</string>
<string name="notification_direct_message">Личное сообщение</string>
<string name="notification_direct_message_from">Личное сообщение от %1$s</string>
<string name="notification_new_messages">%1$d новых сообщений</string>
<string name="notification_new_messages_in">%1$s: %2$s</string>
<string name="notification_reply">Ответить</string>
<string name="notification_reply_hint">Ответ в чат…</string>
<string name="settings_devices_title">Устройства</string>
<string name="settings_devices_empty">Нет активных сессий</string>
@@ -380,6 +380,15 @@
<string name="settings_push_notifications">Push notifications</string>
<string name="settings_push_notifications_d">These will let you know if you got a new message when the app is closed.</string>
<string name="settings_push_notifications_unavailable">Not available on this platform. New messages are only visible while the app is open.</string>
<string name="settings_desktop_notifications">Message notifications</string>
<string name="settings_desktop_notifications_d">Show alerts in the system tray when the window is hidden or you are in another 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_new_messages">%1$d new messages</string>
<string name="notification_new_messages_in">%1$s: %2$s</string>
<string name="notification_reply">Reply</string>
<string name="notification_reply_hint">Reply to chat…</string>
<string name="settings_devices_title">Devices</string>
<string name="settings_devices_empty">No active sessions</string>
@@ -8,7 +8,16 @@ object AppForeground {
private val _isInForeground = MutableStateFlow(true)
val isInForeground: StateFlow<Boolean> = _isInForeground.asStateFlow()
private val _isWindowFocused = MutableStateFlow(true)
val isWindowFocused: StateFlow<Boolean> = _isWindowFocused.asStateFlow()
fun setForeground(inForeground: Boolean) {
_isInForeground.value = inForeground
if (!inForeground) _isWindowFocused.value = false
}
fun setWindowFocused(focused: Boolean) {
_isWindowFocused.value = focused
if (focused) _isInForeground.value = true
}
}
@@ -614,11 +614,11 @@ object ApiClient {
}
.body()
suspend fun markMessagesRead(messageIds: List<Int>) {
if (messageIds.isEmpty()) return
suspend fun markMessagesRead(messageIds: List<Int> = emptyList(), markAll: Boolean = false) {
if (!markAll && messageIds.isEmpty()) return
http.post("${ServerConfig.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json)
setBody(MarkReadRequest(messageIds = messageIds))
setBody(MarkReadRequest(messageIds = messageIds, markAll = markAll))
}
}
@@ -751,10 +751,14 @@ object ApiClient {
.body<DmConversationsResponse>()
.conversations
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
suspend fun markDmConversationRead(
otherUserId: Int,
messageIds: List<Int>? = null,
markAll: Boolean = false,
) {
http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/read") {
contentType(ContentType.Application.Json)
setBody(DmMarkReadRequest(upToEnvelopeId = upToEnvelopeId))
setBody(DmMarkReadRequest(messageIds = messageIds, markAll = markAll))
}
}
@@ -19,6 +19,7 @@ import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.Logger
import ru.fromchat.config.ServerConfig
import ru.fromchat.notifications.notifyIncomingCallIfBackground
private const val TAG = "CallStore"
@@ -115,6 +116,8 @@ object CallStore {
fromUsername = fromUsername,
roomName = roomName,
)
val callerLabel = peerLabel(fromUserId).ifBlank { fromUsername }
notifyIncomingCallIfBackground(callerLabel)
}
fun startOutgoingCall(peerUserId: Int) {
@@ -65,23 +65,26 @@ object WebSocketManager {
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
val messages = _messages.asSharedFlow()
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
private val sessionReadyHandlers = mutableListOf<suspend () -> Unit>()
@Volatile
private var globalHandlers: List<((WebSocketMessage) -> Unit)> = emptyList()
@Volatile
private var sessionReadyHandlers: List<suspend () -> Unit> = emptyList()
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
globalHandlers += handler
globalHandlers = globalHandlers + handler
}
fun removeGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
globalHandlers -= handler
globalHandlers = globalHandlers - handler
}
fun addSessionReadyHandler(handler: suspend () -> Unit) {
sessionReadyHandlers += handler
sessionReadyHandlers = sessionReadyHandlers + handler
}
fun removeSessionReadyHandler(handler: suspend () -> Unit) {
sessionReadyHandlers -= handler
sessionReadyHandlers = sessionReadyHandlers - handler
}
private fun notifySessionReady() {
@@ -258,7 +261,11 @@ object WebSocketManager {
}
}
globalHandlers.forEach { it(msg) }
globalHandlers.forEach { handler ->
runCatching { handler(msg) }.onFailure {
logW("Global handler failed: ${it.message}", it)
}
}
_messages.emit(msg)
} catch (e: Throwable) {
logW("Received malformed payload: ${e.message}", e)
@@ -140,15 +140,16 @@ object MessageCacheStore {
): ChatListPreviewState? {
if (instanceId.isBlank()) return null
val convId = conversationIdForPublic()
val recent = resolvePreviewSourceMessageRow(instanceId, convId) ?: return null
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
?: return null
return buildChatListPreviewState(message, strings, ApiClient.user?.id)
.let { state ->
state.copy(
text = state.text?.trim()?.takeIf { it.isNotEmpty() },
)
val recent = resolvePreviewSourceMessageRow(instanceId, convId)
val message = recent?.toAppMessage()?.let {
enrichQueuedOutboundUi(listOf(it), convId).firstOrNull()
}
val base = message?.let {
buildChatListPreviewState(it, strings, ApiClient.user?.id).let { state ->
state.copy(text = state.text?.trim()?.takeIf { text -> text.isNotEmpty() })
}
} ?: ChatListPreviewState(text = null)
return withPublicUnread(base, instanceId, convId)
}
suspend fun loadRecentPublicChatPreviewState(
@@ -157,15 +158,16 @@ object MessageCacheStore {
): ChatListPreviewState? = withContext(Dispatchers.Default) {
val convId = conversationIdForPublic()
val iid = instanceId()
val recent = resolvePreviewSourceMessageRow(iid, convId) ?: return@withContext null
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
?: return@withContext null
buildChatListPreviewState(message, strings, ApiClient.user?.id)
.let { state ->
state.copy(
text = state.text?.trim()?.takeIf { it.isNotEmpty() },
)
val recent = resolvePreviewSourceMessageRow(iid, convId)
val message = recent?.toAppMessage()?.let {
enrichQueuedOutboundUi(listOf(it), convId).firstOrNull()
}
val base = message?.let {
buildChatListPreviewState(it, strings, ApiClient.user?.id).let { state ->
state.copy(text = state.text?.trim()?.takeIf { text -> text.isNotEmpty() })
}
} ?: ChatListPreviewState(text = null)
withPublicUnread(base, iid, convId)
}
suspend fun replacePublicMessages(messages: List<Message>, replaceAll: Boolean = false) {
@@ -497,10 +499,6 @@ object MessageCacheStore {
else -> conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim()
}
val localUnread = db.messageDatabaseQueries
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
.executeAsOne()
.toInt()
UpsertDmConversationRow(
conversationId = conversationId,
otherUserId = conv.user.id,
@@ -511,7 +509,12 @@ object MessageCacheStore {
currentUserId,
previewStrings,
),
unreadCount = maxOf(conv.unreadCount, localUnread),
unreadCount = localDmUnreadCount(
iid,
conversationId,
conv.user.id,
conv.unreadCount,
),
updatedAt = conv.lastMessage.timestamp,
)
}
@@ -697,23 +700,20 @@ object MessageCacheStore {
}
}
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
suspend fun markDmConversationReadLocally(otherUserId: Int, messageIds: List<Int>? = null) {
val iid = instanceId()
val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) {
if (upToEnvelopeId != null && upToEnvelopeId > 0) {
db.messageDatabaseQueries.markInboundDmMessagesReadUpTo(
instanceId = iid,
conversationId = convId,
userId = otherUserId.toLong(),
id = upToEnvelopeId.toLong(),
)
} else {
if (messageIds.isNullOrEmpty()) {
db.messageDatabaseQueries.markAllInboundDmMessagesRead(
instanceId = iid,
conversationId = convId,
userId = otherUserId.toLong(),
)
} else {
messageIds.filter { it > 0 }.forEach { id ->
db.messageDatabaseQueries.markPublicMessageRead(iid, convId, id.toLong())
}
}
val unreadCount = db.messageDatabaseQueries
.countUnreadInboundDmMessages(iid, convId, otherUserId.toLong())
@@ -738,11 +738,29 @@ object MessageCacheStore {
}
}
suspend fun markPublicMessagesReadLocally() {
suspend fun isPublicMessageRead(messageId: Int): Boolean {
if (messageId <= 0) return true
val iid = instanceId()
val convId = conversationIdForPublic()
return withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectMessageById(iid, convId, messageId.toLong())
.executeAsOneOrNull()
?.isRead == 1L
}
}
suspend fun markPublicMessagesReadLocally(messageIds: List<Int>? = null) {
val iid = instanceId()
val convId = conversationIdForPublic()
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
if (messageIds == null) {
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
} else {
messageIds.filter { it > 0 }.forEach { id ->
db.messageDatabaseQueries.markPublicMessageRead(iid, convId, id.toLong())
}
}
}
}
@@ -860,7 +878,12 @@ object MessageCacheStore {
lastMessagePendingIndicator = previewState?.pendingIndicator
?: ChatListPreviewPendingIndicator.None,
lastMessageUploadProgress = previewState?.uploadProgress,
unreadCount = row.unreadCount.toInt(),
unreadCount = localDmUnreadCount(
instanceId,
row.id,
row.otherUserId?.toInt() ?: 0,
row.unreadCount.toInt(),
),
)
}
}
@@ -931,6 +954,37 @@ object MessageCacheStore {
}
}
private fun withPublicUnread(
state: ChatListPreviewState,
instanceId: String,
conversationId: String,
): ChatListPreviewState {
val selfId = ApiClient.user?.id?.toLong() ?: return state
return state.copy(
unreadCount = db.messageDatabaseQueries
.countUnreadPublicInboundMessages(instanceId, conversationId, selfId)
.executeAsOne()
.toInt(),
)
}
private fun localDmUnreadCount(
instanceId: String,
conversationId: String,
otherUserId: Int,
serverUnread: Int,
): Int {
if (otherUserId <= 0) return serverUnread.coerceAtLeast(0)
val inbound = db.messageDatabaseQueries
.countInboundDmMessages(instanceId, conversationId, otherUserId.toLong())
.executeAsOne()
if (inbound <= 0L) return serverUnread.coerceAtLeast(0)
return db.messageDatabaseQueries
.countUnreadInboundDmMessages(instanceId, conversationId, otherUserId.toLong())
.executeAsOne()
.toInt()
}
private fun resolvePreviewSourceMessageRow(
instanceId: String,
conversationId: String,
@@ -1061,10 +1115,9 @@ object MessageCacheStore {
private suspend fun upsertSingle(conversationId: String, msg: Message) {
val iid = instanceId()
withContext(Dispatchers.Default) {
val existingReplyToId = db.messageDatabaseQueries
val existing = db.messageDatabaseQueries
.selectMessageById(iid, conversationId, msg.id.toLong())
.executeAsOneOrNull()
?.replyToId
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
id = msg.id.toLong(),
@@ -1072,9 +1125,9 @@ object MessageCacheStore {
userId = msg.user_id.toLong(),
content = storedMessageContent(msg),
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isRead = mergedIsRead(existing?.isRead, msg.is_read),
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToId),
replyToId = resolveReplyToIdForPersistence(msg, existing?.replyToId),
clientMessageId = msg.client_message_id,
deletedFlag = 0L,
sendStatus = if (msg.id < 0) "pending" else "sent"
@@ -1092,11 +1145,10 @@ object MessageCacheStore {
}
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction {
val existingReplyToId = db.messageDatabaseQueries
val existing = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.firstOrNull { it.clientMessageId == clientMessageId }
?.replyToId
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
@@ -1105,9 +1157,9 @@ object MessageCacheStore {
userId = confirmed.user_id.toLong(),
content = storedContent,
timestamp = confirmed.timestamp,
isRead = if (confirmed.is_read) 1L else 0L,
isRead = mergedIsRead(existing?.isRead, confirmed.is_read),
isEdited = if (confirmed.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(confirmed, existingReplyToId),
replyToId = resolveReplyToIdForPersistence(confirmed, existing?.replyToId),
clientMessageId = confirmed.client_message_id,
deletedFlag = 0L,
sendStatus = "sent"
@@ -1331,6 +1383,9 @@ object MessageCacheStore {
?: existingReplyToId?.takeIf { it > 0L }
}
private fun mergedIsRead(existingIsRead: Long?, incomingIsRead: Boolean): Long =
if (existingIsRead == 1L || incomingIsRead) 1L else 0L
private fun DbMessage.toAppMessage(): Message {
val uid = userId.toInt()
val self = ApiClient.user
@@ -1464,10 +1519,13 @@ object MessageCacheStore {
"incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
)
withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries
val existingRows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.associate { it.id.toInt() to it.replyToId }
val existingReplyToIds = existingRows.associate { it.id.toInt() to it.replyToId }
val existingReadIds = existingRows.mapNotNull { row ->
row.id.toInt().takeIf { row.isRead == 1L }
}.toSet()
if (replaceAll) {
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
@@ -1479,7 +1537,10 @@ object MessageCacheStore {
userId = msg.user_id.toLong(),
content = storedMessageContent(msg),
timestamp = msg.timestamp,
isRead = if (msg.is_read) 1L else 0L,
isRead = mergedIsRead(
if (msg.id in existingReadIds) 1L else 0L,
msg.is_read,
),
isEdited = if (msg.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
clientMessageId = msg.client_message_id,
@@ -2,6 +2,8 @@ package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings
@@ -11,11 +13,16 @@ import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.notifications.ChatNotificationDismissals
/**
* Instance-scoped message access for UI and send pipeline.
*/
object MessageRepository {
private val visibleReadMutex = Mutex()
private val sentPublicReadIds = mutableSetOf<Int>()
private val sentDmReadIds = mutableSetOf<Int>()
private fun activeInstance(): String = CacheContext.requireActiveInstanceId()
fun observeMessages(conversationId: String): Flow<List<Message>> =
@@ -139,31 +146,84 @@ object MessageRepository {
suspend fun patchDmConversationPeerProfile(otherUserId: Int) =
MessageCacheStore.patchDmConversationPeerProfile(otherUserId)
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
if (upToEnvelopeId <= 0) return
val convId = conversationIdForDm(otherUserId)
val alreadyRead = MessageCacheStore.isInboundDmMessageRead(otherUserId, upToEnvelopeId)
if (alreadyRead) return
markDmConversationRead(otherUserId, upToEnvelopeId)
suspend fun markDmConversationRead(otherUserId: Int, messageIds: List<Int>? = null) {
runCatching {
ApiClient.markDmConversationRead(
otherUserId,
messageIds = messageIds,
markAll = messageIds.isNullOrEmpty(),
)
}
MessageCacheStore.markDmConversationReadLocally(otherUserId, messageIds)
if (!messageIds.isNullOrEmpty()) {
sentDmReadIds.addAll(messageIds.filter { it > 0 })
}
ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun markPublicConversationRead() {
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
val serverIds = runCatching {
ApiClient.getNewMessages().messages.map { it.id }
}.getOrDefault(emptyList())
val ids = (localIds + serverIds).distinct()
if (ids.isNotEmpty()) {
runCatching { ApiClient.markMessagesRead(ids) }
}
runCatching { ApiClient.markMessagesRead(markAll = true) }
MessageCacheStore.markPublicMessagesReadLocally()
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
ChatNotificationDismissals.dismissAllMessageNotifications()
}
/**
* Marks messages that are on-screen as read and reports those ids to the server once.
* Off-screen unread messages in the same conversation are left unread.
*/
suspend fun markVisibleMessagesRead(
peerUserId: Int?,
visibleMessageIds: Set<Int>,
currentUserId: Int?,
messages: List<Message>,
) {
if (visibleMessageIds.isEmpty()) return
visibleReadMutex.withLock {
val ownId = currentUserId
val visibleInboundIds = messages
.filter { message ->
message.id > 0 &&
message.id in visibleMessageIds &&
(ownId == null || message.user_id != ownId) &&
(peerUserId == null || message.user_id == peerUserId)
}
.map { it.id }
if (peerUserId != null && peerUserId > 0) {
markVisibleDmRead(peerUserId, visibleInboundIds)
} else {
markVisiblePublicRead(visibleInboundIds)
}
}
}
private suspend fun markVisibleDmRead(otherUserId: Int, messageIds: List<Int>) {
val toSend = messageIds.filter { it > 0 }.distinct().filter { id ->
id !in sentDmReadIds && !MessageCacheStore.isInboundDmMessageRead(otherUserId, id)
}
if (toSend.isEmpty()) return
sentDmReadIds.addAll(toSend)
runCatching { ApiClient.markDmConversationRead(otherUserId, messageIds = toSend) }
.onSuccess {
MessageCacheStore.markDmConversationReadLocally(otherUserId, toSend)
ChatNotificationDismissals.dismissDmIfMessageRead(otherUserId, toSend)
}
.onFailure { sentDmReadIds.removeAll(toSend.toSet()) }
}
private suspend fun markVisiblePublicRead(messageIds: List<Int>) {
val distinct = messageIds.filter { it > 0 }.distinct()
if (distinct.isEmpty()) return
val toSend = distinct.filter { id ->
id !in sentPublicReadIds && !MessageCacheStore.isPublicMessageRead(id)
}
if (toSend.isEmpty()) return
sentPublicReadIds.addAll(toSend)
runCatching { ApiClient.markMessagesRead(toSend) }
.onSuccess {
MessageCacheStore.markPublicMessagesReadLocally(toSend)
ChatNotificationDismissals.dismissPublicIfMessageRead(toSend)
}
.onFailure { sentPublicReadIds.removeAll(toSend.toSet()) }
}
suspend fun archiveDmConversation(otherUserId: Int) =
@@ -185,5 +245,7 @@ object MessageRepository {
fun resetListPreviewStringsOnLogout() {
MessageCacheStore.listPreviewStrings = null
sentPublicReadIds.clear()
sentDmReadIds.clear()
}
}
@@ -18,6 +18,7 @@ data class ChatListPreviewState(
val text: String?,
val pendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None,
val uploadProgress: Int? = null,
val unreadCount: Int = 0,
) {
fun displayText(default: String): String =
text?.trim()?.takeIf { it.isNotEmpty() } ?: default
@@ -8,7 +8,6 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.types.DmDeletedData
@@ -54,9 +53,7 @@ object DmInboundMessageProcessor {
MessageRepository.upsertDmMessage(otherUserId, hydrated)
}
} else {
val isRead = ActiveDmChatTracker.isActive(otherUserId)
val inbound = hydrated.copy(is_read = isRead)
MessageRepository.upsertDmMessage(otherUserId, inbound)
MessageRepository.upsertDmMessage(otherUserId, hydrated)
}
}
}
@@ -176,7 +173,7 @@ object DmInboundMessageProcessor {
user_id = envelope.senderId,
content = dec.text,
timestamp = envelope.timestamp,
is_read = envelope.senderId == currentUserId,
is_read = envelope.isReadByViewer(currentUserId),
is_edited = false,
username = username,
displayName = displayName,
@@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class MarkReadRequest(
val messageIds: List<Int>,
val messageIds: List<Int> = emptyList(),
val markAll: Boolean = false,
)
@@ -16,5 +16,9 @@ data class DmEnvelope(
val timestamp: String,
@SerialName("client_message_id") val clientMessageId: String? = null,
@SerialName("reply_to_id") val replyToId: Int? = null,
val files: List<DmFile>? = null
)
val files: List<DmFile>? = null,
val isRead: Boolean? = null,
) {
fun isReadByViewer(currentUserId: Int?): Boolean =
(currentUserId != null && senderId == currentUserId) || isRead == true
}
@@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class DmMarkReadRequest(
val upToEnvelopeId: Int? = null,
val messageIds: List<Int>? = null,
val markAll: Boolean = false,
)
@@ -1,6 +1,12 @@
package ru.fromchat.notifications
/** Platform bridge so shared mark-read can dismiss message notifications. */
expect object ChatNotificationDismissals {
fun dismissAllMessageNotifications()
/** Shared mark-read calls this so every platform can drop message notifications. */
object ChatNotificationDismissals {
fun dismissAllMessageNotifications() = MessageNotificationCoordinator.dismissAll()
fun dismissPublicIfMessageRead(messageIds: Collection<Int>) =
MessageNotificationCoordinator.dismissPublicIfMessageRead(messageIds)
fun dismissDmIfMessageRead(peerUserId: Int, messageIds: Collection<Int>) =
MessageNotificationCoordinator.dismissDmIfMessageRead(peerUserId, messageIds)
}
@@ -0,0 +1,4 @@
package ru.fromchat.notifications
/** Platform hook when an incoming call arrives while the app may be in the background. */
internal expect fun notifyIncomingCallIfBackground(callerDisplayName: String)
@@ -0,0 +1,508 @@
package ru.fromchat.notifications
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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 kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.getString
import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
import ru.fromchat.api.crypto.DmCiphertextCorruptedException
import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji
private const val TAG = "MessageNotificationCoordinator"
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 PUBLIC_FETCH_DEBOUNCE_MS = 450L
private const val BANNER_STAGGER_MS = 350L
/**
* Shared WebSocket / fetch pipeline for message notifications.
* Platforms only implement [MessageNotificationSink].
*/
object MessageNotificationCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val publicFetchMutex = Mutex()
private val activeMutex = Mutex()
private val displayedMessageIds = mutableMapOf<String, Int>()
private val recentlyDeleted = ArrayDeque<String>()
private var publicFetchJob: Job? = null
private var installed = false
private val webSocketHandler: (WebSocketMessage) -> Unit = { msg ->
scope.launch {
runCatching { handleWebSocketMessage(msg) }
.onFailure { Logger.e(TAG, "WebSocket handler failed", it) }
}
}
fun install() {
if (installed) {
Logger.i(TAG, "install skipped: already installed")
return
}
installed = true
WebSocketManager.addGlobalMessageHandler(webSocketHandler)
MessageNotificationSink.dismiss(PUBLIC_NOTIFICATION_SLOT)
Logger.i(TAG, "installed enabled=${MessageNotificationSink.areEnabled()}")
}
fun refreshChrome() = MessageNotificationSink.refreshChrome()
fun uninstall() {
if (!installed) return
installed = false
WebSocketManager.removeGlobalMessageHandler(webSocketHandler)
publicFetchJob?.cancel()
Logger.i(TAG, "uninstalled")
}
fun dismissAll() {
scope.launch {
activeMutex.withLock {
displayedMessageIds.clear()
}
MessageNotificationSink.dismissAll()
MessageNotificationSink.refreshChrome()
}
}
fun dismissPublicIfMessageRead(messageIds: Collection<Int>) {
if (messageIds.isEmpty()) return
scope.launch {
messageIds.forEach { dismiss(publicNotificationId(it)) }
val leftover = activeMutex.withLock { displayedMessageIds[PUBLIC_NOTIFICATION_SLOT] }
if (leftover != null && leftover in messageIds) {
dismiss(PUBLIC_NOTIFICATION_SLOT)
}
MessageNotificationSink.refreshChrome()
}
}
fun dismissDmIfMessageRead(peerUserId: Int, messageIds: Collection<Int>) {
if (peerUserId <= 0 || messageIds.isEmpty()) return
scope.launch {
messageIds.forEach { dismiss(dmNotificationId(peerUserId, it)) }
val slot = dmNotificationSlot(peerUserId)
val leftover = activeMutex.withLock { displayedMessageIds[slot] }
if (leftover != null && leftover in messageIds) {
dismiss(slot)
}
MessageNotificationSink.refreshChrome()
}
}
fun schedulePublicFetchAndNotify() {
Logger.i(TAG, "schedule public fetch in ${PUBLIC_FETCH_DEBOUNCE_MS}ms")
publicFetchJob?.cancel()
publicFetchJob = scope.launch {
delay(PUBLIC_FETCH_DEBOUNCE_MS)
publicFetchMutex.withLock {
fetchAndNotify(includeDmMessages = false)
}
}
}
suspend fun fetchAndNotify(
includeDmMessages: Boolean = false,
dmMessageId: Int? = null,
) {
if (!MessageNotificationSink.areEnabled()) {
Logger.i(TAG, "fetchAndNotify skip: disabled")
return
}
val currentUserId = settings.getInt("current_user_id", -1)
if (currentUserId <= 0) {
Logger.i(TAG, "fetchAndNotify skip: no current_user_id")
return
}
try {
val fetched = ApiClient.getNewMessages().messages
val messages = fetched.filter { it.user_id != currentUserId }
Logger.i(
TAG,
"fetchAndNotify /messages/new count=${fetched.size} afterOwnFilter=${messages.size} " +
"ids=${fetched.joinToString { "${it.id}/u${it.user_id}" }} " +
"includeDm=$includeDmMessages dmMessageId=$dmMessageId",
)
if (messages.isNotEmpty()) {
displayPublicNotifications(messages, currentUserId)
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(currentUserId, dmMessageId)
}
MessageNotificationSink.refreshChrome()
} catch (e: Exception) {
if (e is ClientRequestException && e.response.status.value == 401) {
runCatching {
ApiClient.loadPersistedData()
val retryUserId = settings.getInt("current_user_id", -1)
val retryMessages = ApiClient.getNewMessages()
.messages
.filter { it.user_id != retryUserId }
if (retryMessages.isNotEmpty()) {
displayPublicNotifications(retryMessages, retryUserId)
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(retryUserId, dmMessageId)
}
MessageNotificationSink.refreshChrome()
}.onFailure {
Logger.e(TAG, "fetchAndNotify retry failed", it)
}
return
}
Logger.e(TAG, "fetchAndNotify failed: ${e.message}", e)
}
}
private suspend fun handleWebSocketMessage(msg: WebSocketMessage) {
val enabled = MessageNotificationSink.areEnabled()
val currentUserId = settings.getInt("current_user_id", -1)
val data = msg.data?.jsonObject
if (!enabled) return
if (currentUserId <= 0) return
fun isOwnPublic(payload: JsonObject?) = jsonInt(payload, "user_id") == currentUserId
fun isOwnDm(payload: JsonObject?) = jsonInt(payload, "senderId") == currentUserId
when (msg.type) {
"newMessage" -> {
if (!isOwnPublic(data)) schedulePublicFetchAndNotify()
}
"dmNew" -> {
if (!isOwnDm(data)) {
fetchAndNotify(includeDmMessages = true, dmMessageId = jsonInt(data, "id"))
}
}
"messageEdited" -> msg.data?.let { handlePublicEdited(it, currentUserId) }
"messageDeleted" -> msg.data?.let { handlePublicDeleted(it) }
"dmEdited" -> msg.data?.let { handleDmEdited(it, currentUserId) }
"dmDeleted" -> msg.data?.let { handleDmDeleted(it) }
"updates" -> handleUpdates(data, currentUserId, ::isOwnPublic, ::isOwnDm)
else -> Unit
}
}
private suspend fun handleUpdates(
data: JsonObject?,
currentUserId: Int,
isOwnPublic: (JsonObject?) -> Boolean,
isOwnDm: (JsonObject?) -> Boolean,
) {
val updates = data?.get("updates")?.jsonArray ?: return
var shouldFetchPublic = false
var shouldFetchDm = false
var latestDmMessageId: Int? = null
for (item in updates) {
val obj = item.jsonObject
val type = obj["type"]?.jsonPrimitive?.content
val payload = obj["data"]?.jsonObject
when (type) {
"newMessage" -> if (!isOwnPublic(payload)) shouldFetchPublic = true
"dmNew" -> if (!isOwnDm(payload)) {
shouldFetchDm = true
jsonInt(payload, "id")?.let { id ->
latestDmMessageId = id.coerceAtLeast(latestDmMessageId ?: 0)
}
}
"messageEdited" -> payload?.let { handlePublicEdited(it, currentUserId) }
"messageDeleted" -> payload?.let { handlePublicDeleted(it) }
"dmEdited" -> payload?.let { handleDmEdited(it, currentUserId) }
"dmDeleted" -> payload?.let { handleDmDeleted(it) }
}
}
if (shouldFetchPublic) schedulePublicFetchAndNotify()
if (shouldFetchDm) {
fetchAndNotify(includeDmMessages = true, dmMessageId = latestDmMessageId)
}
}
private suspend fun handlePublicEdited(element: JsonElement, currentUserId: Int) {
val message = decodeMessage(element) ?: return
val identifier = publicNotificationId(message.id)
if (!isDisplaying(identifier, message.id) &&
!isDisplaying(PUBLIC_NOTIFICATION_SLOT, message.id)
) {
return
}
if (MessageNotificationSink.shouldSuppressPublic()) {
dismiss(identifier)
dismiss(PUBLIC_NOTIFICATION_SLOT)
return
}
present(
publicNotification(message, currentUserId, previewStrings(), isUpdate = true),
)
}
private suspend fun handlePublicDeleted(element: JsonElement) {
val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
}.getOrNull() ?: return
rememberDeleted("public:${deleted.message_id}")
dismiss(publicNotificationId(deleted.message_id))
if (isDisplaying(PUBLIC_NOTIFICATION_SLOT, deleted.message_id)) {
dismiss(PUBLIC_NOTIFICATION_SLOT)
}
}
private suspend fun handleDmEdited(element: JsonElement, currentUserId: Int) {
val envelope = decodeDmEnvelope(element) ?: return
val identifier = dmNotificationId(envelope.senderId, envelope.id)
val slot = dmNotificationSlot(envelope.senderId)
if (!isDisplaying(identifier, envelope.id) && !isDisplaying(slot, envelope.id)) return
if (MessageNotificationSink.shouldSuppressDm(envelope.senderId)) {
dismiss(identifier)
dismiss(slot)
return
}
present(dmNotification(envelope, currentUserId, previewStrings(), isUpdate = true) ?: return)
}
private suspend fun handleDmDeleted(element: JsonElement) {
val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
}.getOrNull() ?: return
rememberDeleted("dm:${deleted.id}")
val peerId = deleted.senderId
dismiss(dmNotificationId(peerId, deleted.id))
val slot = dmNotificationSlot(peerId)
if (isDisplaying(slot, deleted.id)) {
dismiss(slot)
}
}
private suspend fun displayPublicNotifications(messages: List<Message>, currentUserId: Int) {
if (MessageNotificationSink.shouldSuppressPublic()) {
Logger.i(TAG, "displayPublic skip: suppress incomingIds=${messages.map { it.id }}")
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
messages.forEach { shown.add(it.id.toString()) }
settings.putStringSet(PREF_SHOWN_KEY, shown)
return
}
val previewStrings = previewStrings()
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
val newMessages = messages
.filter { msg ->
!shown.contains(msg.id.toString()) &&
msg.user_id != currentUserId &&
"public:${msg.id}" !in recentlyDeleted
}
.sortedBy { it.id }
if (newMessages.isEmpty()) {
Logger.i(TAG, "displayPublic skip: already shown, own, or deleted")
return
}
newMessages.forEach { shown.add(it.id.toString()) }
settings.putStringSet(PREF_SHOWN_KEY, shown)
presentAll(
newMessages.map { message ->
publicNotification(message, currentUserId, previewStrings, isUpdate = false)
},
)
}
private suspend fun fetchAndNotifyDirectMessages(currentUserId: Int, dmMessageId: Int? = null) {
val storedLastDmMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
val sinceId = when {
dmMessageId != null && dmMessageId > storedLastDmMessageId -> dmMessageId - 1
storedLastDmMessageId > 0 -> storedLastDmMessageId
else -> null
} ?: return
val response = runCatching {
ApiClient.getDmFetch(sinceId)
}.getOrElse { throwable ->
if (throwable is ClientRequestException && throwable.response.status.value == 401) {
throw throwable
}
Logger.e(TAG, "DM fetch failed: ${throwable.message}", throwable)
return
}
val dmMessages = response.messages
if (dmMessages.isEmpty()) return
val previewStrings = previewStrings()
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
val toPresent = buildList {
dmMessages
.filter { envelope -> envelope.id > 0 && envelope.senderId != currentUserId }
.forEach { envelope ->
val shownDmKey = "dm:${envelope.id}"
if (shownDm.contains(shownDmKey) || envelope.id <= latestMessageId) return@forEach
if ("dm:${envelope.id}" in recentlyDeleted) return@forEach
if (MessageNotificationSink.shouldSuppressDm(envelope.senderId)) {
shownDm.add(shownDmKey)
return@forEach
}
val notification = dmNotification(
envelope,
currentUserId,
previewStrings,
isUpdate = false,
) ?: return@forEach
shownDm.add(shownDmKey)
add(notification)
}
}
presentAll(toPresent)
val newMaxDmId = dmMessages.maxOfOrNull { it.id } ?: 0
if (newMaxDmId > latestMessageId) {
settings.putInt(PREF_LAST_DM_MESSAGE_ID, newMaxDmId)
}
settings.putStringSet(PREF_SHOWN_DM_KEY, shownDm)
}
private suspend fun presentAll(notifications: List<PresentedMessageNotification>) {
notifications.forEachIndexed { index, notification ->
if (index > 0) delay(BANNER_STAGGER_MS)
present(notification)
}
}
private suspend fun present(notification: PresentedMessageNotification) {
MessageNotificationSink.present(notification)
activeMutex.withLock {
displayedMessageIds[notification.identifier] = notification.messageId
}
}
private suspend fun dismiss(identifier: String) {
activeMutex.withLock {
displayedMessageIds.remove(identifier)
}
MessageNotificationSink.dismiss(identifier)
}
private suspend fun isDisplaying(identifier: String, messageId: Int): Boolean =
activeMutex.withLock { displayedMessageIds[identifier] == messageId }
private fun rememberDeleted(identifier: String) {
recentlyDeleted.addLast(identifier)
while (recentlyDeleted.size > 50) recentlyDeleted.removeFirst()
}
private fun publicNotification(
message: Message,
currentUserId: Int,
previewStrings: ChatListPreviewStrings,
isUpdate: Boolean,
) = PresentedMessageNotification(
identifier = publicNotificationId(message.id),
messageId = message.id,
isDirectMessage = false,
peerUserId = null,
senderName = senderDisplayLabel(message, currentUserId),
body = buildChatListPreview(message, previewStrings)?.takeIf { it.isNotBlank() }
?: message.content.trim(),
isUpdate = isUpdate,
launchTarget = NotificationLaunchTarget(
startAtPublicChat = true,
scrollToMessageId = message.id,
),
)
private suspend fun dmNotification(
envelope: DmEnvelope,
currentUserId: Int,
previewStrings: ChatListPreviewStrings,
isUpdate: Boolean,
): PresentedMessageNotification? {
if (envelope.senderId == currentUserId) return null
val plaintext = runCatching {
decryptEnvelope(envelope, currentUserId)
}.getOrElse { throwable ->
when (throwable) {
is DmCiphertextCorruptedException -> CorruptedDmMessagePlaceholder
else -> "Encrypted message"
}
}
val senderName = envelope.senderDisplayName?.takeIf { it.isNotBlank() }
?: ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
?: envelope.senderUsername.orEmpty()
val body = buildChatListPreviewFromEnvelope(
envelope = envelope,
decryptedPlaintext = plaintext,
strings = previewStrings,
)?.takeIf { it.isNotBlank() } ?: plaintext
return PresentedMessageNotification(
identifier = dmNotificationId(envelope.senderId, envelope.id),
messageId = envelope.id,
isDirectMessage = true,
peerUserId = envelope.senderId,
senderName = senderName,
body = body,
isUpdate = isUpdate,
launchTarget = NotificationLaunchTarget(
dmConversationUserId = envelope.senderId,
scrollToMessageId = envelope.id,
),
)
}
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" }
}
private suspend fun previewStrings(): ChatListPreviewStrings {
val emoji = getString(Res.string.chat_preview_image_emoji)
return ChatListPreviewStrings(
imageEmoji = emoji,
imageOnly = getString(Res.string.chat_preview_image, emoji),
attachmentOnly = getString(Res.string.chat_preview_attachment),
)
}
private fun decodeMessage(element: JsonElement): Message? =
runCatching { ApiClient.json.decodeFromJsonElement(Message.serializer(), element) }.getOrNull()
private fun decodeDmEnvelope(element: JsonElement): DmEnvelope? =
runCatching { ApiClient.json.decodeFromJsonElement(DmEnvelope.serializer(), element) }.getOrNull()
private fun jsonInt(data: JsonObject?, key: String): Int? =
runCatching { data?.get(key)?.jsonPrimitive?.content?.toIntOrNull() }.getOrNull()
}
@@ -0,0 +1,39 @@
package ru.fromchat.notifications
internal const val PUBLIC_NOTIFICATION_SLOT = "public"
internal fun publicNotificationId(messageId: Int) = "public:$messageId"
internal fun dmNotificationSlot(peerUserId: Int) = "dm:$peerUserId"
internal fun dmNotificationId(peerUserId: Int, messageId: Int) = "dm:$peerUserId:$messageId"
internal fun notificationAndroidId(identifier: String): Int {
val hashed = identifier.hashCode() and 0x7FFFFFFF
return if (hashed == 0) 1 else hashed
}
internal data class PresentedMessageNotification(
val identifier: String,
val messageId: Int,
val isDirectMessage: Boolean,
val peerUserId: Int?,
val senderName: String,
val body: String,
val isUpdate: Boolean,
val launchTarget: NotificationLaunchTarget,
) {
val androidNotifyId: Int
get() = notificationAndroidId(identifier)
}
/** Platform notification UI. Shared code decides *when*; this decides *how*. */
internal expect object MessageNotificationSink {
fun areEnabled(): Boolean
fun shouldSuppressPublic(): Boolean
fun shouldSuppressDm(peerUserId: Int): Boolean
suspend fun present(notification: PresentedMessageNotification)
fun dismiss(identifier: String)
fun dismissAll()
fun refreshChrome()
}
@@ -312,6 +312,16 @@ fun App(
}
}
}
Lifecycle.Event.ON_RESUME -> {
if (!keepWebSocketAliveInBackground()) {
AppForeground.setWindowFocused(true)
}
}
Lifecycle.Event.ON_PAUSE -> {
if (!keepWebSocketAliveInBackground()) {
AppForeground.setWindowFocused(false)
}
}
Lifecycle.Event.ON_STOP -> {
if (!keepWebSocketAliveInBackground()) {
AppForeground.setForeground(false)
@@ -71,6 +71,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.datetime.LocalDate
@@ -89,6 +90,7 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
@@ -100,6 +102,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
import ru.fromchat.AppForeground
import ru.fromchat.ui.main.ConversationDetailContentPadding
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.detailPaneShowBackButton
@@ -155,6 +158,7 @@ import ru.fromchat.ui.chat.utils.getImageDimensions
import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.extraStatusBars
@@ -581,9 +585,42 @@ fun ChatScreen(
}
}
val appWindowFocused by AppForeground.isWindowFocused.collectAsState()
val windowFocused = LocalWindowInfo.current.isWindowFocused && appWindowFocused
LaunchedEffect(listState, panel, windowFocused, currentUserId) {
if (!windowFocused) return@LaunchedEffect
var pending: Job? = null
snapshotFlow {
val (topClearancePx, bottomClearancePx) = chatScrollClearancePx.value
unobstructedVisibleMessageIdsInChatList(
listState = listState,
messages = panelState.messages,
topClearancePx = topClearancePx,
bottomClearancePx = bottomClearancePx,
)
}
.distinctUntilChanged()
.collect { ids ->
pending?.cancel()
if (ids.isEmpty()) return@collect
pending = launch {
delay(150)
withContext(Dispatchers.Default) {
MessageRepository.markVisibleMessagesRead(
peerUserId = panel.getRecipientId(),
visibleMessageIds = ids,
currentUserId = currentUserId,
messages = panel.getState().messages,
)
}
}
}
}
// Collect WebSocket messages
LaunchedEffect(Unit) {
WebSocketManager.messages.collect { message ->
try {
Logger.d("ChatScreen", "Received WebSocket message: type=${message.type}, data=${message.data != null}")
when (message.type) {
"updates" -> {
@@ -621,15 +658,14 @@ fun ChatScreen(
Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
try {
panel.handleWebSocketMessage(wsMessage)
} catch (e: Exception) {
} catch (e: Throwable) {
Logger.e("ChatScreen", "Error handling WebSocket message: ${e.message}", e)
}
}
}
}
} catch (e: Exception) {
} catch (e: Throwable) {
Logger.e("ChatScreen", "Error parsing updates message: ${e.message}", e)
e.printStackTrace()
}
}
"statusUpdate" -> message.data?.jsonObject?.let { data ->
@@ -642,12 +678,14 @@ fun ChatScreen(
"dmTyping", "stopDmTyping", "typing", "stopTyping", "reactionUpdate",
"registeredUserCount" -> {
scope.launch {
panel.handleWebSocketMessage(message)
runCatching { panel.handleWebSocketMessage(message) }
.onFailure { Logger.e("ChatScreen", "Error handling WebSocket message: ${it.message}", it) }
}
}
"sendMessage" -> {
scope.launch {
panel.handleWebSocketMessage(message)
runCatching { panel.handleWebSocketMessage(message) }
.onFailure { Logger.e("ChatScreen", "Error handling sendMessage: ${it.message}", it) }
}
}
"call_signaling" -> {
@@ -660,6 +698,9 @@ fun ChatScreen(
Logger.w("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
}
}
} catch (e: Throwable) {
Logger.e("ChatScreen", "WebSocket collect failed type=${message.type}", e)
}
}
}
@@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder
import ru.fromchat.Logger
import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier
import ru.fromchat.api.local.db.store.ProfileCache
@@ -20,15 +21,12 @@ import ru.fromchat.utils.haptic.rememberHapticFeedback
/** Route patterns and builders for DM chat + in-DM profile (stacked for predictive / system back). */
object DmNav {
const val CHAT_ROUTE = "dm/{otherUserId}/chat?sourceMessageId={sourceMessageId}"
const val CHAT_ROUTE = "dm/{otherUserId}/chat/{sourceMessageId}"
const val PROFILE_ROUTE = "dm/{otherUserId}/profile"
fun chatRoute(otherUserId: Int, sourceMessageId: Int? = null): String {
return if (sourceMessageId != null && sourceMessageId > 0) {
"dm/$otherUserId/chat?sourceMessageId=$sourceMessageId"
} else {
"dm/$otherUserId/chat"
}
val source = sourceMessageId?.takeIf { it > 0 } ?: 0
return "dm/$otherUserId/chat/$source"
}
fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile"
@@ -46,20 +44,28 @@ fun NavController.navigateToDmChat(
sourceMessageId: Int? = null,
builder: NavOptionsBuilder.() -> Unit = {},
) {
if (isCurrentDmChat(otherUserId)) return
if (sourceMessageId == null && isCurrentDmChat(otherUserId)) return
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
builder()
}
val route = DmNav.chatRoute(otherUserId, sourceMessageId)
runCatching {
navigate(route) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
builder()
}
}.onFailure { Logger.e("DmNav", "navigateToDmChat failed route=$route", it) }
}
/** True when the back-stack top is already [DmNav.CHAT_ROUTE] for [otherUserId]. */
fun NavController.isCurrentDmChat(otherUserId: Int): Boolean {
val entry = currentBackStackEntry ?: return false
if (entry.destination.route != DmNav.CHAT_ROUTE) return false
val currentId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull()
val currentId = when (val raw = entry.savedStateHandle.get<Any?>("otherUserId")) {
is Int -> raw
is Long -> raw.toInt()
is String -> raw.toIntOrNull()
else -> null
}
return currentId == otherUserId
}
@@ -586,10 +586,7 @@ class DmPanel(
user_id = envelope.senderId,
content = dec.text,
timestamp = envelope.timestamp,
is_read = when {
envelope.senderId == currentUserId -> true
else -> ActiveDmChatTracker.isActive(otherUserId)
},
is_read = envelope.isReadByViewer(currentUserId),
is_edited = false,
username = senderUsername,
displayName = senderDisplayName,
@@ -8,12 +8,9 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageRepository
@@ -23,7 +20,6 @@ import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatScreen
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
@Composable
fun DmScreen(
@@ -75,33 +71,6 @@ fun DmScreen(
}
}
LaunchedEffect(panel, activeInstanceId, activePeerUserId, peerUserId) {
val peerId = activePeerUserId.takeIf { it > 0 } ?: peerUserId ?: return@LaunchedEffect
if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect
combine(
AttachmentDownloadVisibility.visibleMessageIds,
snapshotFlow { panel.getState().messages },
) { visibleIds, messages ->
visibleIds
.filter { id ->
messages.find { it.id == id }?.user_id == peerId
}
.maxOrNull()
}
.distinctUntilChanged()
.collect { maxVisibleInboundId ->
if (
maxVisibleInboundId != null &&
maxVisibleInboundId > 0 &&
ActiveDmChatTracker.isActive(peerId)
) {
withContext(Dispatchers.Default) {
MessageRepository.markDmConversationReadUpTo(peerId, maxVisibleInboundId)
}
}
}
}
ChatScreen(
panel = panel,
currentUserId = currentUserId,
@@ -39,3 +39,30 @@ fun visibleMessageIdsInChatList(
}
return ids
}
/**
* Message ids whose rows intersect the chat viewport after excluding the floating
* header ([topClearancePx]) and input bar ([bottomClearancePx]).
*
* Reverse-layout offsets grow from the visual bottom (start).
*/
fun unobstructedVisibleMessageIdsInChatList(
listState: LazyListState,
messages: List<Message>,
topClearancePx: Int,
bottomClearancePx: Int,
): Set<Int> {
val layoutInfo = listState.layoutInfo
val unobstructedStart = layoutInfo.viewportStartOffset + bottomClearancePx.coerceAtLeast(0)
val unobstructedEnd = (layoutInfo.viewportEndOffset - topClearancePx.coerceAtLeast(0))
.coerceAtLeast(unobstructedStart)
if (unobstructedEnd <= unobstructedStart) return emptySet()
val reversed = messages.asReversed()
return layoutInfo.visibleItemsInfo.mapNotNull { info ->
if (info.index <= 0) return@mapNotNull null
val itemStart = info.offset
val itemEnd = info.offset + info.size
if (itemEnd <= unobstructedStart || itemStart >= unobstructedEnd) return@mapNotNull null
reversed.getOrNull(info.index - 1)?.id?.takeIf { it > 0 }
}.toSet()
}
@@ -1,6 +1,7 @@
package ru.fromchat.ui.main
import androidx.compose.animation.SharedTransitionScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
@@ -66,15 +67,15 @@ fun NavGraphBuilder.conversationDetailDestinations(
route = DmNav.CHAT_ROUTE,
arguments = listOf(
navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
navArgument("sourceMessageId") { type = NavType.StringType; defaultValue = "0" },
),
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
val otherUserId = entry.pathInt("otherUserId")
val sourceMessageId = entry.pathInt("sourceMessageId")
if (otherUserId <= 0) return@composable
DmChatRoute(
otherUserId = otherUserId,
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
scrollToMessageId = sourceMessageId.takeIf { it > 0 },
navController = navController,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = this,
@@ -85,7 +86,7 @@ fun NavGraphBuilder.conversationDetailDestinations(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val otherUserId = entry.pathInt("otherUserId")
if (otherUserId <= 0) return@composable
DmProfileRoute(
otherUserId = otherUserId,
@@ -288,3 +289,11 @@ fun NavGraphBuilder.settingsDetailDestinations(
ServerConfigScreen()
}
}
private fun NavBackStackEntry.pathInt(name: String): Int =
when (val raw = savedStateHandle.get<Any?>(name)) {
is Int -> raw
is Long -> raw.toInt()
is String -> raw.toIntOrNull() ?: 0
else -> 0
}
@@ -943,6 +943,7 @@ internal fun PublicChatRowContent(
val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage
val pendingIndicator = publicChatPreviewState?.pendingIndicator
?: ChatListPreviewPendingIndicator.None
val unreadCount = publicChatPreviewState?.unreadCount ?: 0
val showPreview = preview.isNotBlank() || pendingIndicator != ChatListPreviewPendingIndicator.None
ListItem(
@@ -978,7 +979,12 @@ internal fun PublicChatRowContent(
)
}
},
trailingContent = {},
trailingContent = {
ChatUnreadBadge(
count = unreadCount,
visible = unreadCount > 0 && listMode == ChatsListMode.Normal,
)
},
bodyModifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
@@ -1224,7 +1224,7 @@ fun ChatsTab(
selectedOtherUserIds = selectedOtherUserIds,
isReadOnly = suspensionState.isSuspended,
callsEnabled = callsEnabled,
publicHasUnread = false,
publicHasUnread = (publicChatPreviewState?.unreadCount ?: 0) > 0,
)
} else {
null
@@ -41,6 +41,8 @@ import ru.fromchat.settings_notifications_title
import ru.fromchat.settings_push_notifications
import ru.fromchat.settings_push_notifications_d
import ru.fromchat.settings_push_notifications_unavailable
import ru.fromchat.settings_desktop_notifications
import ru.fromchat.settings_desktop_notifications_d
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.Text
@@ -53,7 +55,10 @@ fun NotificationsScreen(onBack: () -> Unit) {
val snackbarHostState = remember { SnackbarHostState() }
var notificationsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
notificationsEnabled = areAppNotificationsEnabled() && isFcmPushRegisteredLocally()
notificationsEnabled = when {
areDesktopMessageNotificationsSupported() -> areDesktopMessageNotificationsEnabled()
else -> areAppNotificationsEnabled() && isFcmPushRegisteredLocally()
}
}
val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required)
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
@@ -81,57 +86,101 @@ fun NotificationsScreen(onBack: () -> Unit) {
Category(
modifier = Modifier.padding(top = 16.dp)
) {
if (arePushNotificationsSupported()) {
SwitchListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_d),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
checked = notificationsEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled) {
if (!areAppNotificationsEnabled()) {
if (!openAppNotificationSettings()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
} else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
when {
arePushNotificationsSupported() -> {
SwitchListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_d),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
checked = notificationsEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled) {
if (!areAppNotificationsEnabled()) {
if (!openAppNotificationSettings()) {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
} else {
snackbarHostState.showSnackbar(message = notificationsPermissionText)
}
return@launch
}
return@launch
}
val registered = ensureFcmTokenRegistered()
if (registered) {
val registered = ensureFcmTokenRegistered()
if (registered) {
notificationsEnabled = true
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
}
} else {
unregisterFcmTokenFromServer()
notificationsEnabled = false
}
}
},
divider = true
)
ListItem(
headline = stringResource(Res.string.settings_notification_settings),
supportingText = stringResource(Res.string.settings_notification_settings_d),
leadingContent = {
Icon(Icons.Filled.Settings, null)
},
onClick = { openAppNotificationSettings() }
)
}
areDesktopMessageNotificationsSupported() -> {
SwitchListItem(
headline = stringResource(Res.string.settings_desktop_notifications),
supportingText = stringResource(Res.string.settings_desktop_notifications_d),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
checked = notificationsEnabled,
onCheckedChange = { enabled ->
coroutineScope.launch {
if (enabled) {
val granted = requestDesktopNotificationPermission()
if (!granted && !areAppNotificationsEnabled()) {
openAppNotificationSettings()
snackbarHostState.showSnackbar(
message = notificationsPermissionText,
)
return@launch
}
setDesktopMessageNotificationsEnabled(true)
notificationsEnabled = true
} else {
snackbarHostState.showSnackbar(message = unexpectedErrorText)
setDesktopMessageNotificationsEnabled(false)
notificationsEnabled = false
}
} else {
unregisterFcmTokenFromServer()
notificationsEnabled = false
}
}
},
divider = true
)
},
divider = true,
)
ListItem(
headline = stringResource(Res.string.settings_notification_settings),
supportingText = stringResource(Res.string.settings_notification_settings_d),
leadingContent = {
Icon(Icons.Filled.Settings, null)
},
onClick = { openAppNotificationSettings() }
)
} else {
ListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
)
ListItem(
headline = stringResource(Res.string.settings_notification_settings),
supportingText = stringResource(Res.string.settings_notification_settings_d),
leadingContent = {
Icon(Icons.Filled.Settings, null)
},
onClick = { openAppNotificationSettings() },
)
}
else -> {
ListItem(
headline = stringResource(Res.string.settings_push_notifications),
supportingText = stringResource(Res.string.settings_push_notifications_unavailable),
leadingContent = {
Icon(Icons.Filled.Notifications, null)
},
)
}
}
}
}
@@ -150,4 +199,13 @@ expect fun openAppNotificationSettings(): Boolean
expect fun areAppNotificationsEnabled(): Boolean
/** False on platforms without push delivery (e.g. iOS without APNs). */
expect fun arePushNotificationsSupported(): Boolean
expect fun arePushNotificationsSupported(): Boolean
/** True on desktop (tray / notification center via persistent WebSocket). */
expect fun areDesktopMessageNotificationsSupported(): Boolean
expect fun areDesktopMessageNotificationsEnabled(): Boolean
expect fun setDesktopMessageNotificationsEnabled(enabled: Boolean)
expect fun requestDesktopNotificationPermission(): Boolean
@@ -201,21 +201,31 @@ SELECT id
FROM message
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
countUnreadPublicInboundMessages:
SELECT COUNT(*)
FROM message
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0 AND userId != ?;
countInboundDmMessages:
SELECT COUNT(*)
FROM message
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND id > 0 AND deletedFlag = 0;
markPublicMessagesRead:
UPDATE message
SET isRead = 1
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0;
markPublicMessageRead:
UPDATE message
SET isRead = 1
WHERE instanceId = ? AND conversationId = ? AND id = ? AND isRead = 0 AND deletedFlag = 0;
countUnreadInboundDmMessages:
SELECT COUNT(*)
FROM message
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
markInboundDmMessagesReadUpTo:
UPDATE message
SET isRead = 1
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND id <= ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
markAllInboundDmMessagesRead:
UPDATE message
SET isRead = 1
@@ -1,5 +0,0 @@
package ru.fromchat.notifications
actual object ChatNotificationDismissals {
actual fun dismissAllMessageNotifications() = Unit
}
@@ -0,0 +1,3 @@
package ru.fromchat.notifications
internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) = Unit
@@ -0,0 +1,11 @@
package ru.fromchat.notifications
internal actual object MessageNotificationSink {
actual fun areEnabled(): Boolean = false
actual fun shouldSuppressPublic(): Boolean = true
actual fun shouldSuppressDm(peerUserId: Int): Boolean = true
actual suspend fun present(notification: PresentedMessageNotification) = Unit
actual fun dismiss(identifier: String) = Unit
actual fun dismissAll() = Unit
actual fun refreshChrome() = Unit
}
@@ -13,4 +13,12 @@ actual fun openAppNotificationSettings(): Boolean {
actual fun areAppNotificationsEnabled(): Boolean = false
actual fun arePushNotificationsSupported(): Boolean = false
actual fun arePushNotificationsSupported(): Boolean = false
actual fun areDesktopMessageNotificationsSupported(): Boolean = false
actual fun areDesktopMessageNotificationsEnabled(): Boolean = false
actual fun setDesktopMessageNotificationsEnabled(enabled: Boolean) = Unit
actual fun requestDesktopNotificationPermission(): Boolean = false
@@ -0,0 +1,16 @@
package ru.fromchat.desktop
/** Tracks whether the main desktop window is shown and whether it has OS focus. */
object DesktopAppVisibility {
@Volatile
var isWindowVisible: Boolean = true
@Volatile
var isWindowFocused: Boolean = true
val isOsFrontmost: Boolean
get() = !MacNotificationCenter.isAvailable() || MacNotificationCenter.isAppFrontmost()
val isForeground: Boolean
get() = isWindowVisible && isWindowFocused && isOsFrontmost
}
@@ -0,0 +1,17 @@
package ru.fromchat.desktop
import java.util.prefs.Preferences
object DesktopNotificationSettings {
private const val PREF_NODE = "ru/fromchat/desktop"
private const val KEY_ENABLED = "message_notifications_enabled"
var enabled: Boolean
get() = preferences().getBoolean(KEY_ENABLED, true)
set(value) {
preferences().putBoolean(KEY_ENABLED, value)
}
private fun preferences(): Preferences =
Preferences.userRoot().node(PREF_NODE)
}
@@ -3,6 +3,17 @@ package ru.fromchat.desktop
import java.awt.SystemTray
import java.awt.TrayIcon
import ru.fromchat.Logger
import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.notifications.NotificationLaunchTarget
data class DesktopNotificationPayload(
val title: String,
val body: String,
val subtitle: String = "",
val launchTarget: NotificationLaunchTarget? = null,
) {
fun displayBody(): String = if (subtitle.isBlank()) body else "$subtitle\n$body"
}
/**
* Best-effort desktop notifications. [Main] may install a Compose Tray sink;
@@ -12,23 +23,79 @@ object DesktopNotifier {
private const val TAG = "DesktopNotifier"
@Volatile
var sink: ((title: String, body: String) -> Unit)? = null
var sink: ((DesktopNotificationPayload) -> Unit)? = null
fun show(title: String, body: String) {
val t = title.trim().ifEmpty { "FromChat" }
val b = body.trim()
sink?.invoke(t, b)?.let { return }
showAwtBalloon(t, b)
@Volatile
private var pendingLaunchTarget: NotificationLaunchTarget? = null
private val launches = java.util.concurrent.ConcurrentHashMap<String, NotificationLaunchTarget>()
fun rememberLaunch(identifier: String, target: NotificationLaunchTarget) {
launches[identifier] = target
pendingLaunchTarget = target
}
fun rememberLaunch(target: NotificationLaunchTarget) {
pendingLaunchTarget = target
}
fun forgetLaunch(identifier: String) {
launches.remove(identifier)
}
fun show(
title: String,
body: String,
subtitle: String = "",
launchTarget: NotificationLaunchTarget? = null,
) {
val payload = DesktopNotificationPayload(
title = title.trim().ifEmpty { "FromChat" },
body = body.trim(),
subtitle = subtitle.trim(),
launchTarget = launchTarget,
)
if (payload.launchTarget != null) {
rememberLaunch(payload.launchTarget)
}
val sink = this.sink
Logger.i(
TAG,
"show sink=${sink != null} titleLen=${payload.title.length} " +
"subtitleLen=${payload.subtitle.length} bodyLen=${payload.body.length} " +
"launch=$launchTarget",
)
sink?.invoke(payload) ?: showAwtBalloon(payload.title, payload.displayBody())
}
/** Opens the chat referenced by [identifier], or the most recent notification if unknown. */
fun deliverPendingLaunch(identifier: String? = null) {
val target = identifier?.let { launches.remove(it) } ?: pendingLaunchTarget ?: return
pendingLaunchTarget = null
NotificationLaunchCoordinator.publish(target)
}
fun clearPendingLaunch() {
pendingLaunchTarget = null
launches.clear()
}
fun showAwtFallback(title: String, body: String) {
showAwtBalloon(title, body)
}
private fun showAwtBalloon(title: String, body: String) {
runCatching {
if (!SystemTray.isSupported()) {
Logger.d(TAG, "SystemTray unsupported; drop notification")
Logger.i(TAG, "AWT balloon skip: SystemTray unsupported")
return
}
val tray = SystemTray.getSystemTray()
val icon = tray.trayIcons.firstOrNull() ?: return
val icon = tray.trayIcons.firstOrNull()
if (icon == null) {
Logger.i(TAG, "AWT balloon skip: no tray icon")
return
}
Logger.i(TAG, "AWT balloon displayMessage titleLen=${title.length}")
icon.displayMessage(title, body, TrayIcon.MessageType.INFO)
}.onFailure {
Logger.w(TAG, "AWT balloon failed: ${it.message}", it)
@@ -0,0 +1,29 @@
package ru.fromchat.desktop
import java.awt.Taskbar
import ru.fromchat.Logger
/** Best-effort dock / taskbar unread badge (macOS when supported; no-op elsewhere). */
object DesktopTaskbarBadge {
private const val TAG = "DesktopTaskbarBadge"
fun setUnreadCount(count: Int) {
runCatching {
if (!Taskbar.isTaskbarSupported()) return
val taskbar = Taskbar.getTaskbar()
val badgeFeature = runCatching {
Taskbar.Feature.valueOf("ICON_BADGE")
}.getOrNull() ?: return
if (!taskbar.isSupported(badgeFeature)) return
val badge = when {
count <= 0 -> null
count > 99 -> "99+"
else -> count.toString()
}
val method = taskbar.javaClass.getMethod("setIconBadge", String::class.java)
method.invoke(taskbar, badge)
}.onFailure {
Logger.d(TAG, "icon badge update failed: ${it.message}")
}
}
}
@@ -0,0 +1,274 @@
package ru.fromchat.desktop
import ru.fromchat.Logger
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* Registers FromChat with macOS Notification Center ([UNUserNotificationCenter])
* and delivers native banners that appear under our bundle id, not "java".
*/
object MacNotificationCenter {
private const val TAG = "MacNotificationCenter"
private const val LIBRARY = "fromchat_notifications"
private const val SPURIOUS_ACTIVATION_MS = 5_000L
@Volatile
var onActivated: ((String?) -> Unit)? = null
@Volatile
private var lastDeliverId: String? = null
@Volatile
private var lastDeliverAtMs: Long = 0L
@Volatile
private var lastDeliverWhileFrontmost: Boolean = false
@Volatile
private var lastWillPresentId: String? = null
@Volatile
private var lastWillPresentAtMs: Long = 0L
private val available: Boolean by lazy { loadLibrary() }
fun isAvailable(): Boolean = available
fun registerAndRequestAuthorization(): Boolean {
if (!available) {
Logger.w(TAG, "register skip: native library unavailable")
return false
}
Logger.i(TAG, "register begin ${runCatching { nativeDebugInfo() }.getOrDefault("debug=fail")}")
if (!runCatching { nativeIsBundled() }.getOrDefault(false)) {
Logger.w(TAG, "skip UNUserNotificationCenter: process is not an .app bundle")
return false
}
runCatching { nativeRegisterBundle() }
val granted = runCatching { nativeRequestAuthorization() }.getOrDefault(false)
val status = runCatching { nativeAuthorizationStatus() }.getOrDefault(-1)
Logger.i(TAG, "register done granted=$granted auth=${authStatusName(status)}")
if (!granted && status == 1) {
Logger.w(TAG, "authorization denied — opening Notification settings")
openSystemSettings()
}
return granted
}
fun isAuthorized(): Boolean {
if (!available) return true
return runCatching { nativeAuthorizationStatus() }.getOrDefault(0) >= 2
}
fun isAppFrontmost(): Boolean =
available && runCatching { nativeIsAppFrontmost() }.getOrDefault(false)
/** Resigns key status so Notification Center can show banners when another app is in use. */
fun resignActive() {
if (!available) return
runCatching { nativeResignActive() }
}
/** Lets the real frontmost app own activation so Notification Center uses banners. */
fun yieldActivationIfNotFrontmost() {
if (!available) return
runCatching { nativeYieldActivation() }
}
fun deliver(
title: String,
body: String,
subtitle: String = "",
identifier: String = System.nanoTime().toString(),
playSound: Boolean = true,
): Boolean {
if (!available) {
Logger.i(TAG, "deliver skip: native library unavailable")
return false
}
if (!runCatching { nativeIsBundled() }.getOrDefault(false)) {
Logger.i(
TAG,
"deliver skip: not bundled ${runCatching { nativeDebugInfo() }.getOrDefault("")}",
)
return false
}
val status = runCatching { nativeAuthorizationStatus() }.getOrDefault(-1)
if (status < 2) {
Logger.i(TAG, "deliver skip: auth=${authStatusName(status)} id=$identifier")
return false
}
lastDeliverId = identifier
lastDeliverAtMs = System.currentTimeMillis()
val windowFocused = DesktopAppVisibility.isWindowFocused
lastDeliverWhileFrontmost = windowFocused
Logger.i(
TAG,
"deliver begin auth=${authStatusName(status)} id=$identifier " +
"titleLen=${title.length} subtitleLen=${subtitle.length} bodyLen=${body.length} " +
"windowFocused=$windowFocused frontmost=$lastDeliverWhileFrontmost " +
runCatching { nativeDebugInfo() }.getOrDefault(""),
)
val ok = runCatching {
nativeDeliver(title, body, subtitle, identifier, playSound, windowFocused)
}
.onFailure { Logger.e(TAG, "deliver native threw", it) }
.getOrDefault(false)
Logger.i(TAG, "deliver end ok=$ok id=$identifier")
return ok
}
fun removeAll() {
if (!available) return
Logger.i(TAG, "removeAll")
runCatching { nativeRemoveAll() }
}
fun remove(identifier: String) {
if (!available) return
Logger.i(TAG, "remove id=$identifier")
runCatching { nativeRemove(arrayOf(identifier)) }
}
fun openSystemSettings(): Boolean {
if (available) {
val native = runCatching { nativeOpenSettings() }.getOrDefault(false)
if (native) return true
}
return runCatching {
ProcessBuilder(
"open",
"x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=ru.fromchat.desktop",
).start().waitFor() == 0
}.getOrDefault(false)
}
@JvmStatic
fun onNativeActivated(identifier: String?) {
val now = System.currentTimeMillis()
val deliverElapsed = now - lastDeliverAtMs
val presentElapsed = now - lastWillPresentAtMs
val fromForegroundDeliver = lastDeliverWhileFrontmost &&
identifier != null &&
identifier == lastDeliverId &&
deliverElapsed in 0 until SPURIOUS_ACTIVATION_MS
val fromWillPresent = identifier != null &&
identifier == lastWillPresentId &&
presentElapsed in 0 until SPURIOUS_ACTIVATION_MS
if (fromForegroundDeliver || fromWillPresent) {
Logger.i(
TAG,
"ignore spurious activation id=$identifier " +
"deliverElapsedMs=$deliverElapsed presentElapsedMs=$presentElapsed",
)
return
}
Logger.i(TAG, "notification activated id=$identifier")
val callback = onActivated ?: return
java.awt.EventQueue.invokeLater {
runCatching { callback(identifier) }.onFailure {
Logger.e(TAG, "onActivated failed", it)
}
}
}
@JvmStatic
fun onNativeWillPresent(identifier: String?) {
lastWillPresentId = identifier
lastWillPresentAtMs = System.currentTimeMillis()
Logger.i(TAG, "willPresent id=$identifier")
}
private fun loadLibrary(): Boolean {
if (!isMacOs()) return false
return runCatching {
val extracted = extractDylib()
if (extracted != null) {
System.load(extracted)
} else {
System.loadLibrary(LIBRARY)
}
Logger.i(TAG, "native library loaded")
true
}.onFailure {
Logger.w(TAG, "native notifications unavailable: ${it.message}")
}.getOrDefault(false)
}
private fun extractDylib(): String? {
val resourceNames = listOf(
"/natives/lib$LIBRARY.dylib",
"/lib$LIBRARY.dylib",
)
val stream = resourceNames.firstNotNullOfOrNull { name ->
MacNotificationCenter::class.java.getResourceAsStream(name)
?: Thread.currentThread().contextClassLoader.getResourceAsStream(name.removePrefix("/"))
} ?: run {
val packaged = System.getProperty("compose.application.resources.dir")
?.let { java.io.File(it, "lib$LIBRARY.dylib") }
return packaged?.takeIf { it.isFile }?.absolutePath
}
val tmp = Files.createTempFile("lib$LIBRARY", ".dylib")
stream.use { input ->
Files.copy(input, tmp, StandardCopyOption.REPLACE_EXISTING)
}
tmp.toFile().deleteOnExit()
return tmp.toAbsolutePath().toString()
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
private fun authStatusName(status: Int): String = when (status) {
0 -> "notDetermined"
1 -> "denied"
2 -> "authorized"
3 -> "provisional"
4 -> "ephemeral"
else -> "unknown($status)"
}
@JvmStatic
private external fun nativeRequestAuthorization(): Boolean
@JvmStatic
private external fun nativeAuthorizationStatus(): Int
@JvmStatic
private external fun nativeDeliver(
title: String,
body: String,
subtitle: String,
identifier: String,
playSound: Boolean,
windowFocused: Boolean,
): Boolean
@JvmStatic
private external fun nativeRemoveAll()
@JvmStatic
private external fun nativeRemove(identifiers: Array<String>)
@JvmStatic
private external fun nativeOpenSettings(): Boolean
@JvmStatic
private external fun nativeRegisterBundle()
@JvmStatic
private external fun nativeIsAppFrontmost(): Boolean
@JvmStatic
private external fun nativeResignActive()
@JvmStatic
private external fun nativeYieldActivation()
@JvmStatic
private external fun nativeIsBundled(): Boolean
@JvmStatic
private external fun nativeDebugInfo(): String
}
@@ -1,5 +0,0 @@
package ru.fromchat.notifications
actual object ChatNotificationDismissals {
actual fun dismissAllMessageNotifications() = Unit
}
@@ -0,0 +1,17 @@
package ru.fromchat.notifications
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString
import ru.fromchat.Res
import ru.fromchat.call_incoming_subtitle
import ru.fromchat.desktop.DesktopAppVisibility
import ru.fromchat.desktop.DesktopNotificationSettings
import ru.fromchat.ui.calls.notifyIncomingCall
internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) {
if (!DesktopNotificationSettings.enabled) return
if (DesktopAppVisibility.isWindowVisible) return
val title = callerDisplayName.trim().ifBlank { "FromChat" }
val body = runBlocking { getString(Res.string.call_incoming_subtitle) }
notifyIncomingCall(title, body)
}
@@ -0,0 +1,89 @@
package ru.fromchat.notifications
import org.jetbrains.compose.resources.getString
import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.desktop.DesktopAppVisibility
import ru.fromchat.desktop.DesktopNotificationSettings
import ru.fromchat.desktop.DesktopNotifier
import ru.fromchat.desktop.DesktopTaskbarBadge
import ru.fromchat.desktop.MacNotificationCenter
import ru.fromchat.public_chat
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
private const val TAG = "MessageNotificationSink"
private fun isUsingDesktopApp(): Boolean =
DesktopAppVisibility.isWindowVisible && DesktopAppVisibility.isWindowFocused
internal actual object MessageNotificationSink {
actual fun areEnabled(): Boolean = DesktopNotificationSettings.enabled
actual fun shouldSuppressPublic(): Boolean =
isUsingDesktopApp() && isPublicChatVisible
actual fun shouldSuppressDm(peerUserId: Int): Boolean =
isUsingDesktopApp() && ActiveDmChatTracker.isActive(peerUserId)
actual suspend fun present(notification: PresentedMessageNotification) {
val title: String
val subtitle: String
if (notification.isDirectMessage) {
title = notification.senderName.ifBlank { "FromChat" }
subtitle = ""
} else {
title = getString(Res.string.public_chat)
subtitle = notification.senderName
}
DesktopNotifier.rememberLaunch(notification.identifier, notification.launchTarget)
Logger.i(
TAG,
"present id=${notification.identifier} update=${notification.isUpdate} " +
"titleLen=${title.length} subtitleLen=${subtitle.length} bodyLen=${notification.body.length}",
)
val native = MacNotificationCenter.deliver(
title = title,
body = notification.body,
subtitle = subtitle,
identifier = notification.identifier,
playSound = !notification.isUpdate,
)
if (!native) {
DesktopNotifier.showAwtFallback(
title,
if (subtitle.isBlank()) notification.body else "$subtitle\n${notification.body}",
)
}
}
actual fun dismiss(identifier: String) {
Logger.i(TAG, "dismiss identifier=$identifier")
DesktopNotifier.forgetLaunch(identifier)
MacNotificationCenter.remove(identifier)
}
actual fun dismissAll() {
Logger.i(TAG, "dismissAll")
DesktopNotifier.clearPendingLaunch()
MacNotificationCenter.removeAll()
DesktopTaskbarBadge.setUnreadCount(0)
}
actual fun refreshChrome() {
runCatching {
val instanceId = CacheContext.activeInstanceId.value.trim()
val total = if (instanceId.isEmpty()) {
0
} else {
ru.fromchat.api.local.db.store.MessageCacheStore
.loadCachedDmConversationsImmediate(instanceId)
.sumOf { it.unreadCount }
}
DesktopTaskbarBadge.setUnreadCount(total)
}.onFailure {
Logger.d(TAG, "badge refresh failed: ${it.message}")
}
}
}
@@ -1,7 +1,36 @@
package ru.fromchat.ui.main.settings
actual fun openAppNotificationSettings(): Boolean = false
import ru.fromchat.desktop.DesktopNotificationSettings
import ru.fromchat.desktop.MacNotificationCenter
actual fun areAppNotificationsEnabled(): Boolean = true
actual fun openAppNotificationSettings(): Boolean {
if (isMacOs()) return MacNotificationCenter.openSystemSettings()
return false
}
actual fun areAppNotificationsEnabled(): Boolean {
if (!isMacOs()) return true
if (!MacNotificationCenter.isAvailable()) return true
return MacNotificationCenter.isAuthorized()
}
actual fun arePushNotificationsSupported(): Boolean = false
actual fun areDesktopMessageNotificationsSupported(): Boolean = true
actual fun areDesktopMessageNotificationsEnabled(): Boolean = DesktopNotificationSettings.enabled
actual fun setDesktopMessageNotificationsEnabled(enabled: Boolean) {
DesktopNotificationSettings.enabled = enabled
if (enabled && isMacOs()) {
MacNotificationCenter.registerAndRequestAuthorization()
}
}
actual fun requestDesktopNotificationPermission(): Boolean {
if (!isMacOs()) return true
return MacNotificationCenter.registerAndRequestAuthorization()
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")