diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt index 8c9184d..702e41f 100644 --- a/app/android/src/main/kotlin/ru/fromchat/App.kt +++ b/app/android/src/main/kotlin/ru/fromchat/App.kt @@ -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() } } -} \ No newline at end of file +} diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt index 489a498..accbdca 100644 --- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt +++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt @@ -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() - .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) diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt index bad97ae..3968fb1 100644 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt +++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt @@ -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) diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt deleted file mode 100644 index d135620..0000000 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ /dev/null @@ -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() - .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() - .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) { - 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) - } - } - } - } -} diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt index 54476c5..8fe373d 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt @@ -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 { diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 77bc046..43912a7 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -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 = """ + CFBundleDisplayName + FromChat + NSUserNotificationAlertStyle + banner + LSApplicationCategoryType + public.app-category.social-networking CFBundleURLTypes @@ -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("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("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( + """ + + + + + CFBundleExecutable + FromChat + CFBundleIdentifier + ru.fromchat.desktop + CFBundleName + FromChat + CFBundleDisplayName + FromChat + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + CFBundleIconFile + app_window_icon.icns + LSMinimumSystemVersion + 11.0 + LSApplicationCategoryType + public.app-category.social-networking + NSHighResolutionCapable + + NSUserNotificationAlertStyle + banner + CFBundleURLTypes + + + CFBundleURLName + FromChat + CFBundleURLSchemes + + fromchat + + + + + + """.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", - ), - ) } } diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 6ad533b..66fb2c9 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -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) { 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) { } 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) { } } - 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) diff --git a/app/desktop/src/nativeDarwin/MacNotificationCenter.m b/app/desktop/src/nativeDarwin/MacNotificationCenter.m new file mode 100644 index 0000000..a377931 --- /dev/null +++ b/app/desktop/src/nativeDarwin/MacNotificationCenter.m @@ -0,0 +1,468 @@ +#import +#import +#import +#import +#import +#import + +@class FromChatNotificationDelegate; + +static JavaVM *fromchatJvm = NULL; +static FromChatNotificationDelegate *fromchatDelegate = nil; +static jclass fromchatMacNotificationCenterClass = NULL; + +@interface FromChatNotificationDelegate : NSObject +@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 *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); +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.android.kt deleted file mode 100644 index 8d72459..0000000 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.android.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.android.kt new file mode 100644 index 0000000..c1a3754 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.android.kt @@ -0,0 +1,3 @@ +package ru.fromchat.notifications + +internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) = Unit diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.android.kt new file mode 100644 index 0000000..fe7326b --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.android.kt @@ -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, + ) +} diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt similarity index 100% rename from app/android/src/main/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt rename to app/shared/src/androidMain/kotlin/ru/fromchat/notifications/PublicChatNotificationAvatar.kt diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt index 419ae6e..38dd858 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.android.kt @@ -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 diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index e1680dc..9b86a9a 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -349,6 +349,15 @@ Push-уведомления Они помогут вам узнать, если появилось новое сообщение пока приложение закрыто. Недоступны на этой платформе. Новые сообщения видны только пока приложение открыто. + Уведомления о сообщениях + Показывать оповещения в системном трее, когда окно скрыто или вы в другом чате. + + Личное сообщение + Личное сообщение от %1$s + %1$d новых сообщений + %1$s: %2$s + Ответить + Ответ в чат… Устройства Нет активных сессий diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 3567e54..9f726f7 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -380,6 +380,15 @@ Push notifications These will let you know if you got a new message when the app is closed. Not available on this platform. New messages are only visible while the app is open. + Message notifications + Show alerts in the system tray when the window is hidden or you are in another chat. + + Direct message + Direct message from %1$s + %1$d new messages + %1$s: %2$s + Reply + Reply to chat… Devices No active sessions diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt index e2081df..41a719b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/AppForeground.kt @@ -8,7 +8,16 @@ object AppForeground { private val _isInForeground = MutableStateFlow(true) val isInForeground: StateFlow = _isInForeground.asStateFlow() + private val _isWindowFocused = MutableStateFlow(true) + val isWindowFocused: StateFlow = _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 } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 82d15da..9f9c56d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -614,11 +614,11 @@ object ApiClient { } .body() - suspend fun markMessagesRead(messageIds: List) { - if (messageIds.isEmpty()) return + suspend fun markMessagesRead(messageIds: List = 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() .conversations - suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) { + suspend fun markDmConversationRead( + otherUserId: Int, + messageIds: List? = 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)) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt index 22d1ea7..017e651 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt @@ -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) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt index 9d35abc..0b62942 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt @@ -65,23 +65,26 @@ object WebSocketManager { private val _messages = MutableSharedFlow(replay = 0, extraBufferCapacity = 64) val messages = _messages.asSharedFlow() - private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>() - private val sessionReadyHandlers = mutableListOf Unit>() + @Volatile + private var globalHandlers: List<((WebSocketMessage) -> Unit)> = emptyList() + + @Volatile + private var sessionReadyHandlers: List 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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index e1cf54d..0b993bb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -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, 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? = 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? = 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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index c2b6d3a..93178a5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -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() + private val sentDmReadIds = mutableSetOf() + private fun activeInstance(): String = CacheContext.requireActiveInstanceId() fun observeMessages(conversationId: String): Flow> = @@ -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? = 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, + currentUserId: Int?, + messages: List, + ) { + 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) { + 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) { + 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() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt index 30cf7bf..0698076 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt index 30cb239..d6a370d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt index f4e889c..ca05a4c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt @@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable @Serializable data class MarkReadRequest( - val messageIds: List, + val messageIds: List = emptyList(), + val markAll: Boolean = false, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt index 02ce619..e088e19 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt @@ -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? = null -) \ No newline at end of file + val files: List? = null, + val isRead: Boolean? = null, +) { + fun isReadByViewer(currentUserId: Int?): Boolean = + (currentUserId != null && senderId == currentUserId) || isRead == true +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt index c5dab30..c1bd8fb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt @@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable @Serializable data class DmMarkReadRequest( - val upToEnvelopeId: Int? = null, + val messageIds: List? = null, + val markAll: Boolean = false, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt index d300df3..350bc50 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt @@ -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) = + MessageNotificationCoordinator.dismissPublicIfMessageRead(messageIds) + + fun dismissDmIfMessageRead(peerUserId: Int, messageIds: Collection) = + MessageNotificationCoordinator.dismissDmIfMessageRead(peerUserId, messageIds) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.kt new file mode 100644 index 0000000..ce8a928 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/MessageNotificationCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/MessageNotificationCoordinator.kt new file mode 100644 index 0000000..9663641 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/MessageNotificationCoordinator.kt @@ -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() + private val recentlyDeleted = ArrayDeque() + 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) { + 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) { + 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, 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) { + 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() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.kt new file mode 100644 index 0000000..de0c9d7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.kt @@ -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() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index ba2fb6e..e601e46 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index d28e0cd..5fa0a4c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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) + } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt index a1e7777..4b0ff7e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt @@ -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("otherUserId")?.toIntOrNull() + val currentId = when (val raw = entry.savedStateHandle.get("otherUserId")) { + is Int -> raw + is Long -> raw.toInt() + is String -> raw.toIntOrNull() + else -> null + } return currentId == otherUserId } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index e737036..88046b5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt index 4d8d44b..6448413 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt index 0e19e61..a70ef1b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentDownloadVisibility.kt @@ -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, + topClearancePx: Int, + bottomClearancePx: Int, +): Set { + 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() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt index bc27c6c..562258a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainDetailDestinations.kt @@ -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("otherUserId")?.toIntOrNull() ?: 0 - val sourceMessageId = entry.savedStateHandle.get("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("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(name)) { + is Int -> raw + is Long -> raw.toInt() + is String -> raw.toIntOrNull() ?: 0 + else -> 0 + } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index c385394..9724d88 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -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(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 7e0d138..7c6e015 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -1224,7 +1224,7 @@ fun ChatsTab( selectedOtherUserIds = selectedOtherUserIds, isReadOnly = suspensionState.isSuspended, callsEnabled = callsEnabled, - publicHasUnread = false, + publicHasUnread = (publicChatPreviewState?.unreadCount ?: 0) > 0, ) } else { null diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt index d2c1f4b..2f0d148 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.kt @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index 28a7258..de27294 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -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 diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.ios.kt deleted file mode 100644 index 8811b42..0000000 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.ios.kt +++ /dev/null @@ -1,5 +0,0 @@ -package ru.fromchat.notifications - -actual object ChatNotificationDismissals { - actual fun dismissAllMessageNotifications() = Unit -} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.ios.kt new file mode 100644 index 0000000..c1a3754 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.ios.kt @@ -0,0 +1,3 @@ +package ru.fromchat.notifications + +internal actual fun notifyIncomingCallIfBackground(callerDisplayName: String) = Unit diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.ios.kt new file mode 100644 index 0000000..88398d2 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.ios.kt @@ -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 +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt index cecc142..3ea1ab5 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.ios.kt @@ -13,4 +13,12 @@ actual fun openAppNotificationSettings(): Boolean { actual fun areAppNotificationsEnabled(): Boolean = false -actual fun arePushNotificationsSupported(): Boolean = false \ No newline at end of file +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 \ No newline at end of file diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAppVisibility.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAppVisibility.kt new file mode 100644 index 0000000..2f266eb --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopAppVisibility.kt @@ -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 +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotificationSettings.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotificationSettings.kt new file mode 100644 index 0000000..09c0441 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotificationSettings.kt @@ -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) +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt index ab490ff..96b4919 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopNotifier.kt @@ -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() + + 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) diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopTaskbarBadge.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopTaskbarBadge.kt new file mode 100644 index 0000000..4591514 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/DesktopTaskbarBadge.kt @@ -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}") + } + } +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/MacNotificationCenter.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/MacNotificationCenter.kt new file mode 100644 index 0000000..f493a49 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/desktop/MacNotificationCenter.kt @@ -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) + + @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 +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.jvm.kt deleted file mode 100644 index 8811b42..0000000 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.jvm.kt +++ /dev/null @@ -1,5 +0,0 @@ -package ru.fromchat.notifications - -actual object ChatNotificationDismissals { - actual fun dismissAllMessageNotifications() = Unit -} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.jvm.kt new file mode 100644 index 0000000..cbeb1f9 --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/IncomingCallNotification.jvm.kt @@ -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) +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.jvm.kt new file mode 100644 index 0000000..023b39f --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/notifications/MessageNotificationSink.jvm.kt @@ -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}") + } + } +} diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt index 41dadc4..c538905 100644 --- a/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/ui/main/settings/NotificationsScreen.jvm.kt @@ -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")