diff --git a/app/android/src/main/kotlin/ru/fromchat/App.kt b/app/android/src/main/kotlin/ru/fromchat/App.kt index 8ffdbd3..25ce532 100644 --- a/app/android/src/main/kotlin/ru/fromchat/App.kt +++ b/app/android/src/main/kotlin/ru/fromchat/App.kt @@ -2,11 +2,7 @@ package ru.fromchat import android.app.Application import android.util.Log -import com.google.firebase.messaging.FirebaseMessaging import com.pr0gramm3r101.utils.UtilsLibrary -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -16,16 +12,23 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient import ru.fromchat.api.WebSocketManager -import ru.fromchat.core.config.Config +import ru.fromchat.fcm.ensureFcmTokenRegistered import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import ru.fromchat.notifications.NotificationHelper class App: Application() { @OptIn(DelicateCoroutinesApi::class) - private fun fetchAndNotify() { + private fun fetchAndNotify( + includeDmMessages: Boolean = false, + dmMessageId: Int? = null + ) { GlobalScope.launch(Dispatchers.IO) { runCatching { - NotificationHelper.fetchAndNotify(applicationContext) + NotificationHelper.fetchAndNotify( + applicationContext, + includeDmMessages = includeDmMessages, + dmMessageId = dmMessageId + ) } } } @@ -39,23 +42,50 @@ class App: Application() { runCatching { if (msg.type == "newMessage") { fetchAndNotify() + } else if (msg.type == "dmNew") { + val dmMessageId = msg.data?.jsonObject?.get("id")?.jsonPrimitive?.content?.toIntOrNull() + fetchAndNotify( + includeDmMessages = true, + dmMessageId = dmMessageId + ) } else if (msg.type == "updates") { msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates -> - var shouldFetch = false + var shouldFetchPublic = false + var shouldFetchDm = false + var latestDmMessageId: Int? = null + for (item in updates) { - if ( - item - .jsonObject["type"] + val type = item + .jsonObject["type"] + ?.jsonPrimitive + ?.content + + if (type == "newMessage") { + shouldFetchPublic = true + continue + } + + if (type == "dmNew") { + shouldFetchDm = true + val envelopeId = item + .jsonObject["data"] + ?.jsonObject + ?.get("id") ?.jsonPrimitive ?.content - in arrayOf("newMessage", "dmNew") - ) { - shouldFetch = true - break + ?.toIntOrNull() + if (envelopeId != null) { + latestDmMessageId = envelopeId.coerceAtLeast(latestDmMessageId ?: 0) + } } } - if (shouldFetch) fetchAndNotify() + if (shouldFetchPublic || shouldFetchDm) { + fetchAndNotify( + includeDmMessages = shouldFetchDm, + dmMessageId = latestDmMessageId + ) + } } } } @@ -72,40 +102,9 @@ class App: Application() { // If we have an auth token, try to get current FCM token and register it immediately runCatching { - val auth = ApiClient.token - if (!auth.isNullOrEmpty()) { - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - GlobalScope.launch(Dispatchers.IO) { - if (task.isSuccessful) { - try { - val resp = ApiClient.http.post( - "${Config.apiBaseUrl}/push/register" - ) { - header("Content-Type", "application/json") - setBody( - ApiClient.json.encodeToString( - mapOf("token" to task.result) - ) - ) - } - Log.d( - "AppFCM", - "Registered existing FCM token on startup, status=${resp.status.value}" - ) - } catch (e: Exception) { - Log.e( - "AppFCM", - "Failed to register FCM token on startup: ${e.message}" - ) - } - } else { - Log.w( - "AppFCM", - "FirebaseMessaging token fetch failed on startup: ${task.exception?.message}" - ) - } - } - } + val isRegistered = ensureFcmTokenRegistered() + if (!isRegistered) { + Log.d("AppFCM", "FCM token registration skipped or deferred") } } } diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt index 51fbc60..a8ba335 100644 --- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt +++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt @@ -1,5 +1,4 @@ package ru.fromchat - import android.Manifest import android.content.Intent import android.os.Build @@ -30,18 +29,34 @@ import ru.fromchat.core.config.Config import ru.fromchat.ui.App import ru.fromchat.ui.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" + class MainActivity : ComponentActivity() { private var scrollToMessageId by mutableStateOf(null) private var startAtPublicChat by mutableStateOf(false) + private var startAtDmConversationUserId by mutableStateOf(null) private var prevIsPublicChatVisible: Boolean? = null private fun handleIntent(intent: Intent?) { - val messageId = intent?.getIntExtra("scroll_to_message_id", -1) ?: -1 + val messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1 + val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC + val dmConversationUserId = intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1) ?: -1 + scrollToMessageId = if (messageId != -1) messageId else null - startAtPublicChat = messageId != -1 + startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM + startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) { + dmConversationUserId + } else { + null + } // Mark messages as read if clicked from notification - if (intent?.getBooleanExtra("mark_message_read", false) == true) { + if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) { markMessagesAsRead() } } @@ -101,7 +116,8 @@ class MainActivity : ComponentActivity() { setContent { App( scrollToMessageId = scrollToMessageId, - startAtPublicChat = startAtPublicChat + startAtPublicChat = startAtPublicChat, + startAtDmConversationUserId = startAtDmConversationUserId ) } diff --git a/app/android/src/main/kotlin/ru/fromchat/fcm/FcmRegistrationAndroid.kt b/app/android/src/main/kotlin/ru/fromchat/fcm/FcmRegistrationAndroid.kt deleted file mode 100644 index 463d2c7..0000000 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FcmRegistrationAndroid.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ru.fromchat.fcm - -import android.util.Log -import com.pr0gramm3r101.utils.settings.settings -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import ru.fromchat.api.ApiClient -import ru.fromchat.core.config.Config - -suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) { - try { - val pending = settings.getString("pending_fcm_token", "") - - // Only upload if we have auth token - if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) { - Log.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload") - return@withContext - } - - try { - ApiClient.http.post("${Config.apiBaseUrl}/push/register") { - header("Content-Type", "application/json") - setBody(ApiClient.json.encodeToString(mapOf("token" to pending))) - } - - settings.remove("pending_fcm_token") - } catch (e: Exception) { - Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}") - } - } catch (e: Exception) { - Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}") - } -} 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 b48ca85..5c1c658 100644 --- a/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt +++ b/app/android/src/main/kotlin/ru/fromchat/fcm/FromChatFirebaseMessagingService.kt @@ -4,24 +4,55 @@ import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.pr0gramm3r101.utils.settings.settings -import io.ktor.client.request.post -import io.ktor.client.request.setBody import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient -import ru.fromchat.core.config.Config import ru.fromchat.notifications.NotificationHelper +import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable @OptIn(DelicateCoroutinesApi::class) class FromChatFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d("FromChatFCM", "onMessageReceived: from=${remoteMessage.from}, data=${remoteMessage.data}") + Log.i("FromChatFCM", "onMessageReceived: from=${remoteMessage.from} dataSize=${remoteMessage.data.size}") + Log.d("FromChatFCM", "onMessageReceived data=${remoteMessage.data}") GlobalScope.launch(Dispatchers.IO) { try { - NotificationHelper.fetchAndNotify(applicationContext) + val pushData = remoteMessage.data + val fallbackMessageId = pushData["message_id"]?.toIntOrNull() + ?: pushData["dm_id"]?.toIntOrNull() + val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"] + val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" + val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" + val messageType = pushData["type"] ?: "public_message" + val isDirectMessage = messageType.equals("dm", ignoreCase = true) + if (ApiClient.token.isNullOrBlank()) { + Log.w("FromChatFCM", "No auth token in memory; loading persisted data before handling push") + ApiClient.loadPersistedData() + Log.d("FromChatFCM", "Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}") + } + if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) { + NotificationHelper.showFallbackPushNotification( + applicationContext, + title, + body, + sender, + fallbackMessageId, + messageType == "dm" + ) + } + if (isDirectMessage) { + NotificationHelper.fetchAndNotify( + applicationContext, + includeDmMessages = true, + dmMessageId = fallbackMessageId, + dmSenderName = sender, + ) + } else { + NotificationHelper.fetchAndNotify(applicationContext) + } } catch (e: Exception) { Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e) } @@ -31,25 +62,10 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { Log.d("FromChatFCM", "onNewToken: $token") GlobalScope.launch(Dispatchers.IO) { - // Upload token to backend if authenticated, otherwise save locally (TODO: persist and upload on login) try { - val authToken = ApiClient.token - if (!authToken.isNullOrEmpty()) { - // Call backend endpoint to register token - try { - val resp = ApiClient.http.post("${Config.apiBaseUrl}/push/register") { - setBody(ApiClient.json.encodeToString(mapOf("token" to token))) - } - Log.d("FromChatFCM", "Uploaded FCM token to server: ${resp.status.value}") - } catch (e: Exception) { - Log.e("FromChatFCM", "Failed to upload token: ${e.message}", e) - } - } else { - // Save to shared preferences for later upload (best-effort) - runCatching { - settings.putString("pending_fcm_token", token) - } - } + settings.putString("pending_fcm_token", token) + uploadPendingFcmTokenIfAvailable() + Log.d("FromChatFCM", "FCM token queued or uploaded for this app instance") } catch (e: Exception) { Log.e("FromChatFCM", "onNewToken upload 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 index c97debf..0938167 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -17,6 +17,7 @@ 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.DelicateCoroutinesApi @@ -26,25 +27,52 @@ import kotlinx.coroutines.launch import ru.fromchat.MainActivity import ru.fromchat.R import ru.fromchat.api.ApiClient +import ru.fromchat.api.DmHistoryResponse import ru.fromchat.api.Message import ru.fromchat.api.MessagesResponse import ru.fromchat.core.config.Config +import ru.fromchat.crypto.CorruptedDmMessagePlaceholder +import ru.fromchat.crypto.DmCiphertextCorruptedException +import ru.fromchat.crypto.decryptEnvelope import ru.fromchat.ui.isPublicChatVisible 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 SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary 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" const val KEY_TEXT_REPLY = "key_text_reply" - private fun createMessageIntent(context: Context, messageId: Int) = PendingIntent.getActivity( + fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID + + private fun createMessageIntent( + context: Context, + messageId: Int, + targetDmUserId: Int? = null, + markMessageRead: Boolean = true + ) = PendingIntent.getActivity( context, - messageId, + if (targetDmUserId != null) -messageId else messageId, Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra("scroll_to_message_id", messageId) - putExtra("mark_message_read", true) + 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 ) @@ -52,12 +80,26 @@ object NotificationHelper { private fun notificationReplyAction(context: Context) = "${context.packageName}.NOTIFICATION_REPLY" - private fun createReplyIntent(context: Context) = PendingIntent.getBroadcast( + private fun createReplyIntent( + context: Context, + isDirectMessage: Boolean = false, + targetDmUserId: Int? = null, + parentMessageId: Int? = null + ) = PendingIntent.getBroadcast( context, - SUMMARY_NOTIFICATION_ID, + 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 ) @@ -79,28 +121,300 @@ object NotificationHelper { } } - suspend fun fetchAndNotify(context: Context) { + suspend fun fetchAndNotify( + context: Context, + includeDmMessages: Boolean = false, + dmMessageId: Int? = null, + dmSenderName: String? = null + ) { Log.d("NotificationHelper", "fetchAndNotify: starting fetch") try { + val currentUserId = settings.getInt("current_user_id", -1) + Log.d( + "NotificationHelper", + "fetchAndNotify: currentUserId=$currentUserId hasToken=${ApiClient.token?.isNotBlank() ?: false}" + ) + if (currentUserId == -1) { + Log.w("NotificationHelper", "fetchAndNotify: missing currentUserId, skipping push sync") + return + } + val messages = ApiClient.http .get("${Config.apiBaseUrl}/messages/new") .body() .messages - Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} messages") - if (messages.isEmpty()) return + Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages") + if (messages.isNotEmpty()) { + settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) + CoroutineScope(Dispatchers.Main).launch { + createChannel(context) + displayNotifications(context, messages) + } + } else { + Log.d("NotificationHelper", "fetchAndNotify: no public messages returned") + } - settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) - - // Display notifications on main thread - CoroutineScope(Dispatchers.Main).launch { - createChannel(context) - displayNotifications(context, messages) + if (includeDmMessages) { + fetchAndNotifyDirectMessages(context, currentUserId, dmMessageId, dmSenderName) } } catch (e: Exception) { + if (e is ClientRequestException && e.response.status.value == 401) { + try { + Log.w("NotificationHelper", "fetchAndNotify: received 401; reloading token and retrying") + ApiClient.loadPersistedData() + val retryMessages = ApiClient.http + .get("${Config.apiBaseUrl}/messages/new") + .body() + .messages + Log.d( + "NotificationHelper", + "fetchAndNotify retry: fetched ${retryMessages.size} public messages" + ) + if (retryMessages.isNotEmpty()) { + CoroutineScope(Dispatchers.Main).launch { + createChannel(context) + displayNotifications(context, retryMessages) + } + } + if (includeDmMessages) { + fetchAndNotifyDirectMessages(context, settings.getInt("current_user_id", -1), dmMessageId, dmSenderName) + } + return + } catch (_: Exception) { + Log.e("NotificationHelper", "fetchAndNotify retry failed", e) + } + } Log.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) { + Log.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 + } + Log.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 + Log.d("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) + + 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) { + Log.d( + "NotificationHelper", + "Direct notification skipped: already shown envelopeId=$envelopeId" + ) + return@forEach + } + + val plaintext = runCatching { + decryptEnvelope(envelope, currentUserId) + }.getOrElse { throwable -> + when (throwable) { + is DmCiphertextCorruptedException -> { + Log.w( + "NotificationHelper", + "DM decrypt failed for envelopeId=$envelopeId" + ) + CorruptedDmMessagePlaceholder + } + + else -> { + Log.w( + "NotificationHelper", + "DM decrypt failed for envelopeId=$envelopeId: ${throwable.message}", + throwable + ) + "Encrypted message" + } + } + } + + val senderName = if ( + envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() + ) { + dmSenderName + } else if (!envelope.senderUsername.isNullOrBlank()) { + envelope.senderUsername + } else { + "User ${envelope.senderId}" + } + val dmConversationUserId = envelope.senderId + + showFallbackPushNotification( + context = context, + title = "Direct message from $senderName", + body = plaintext, + sender = senderName, + messageId = envelopeId, + allowWhenPublicChatVisible = true, + isDirectMessage = true, + targetDmUserId = dmConversationUserId, + conversationTitle = "Direct Messages" + ) + 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 = "Public Chat" + ) { + CoroutineScope(Dispatchers.Main).launch { + createChannel(context) + + if (isPublicChatVisible && !allowWhenPublicChatVisible) { + Log.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 + ) { + Log.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)) { + Log.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" + notify( + SUMMARY_NOTIFICATION_ID, + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.logo_big) + .setContentTitle(title) + .setContentText(body) + .setStyle( + NotificationCompat.MessagingStyle( + Person.Builder().setName("FromChat").build() + ).setConversationTitle(conversationTitle).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, + "Reply", + createReplyIntent( + context = context, + isDirectMessage = isDirectMessage, + targetDmUserId = targetDmUserId, + parentMessageId = messageId + ) + ) + .addRemoteInput( + RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel("Reply to chat...") + .build() + ) + .setAllowGeneratedReplies(true) + .build() + ) + .setContentIntent( + createMessageIntent( + context = context, + messageId = messageId ?: 0, + targetDmUserId = targetDmUserId, + markMessageRead = !isDirectMessage + ) + ) + .build() + ) + settings.putStringSet(PREF_SHOWN_KEY, shown) + Log.d( + "NotificationHelper", + "Fallback push notification shown title=$title sender=$senderName messageId=$messageId" + ) + } + } + } @OptIn(DelicateCoroutinesApi::class) private fun displayNotifications(context: Context, messages: List) { Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages") @@ -127,15 +441,24 @@ object NotificationHelper { if (currentUserId == -1) return@launch - val newMessages = messages - .filter { msg -> - !shown.contains(msg.id.toString()) && // Not already shown - msg.user_id != currentUserId // Not from current user - } - .ifEmpty { return@launch } - .apply { forEach { shown.add(it.id.toString()) } } + val newMessages = messages.filter { msg -> + !shown.contains(msg.id.toString()) && // Not already shown + msg.user_id != currentUserId // Not from current user + } + if (newMessages.isEmpty()) { + Log.d( + "NotificationHelper", + "displayNotifications: no new messages after filters for user=$currentUserId" + ) + return@launch + } + newMessages.apply { forEach { shown.add(it.id.toString()) } } newMessageCount = newMessages.size + Log.d( + "NotificationHelper", + "displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}" + ) notify( SUMMARY_NOTIFICATION_ID, @@ -169,12 +492,16 @@ object NotificationHelper { .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(Notification.CATEGORY_MESSAGE) .setAutoCancel(true) - .addAction( - NotificationCompat.Action.Builder( - android.R.drawable.ic_menu_send, - "Reply", - createReplyIntent(context) + .addAction( + NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_send, + "Reply", + createReplyIntent( + context = context, + isDirectMessage = false, + parentMessageId = newMessages.last().id ) + ) .addRemoteInput( RemoteInput.Builder(KEY_TEXT_REPLY) .setLabel("Reply to chat...") @@ -183,19 +510,14 @@ object NotificationHelper { .setAllowGeneratedReplies(true) .build() ) - .addAction( - NotificationCompat.Action.Builder( - android.R.drawable.ic_menu_view, - "View Chat", - createMessageIntent( - context, - newMessages.last().id - ) - ).build() - ) .setContentIntent(createMessageIntent(context, newMessages.last().id)) .build() ) + } else { + Log.w( + "NotificationHelper", + "displayNotifications: POST_NOTIFICATIONS permission missing, skipping" + ) } } 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 806adc3..e2a5208 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationReplyReceiver.kt @@ -5,27 +5,77 @@ import android.content.Context import android.content.Intent import android.util.Log import androidx.core.app.RemoteInput +import androidx.core.app.NotificationManagerCompat import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient +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 CHAT_TYPE_PUBLIC = "public" +private const val CHAT_TYPE_DM = "dm" + @OptIn(DelicateCoroutinesApi::class) class NotificationReplyReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - RemoteInput.getResultsFromIntent(intent)?.getCharSequence("key_text_reply")?.toString()?.let { - if (it.isNotBlank()) { - Log.d("NotificationReply", "Received reply: $it") + Log.e( + "NotificationReply", + "onReceive called action=${intent.action} extras=${intent.extras?.keySet()?.joinToString()}" + ) - GlobalScope.launch(Dispatchers.IO) { - try { - ApiClient.sendMessage(it) - Log.d("NotificationReply", "Reply sent successfully") - } catch (e: Exception) { - Log.e("NotificationReply", "Failed to send reply", e) - } + val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input -> + (input.getCharSequence(NotificationHelper.KEY_TEXT_REPLY) + ?: input.getCharSequence("key_text_reply")) + } + ?.toString() + ?.trim() + ?: run { + Log.w("NotificationReply", "No inline reply text found") + return + } + + if (replyText.isBlank()) { + Log.w("NotificationReply", "Inline reply text is blank") + return + } + + val chatType = intent.getStringExtra(EXTRA_REPLY_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC + val targetDmUserId = intent.getIntExtra(EXTRA_REPLY_DM_USER_ID, -1) + val parentMessageId = intent.getIntExtra(EXTRA_REPLY_PARENT_MESSAGE_ID, -1).takeIf { it > 0 } + + Log.d("NotificationReply", "Received reply for $chatType: $replyText") + NotificationManagerCompat.from(context).cancel(NotificationHelper.summaryNotificationId()) + + GlobalScope.launch(Dispatchers.IO) { + try { + if (ApiClient.token.isNullOrBlank()) { + ApiClient.loadPersistedData() } + + when (chatType) { + CHAT_TYPE_DM -> { + if (targetDmUserId > 0) { + ApiClient.sendDm( + recipientId = targetDmUserId, + plaintext = replyText, + replyToId = parentMessageId + ) + } else { + Log.w("NotificationReply", "Received DM reply without recipient id; skipping send") + } + } + + else -> ApiClient.sendMessageViaHttp( + content = replyText, + replyToId = parentMessageId + ) + } + Log.d("NotificationReply", "Reply dispatch attempt completed for $chatType") + } catch (e: Exception) { + Log.w("NotificationReply", "Failed to send reply", e) } } } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 7c56153..9015818 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -83,6 +83,7 @@ kotlin { androidMain.dependencies { implementation(libs.ktor.client.okhttp) + implementation(libs.firebase.messaging) implementation(libs.androidx.activity.compose) implementation(libs.androidx.work.runtime.ktx) implementation(libs.tweetnacl.java) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt index f9d98d4..ce50950 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/fcm/FcmRegistration.android.kt @@ -1,18 +1,58 @@ package ru.fromchat.fcm import android.util.Log +import com.google.firebase.messaging.FirebaseMessaging import com.pr0gramm3r101.utils.settings.settings +import com.google.android.gms.tasks.Task +import io.ktor.client.call.body import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import ru.fromchat.api.SimpleStatusResponse import ru.fromchat.api.ApiClient import ru.fromchat.core.config.Config +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token" +private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token" + +private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutine { cont -> + FirebaseMessaging.getInstance().token + .addOnCompleteListener { task: Task -> + if (task.isSuccessful) { + cont.resume(task.result) + } else { + cont.resumeWithException( + task.exception ?: IllegalStateException("Failed to fetch FCM token") + ) + } + } +} + +private suspend fun postFcmToken(token: String): Boolean { + return runCatching { + val suffix = token.takeLast(8) + ApiClient.http + .post("${Config.apiBaseUrl}/push/register") { + header("Content-Type", "application/json") + setBody(ApiClient.json.encodeToString(mapOf("token" to token))) + } + .body() + Log.d("FcmReg", "Uploaded FCM token to server: ...$suffix") + true + }.getOrElse { e -> + Log.e("FcmReg", "Failed to upload FCM token: ${e.message}", e) + false + } +} actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) { try { - val pending = settings.getString("pending_fcm_token", "") + val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "") // Only upload if we have auth token if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) { @@ -20,17 +60,66 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers. return@withContext } - try { - ApiClient.http.post("${Config.apiBaseUrl}/push/register") { - header("Content-Type", "application/json") - setBody(ApiClient.json.encodeToString(mapOf("token" to pending))) - } - - settings.remove("pending_fcm_token") - } catch (e: Exception) { - Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}") + if (postFcmToken(pending)) { + settings.putString(CURRENT_FCM_TOKEN_KEY, pending) + settings.remove(PENDING_FCM_TOKEN_KEY) + } else { + Log.d("FcmReg", "Deferring pending FCM token upload") } } catch (e: Exception) { Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}") } } + +actual suspend fun ensureFcmTokenRegistered(): Boolean = withContext(Dispatchers.IO) { + if (ApiClient.token.isNullOrEmpty()) { + Log.d("FcmReg", "Auth token missing; skip explicit FCM sync") + return@withContext false + } + + try { + uploadPendingFcmTokenIfAvailable() + val token = fetchCurrentFcmToken() ?: return@withContext false + val prepared = token.trim() + if (prepared.isBlank()) return@withContext false + + val current = settings.getString(CURRENT_FCM_TOKEN_KEY, "") + if (prepared == current) return@withContext true + + val result = postFcmToken(prepared) + if (result) { + settings.putString(CURRENT_FCM_TOKEN_KEY, prepared) + settings.remove(PENDING_FCM_TOKEN_KEY) + } + result + } catch (e: Exception) { + Log.e("FcmReg", "ensureFcmTokenRegistered error: ${e.message}") + false + } +} + +actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatchers.IO) { + if (ApiClient.token.isNullOrEmpty()) { + Log.d("FcmReg", "Auth token missing; cannot unregister FCM token") + return@withContext false + } + + val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim() + Log.d("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}") + return@withContext runCatching { + ApiClient.http.post("${Config.apiBaseUrl}/push/unregister") { + header("Content-Type", "application/json") + if (token.isNotEmpty()) { + setBody(ApiClient.json.encodeToString(mapOf("token" to token))) + } + } + settings.remove(PENDING_FCM_TOKEN_KEY) + if (token.isNotBlank()) { + settings.remove(CURRENT_FCM_TOKEN_KEY) + } + true + }.getOrElse { e -> + Log.e("FcmReg", "Failed to unregister FCM token: ${e.message}") + false + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt index b3a9fb0..6b518a4 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt @@ -1,8 +1,12 @@ package ru.fromchat.platform import android.content.Intent +import android.Manifest +import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import android.provider.Settings import com.pr0gramm3r101.utils.UtilsLibrary.context @@ -24,3 +28,16 @@ actual fun openAppNotificationSettings(): Boolean = } catch (_: Exception) { false } + +actual fun areAppNotificationsEnabled(): Boolean { + if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return false + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 53cc92d..8738e92 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -133,6 +133,11 @@ Уведомления Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона. Открыть настройки уведомлений + Включить push-уведомления + Отключить push-уведомления + Откройте системные настройки и разрешите уведомления. + Push-уведомления включены + Push-уведомления отключены Устройства Нет активных сессий diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index d588e3e..8609e0a 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -163,6 +163,11 @@ Notifications To allow or block alerts from FromChat, use your phone’s notification settings. Open notification settings + Enable push notifications + Disable push notifications + Open system notification settings to allow alerts. + Push notifications enabled + Push notifications disabled Devices No active sessions 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 063034b..c90f036 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -41,6 +41,7 @@ import ru.fromchat.crypto.IdentityKeyManager import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.platform.currentDeviceInfo +import ru.fromchat.fcm.unregisterFcmTokenFromServer /** * Creates a platform-specific HTTP client that supports WebSockets @@ -267,6 +268,15 @@ object ApiClient { .body() .conversations + suspend fun getDmFetch(since: Int? = null): DmHistoryResponse { + return http + .get("${Config.apiBaseUrl}/dm/fetch") { + contentType(ContentType.Application.Json) + since?.let { parameter("since", it) } + } + .body() + } + suspend fun getDmHistory( otherUserId: Int, limit: Int = 50, @@ -510,6 +520,32 @@ object ApiClient { } } + suspend fun registerFcmToken(token: String): SimpleStatusResponse { + return http + .post("${Config.apiBaseUrl}/push/register") { + contentType(ContentType.Application.Json) + setBody(FcmTokenRequest(token = token)) + } + .body() + } + + suspend fun unregisterFcmToken(token: String? = null): SimpleStatusResponse { + return if (token.isNullOrBlank()) { + http + .post("${Config.apiBaseUrl}/push/unregister") { + contentType(ContentType.Application.Json) + } + .body() + } else { + http + .post("${Config.apiBaseUrl}/push/unregister") { + contentType(ContentType.Application.Json) + setBody(FcmTokenRequest(token = token)) + } + .body() + } + } + suspend fun changePassword( currentPasswordDerived: String, newPasswordDerived: String, @@ -556,6 +592,8 @@ object ApiClient { secureSettings.remove("auth_token") settings.remove("user_info") settings.remove("current_user_id") + settings.remove("pending_fcm_token") + settings.remove("current_fcm_token") token = null user = null uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } @@ -567,14 +605,20 @@ object ApiClient { } suspend fun logout() { - runCatching { - http.get("${Config.apiBaseUrl}/logout") - } + runCatching { http.get("${Config.apiBaseUrl}/logout") } + runCatching { unregisterFcmTokenFromServer() } clearLocalSession() } fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") + suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { + http.post("${Config.apiBaseUrl}/send_message") { + contentType(ContentType.Application.Json) + setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) + } + } + // WebSocket send helpers suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) { WebSocketManager.send( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 662d6b2..c8f5931 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -234,6 +234,7 @@ data class DmEnvelope( val id: Int, val senderId: Int, val recipientId: Int, + @SerialName("sender_username") val senderUsername: String? = null, @SerialName("iv_b64") val ivB64: String, @SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, @@ -411,4 +412,9 @@ data class SimilarityResult( @Serializable data class VerifyResponse( val verified: Boolean +) + +@Serializable +data class FcmTokenRequest( + val token: String ) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt index 21b9a18..a3c6751 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/fcm/FcmRegistration.kt @@ -1,3 +1,15 @@ package ru.fromchat.fcm -expect suspend fun uploadPendingFcmTokenIfAvailable() \ No newline at end of file +expect suspend fun uploadPendingFcmTokenIfAvailable() + +/** + * Ensures a valid FCM token for the current user is registered with the server. + * Returns true when the token was sent successfully. + */ +expect suspend fun ensureFcmTokenRegistered(): Boolean + +/** + * Unregisters the local FCM token from the server for this user. + * Returns true when the unregister request succeeds. + */ +expect suspend fun unregisterFcmTokenFromServer(): Boolean \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt index 3a0a5b5..0dc1c05 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt @@ -5,3 +5,8 @@ package ru.fromchat.platform * @return true if an intent/URL was fired (best effort). */ expect fun openAppNotificationSettings(): Boolean + +/** + * Returns true when notifications are currently enabled for this app, including runtime permission. + */ +expect fun areAppNotificationsEnabled(): Boolean 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 b2c3e45..e15ba29 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -89,7 +89,11 @@ private fun NavGraphBuilder.settingsSlideComposable( } @Composable -fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { +fun App( + scrollToMessageId: Int? = null, + startAtPublicChat: Boolean = false, + startAtDmConversationUserId: Int? = null +) { setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) .components { @@ -120,6 +124,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { // Now determine start destination based on loaded token val hasToken = ApiClient.token?.isNotEmpty() == true startDestination = when { + hasToken && startAtDmConversationUserId != null -> "chat" hasToken && startAtPublicChat -> "chats/publicChat" hasToken && !startAtPublicChat -> "chat" else -> "login" @@ -156,9 +161,22 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { SharedTransitionLayout { val navController = rememberNavController() - // Handle navigation to public chat when requested (e.g., from notification) - LaunchedEffect(startAtPublicChat) { - if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { + // Handle navigation to the target chat when launched from notification + LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) { + if (startDestination == null || startDestination == "login") { + return@LaunchedEffect + } + + if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) { + navController.navigate( + DmNav.chatRoute( + otherUserId = startAtDmConversationUserId, + sourceMessageId = scrollToMessageId + ) + ) { + launchSingleTop = true + } + } else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { navController.navigate("chats/publicChat") { launchSingleTop = true } @@ -284,7 +302,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { composable( route = DmNav.CHAT_ROUTE, - arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), + arguments = listOf( + navArgument("otherUserId") { type = NavType.StringType }, + navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, + ), enterTransition = { when (initialState.destination.route) { DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) @@ -311,9 +332,11 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { }, ) { entry -> val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 + val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1 if (otherUserId <= 0) return@composable DmChatRoute( otherUserId = otherUserId, + scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null, navController = navController, sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this, @@ -373,14 +396,15 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { SettingsSecurityPasswordFlowScreen( onBack = { navController.navigateUp() }, onDonePopToHub = { - navController.popBackStack(SettingsRoutes.Security, inclusive = false) + navController.popBackStack() } ) } settingsSlideComposable(SettingsRoutes.Account, animationSpec) { SettingsAccountScreen( onBack = { navController.navigateUp() }, - onLogout = navigateToLoginClearingChat + onLogout = navigateToLoginClearingChat, + onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt index 911db6a..735c8cc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmNav.kt @@ -14,10 +14,16 @@ import ru.fromchat.ui.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" + const val CHAT_ROUTE = "dm/{otherUserId}/chat?sourceMessageId={sourceMessageId}" const val PROFILE_ROUTE = "dm/{otherUserId}/profile" - fun chatRoute(otherUserId: Int) = "dm/$otherUserId/chat" + fun chatRoute(otherUserId: Int, sourceMessageId: Int? = null): String { + return if (sourceMessageId != null && sourceMessageId > 0) { + "dm/$otherUserId/chat?sourceMessageId=$sourceMessageId" + } else { + "dm/$otherUserId/chat" + } + } fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile" } @@ -27,6 +33,7 @@ private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-" @Composable fun DmChatRoute( otherUserId: Int, + scrollToMessageId: Int? = null, navController: NavController, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, @@ -40,6 +47,7 @@ fun DmChatRoute( DmScreen( panel = panel, modifier = modifier.fillMaxSize(), + scrollToMessageId = scrollToMessageId, onTitleClick = { haptic(HapticFeedbackEvent.ProfileOpened) navController.navigate(DmNav.profileRoute(otherUserId)) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt index b20ad6b..1f5b9a7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt @@ -13,6 +13,7 @@ import ru.fromchat.ui.chat.ChatScreen @Composable fun DmScreen( panel: DmPanel, + scrollToMessageId: Int? = null, modifier: Modifier = Modifier, onTitleClick: (() -> Unit)? = null, hideTitleBarAvatar: Boolean = false, @@ -34,6 +35,7 @@ fun DmScreen( panel = panel, currentUserId = currentUserId, modifier = modifier.fillMaxSize(), + scrollToMessageId = scrollToMessageId, onTitleClick = onTitleClick, hideTitleBarAvatar = hideTitleBarAvatar, onAvatarSlotBounds = onAvatarSlotBounds, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt index e0d0952..f063596 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt @@ -15,10 +15,8 @@ fun SettingsTab() { val nav = LocalNavController.current SettingsHubScreen( onAppearance = { nav.navigate(SettingsRoutes.Appearance) }, - onServerTools = { nav.navigate(SettingsRoutes.ServerTools) }, onNotifications = { nav.navigate(SettingsRoutes.Notifications) }, onDevices = { nav.navigate(SettingsRoutes.Devices) }, - onSecurity = { nav.navigate(SettingsRoutes.Security) }, onAccount = { nav.navigate(SettingsRoutes.Account) }, onAbout = { nav.navigate("about") }, title = stringResource(Res.string.settings), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt index c713097..aa95965 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -58,7 +58,6 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.LaptopMac import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Palette @@ -149,6 +148,9 @@ import ru.fromchat.materialYou import ru.fromchat.materialYou_d import ru.fromchat.password_length_error import ru.fromchat.passwords_dont_match +import ru.fromchat.fcm.ensureFcmTokenRegistered +import ru.fromchat.fcm.unregisterFcmTokenFromServer +import ru.fromchat.platform.areAppNotificationsEnabled import ru.fromchat.platform.openAppNotificationSettings import ru.fromchat.platform.currentDeviceInfo import ru.fromchat.settings_account_delete @@ -197,8 +199,13 @@ import ru.fromchat.settings_devices_this_device import ru.fromchat.settings_devices_title import ru.fromchat.settings_new_password import ru.fromchat.settings_notifications_body +import ru.fromchat.settings_notifications_disable +import ru.fromchat.settings_notifications_enable import ru.fromchat.settings_notifications_title +import ru.fromchat.settings_notifications_permission_required import ru.fromchat.settings_open_notification_settings +import ru.fromchat.settings_push_notifications_disabled +import ru.fromchat.settings_push_notifications_enabled import ru.fromchat.settings_hub_about_sub import ru.fromchat.settings_next import ru.fromchat.settings_password_changed @@ -231,10 +238,8 @@ private fun SettingsListLeadingIcon(imageVector: ImageVector) { @Composable fun SettingsHubScreen( onAppearance: () -> Unit, - onServerTools: () -> Unit, onNotifications: () -> Unit, onDevices: () -> Unit, - onSecurity: () -> Unit, onAccount: () -> Unit, onAbout: () -> Unit, title: String, @@ -263,31 +268,21 @@ fun SettingsHubScreen( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { ListItem( - headline = stringResource(Res.string.settings_category_appearance), - supportingText = stringResource(Res.string.settings_category_appearance_d), - onClick = onAppearance, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Palette) }, + headline = stringResource(Res.string.settings_category_account), + supportingText = stringResource(Res.string.settings_category_account_d), + onClick = onAccount, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) }, divider = true, dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness - ) - ListItem( - headline = stringResource(Res.string.settings_category_server_tools), - supportingText = stringResource(Res.string.settings_category_server_tools_d), - onClick = onServerTools, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness - ) - ListItem( - headline = stringResource(Res.string.settings_category_notifications), - supportingText = stringResource(Res.string.settings_category_notifications_d), - onClick = onNotifications, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Notifications) }, - divider = true, - dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness + dividerThickness = SettingsSurfaceCutDividerThickness, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } ) ListItem( headline = stringResource(Res.string.settings_category_devices), @@ -296,22 +291,49 @@ fun SettingsHubScreen( leadingContent = { SettingsListLeadingIcon(Icons.Filled.Devices) }, divider = true, dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness + dividerThickness = SettingsSurfaceCutDividerThickness, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } ) ListItem( - headline = stringResource(Res.string.settings_category_security), - supportingText = stringResource(Res.string.settings_category_security_d), - onClick = onSecurity, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.Lock) }, + headline = stringResource(Res.string.settings_category_appearance), + supportingText = stringResource(Res.string.settings_category_appearance_d), + onClick = onAppearance, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Palette) }, divider = true, dividerColor = settingsSurfaceCutDividerColor(), - dividerThickness = SettingsSurfaceCutDividerThickness + dividerThickness = SettingsSurfaceCutDividerThickness, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } ) ListItem( - headline = stringResource(Res.string.settings_category_account), - supportingText = stringResource(Res.string.settings_category_account_d), - onClick = onAccount, - leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) } + headline = stringResource(Res.string.settings_category_notifications), + supportingText = stringResource(Res.string.settings_category_notifications_d), + onClick = onNotifications, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Notifications) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } ) } Spacer(Modifier.height(20.dp)) @@ -319,37 +341,20 @@ fun SettingsHubScreen( modifier = Modifier.padding(bottom = 24.dp), containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { - val aboutInteraction = remember { MutableInteractionSource() } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = aboutInteraction, - indication = LocalIndication.current, - onClick = onAbout - ) - .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SettingsListLeadingIcon(Icons.Filled.Info) - Column(Modifier.padding(horizontal = 16.dp).weight(1f)) { - Text( - text = stringResource(Res.string.about), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = stringResource(Res.string.settings_hub_about_sub), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + ListItem( + headline = stringResource(Res.string.about), + supportingText = stringResource(Res.string.settings_hub_about_sub), + onClick = onAbout, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Info) }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + ) } } } @@ -542,8 +547,27 @@ fun SettingsSecurityHubScreen(onBack: () -> Unit, onChangePassword: () -> Unit) @Composable fun SettingsNotificationsScreen(onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + var isUpdating by remember { mutableStateOf(false) } + var notificationsEnabled by remember { mutableStateOf(areAppNotificationsEnabled()) } + val pushNotificationsEnabledText = stringResource(Res.string.settings_push_notifications_enabled) + val pushNotificationsDisabledText = stringResource(Res.string.settings_push_notifications_disabled) + val notificationsEnableText = stringResource(Res.string.settings_notifications_enable) + val notificationsDisableText = stringResource(Res.string.settings_notifications_disable) + val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required) + val unexpectedErrorText = stringResource(Res.string.error_unexpected) Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { + Snackbar( + snackbarData = it, + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface + ) + } + }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MediumTopAppBar( @@ -575,6 +599,62 @@ fun SettingsNotificationsScreen(onBack: () -> Unit) { .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 8.dp) ) + Text( + text = stringResource( + if (notificationsEnabled) { + Res.string.settings_push_notifications_enabled + } else { + Res.string.settings_push_notifications_disabled + } + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) + ) + FilledTonalButton( + onClick = { + coroutineScope.launch { + isUpdating = true + if (!areAppNotificationsEnabled()) { + val opened = openAppNotificationSettings() + if (!opened) { + snackbarHostState.showSnackbar(message = unexpectedErrorText) + } else { + snackbarHostState.showSnackbar(message = notificationsPermissionText) + } + isUpdating = false + return@launch + } + + val success = if (notificationsEnabled) { + unregisterFcmTokenFromServer() + } else { + ensureFcmTokenRegistered() + } + + if (success) { + notificationsEnabled = !notificationsEnabled + } else { + snackbarHostState.showSnackbar(message = unexpectedErrorText) + } + isUpdating = false + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + enabled = !isUpdating + ) { + Text( + text = if (notificationsEnabled) { + notificationsDisableText + } else { + notificationsEnableText + } + ) + } FilledTonalButton( onClick = { openAppNotificationSettings() }, modifier = Modifier @@ -1802,7 +1882,7 @@ private fun SecurityPasswordStepPage( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit) { +fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -1834,6 +1914,23 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit) { Modifier.padding(top = 16.dp), containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { + ListItem( + headline = stringResource(Res.string.settings_change_password), + supportingText = stringResource(Res.string.settings_security_change_password_sub), + onClick = onChangePassword, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Key) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) ListItem( headline = stringResource(Res.string.logout), onClick = { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt index f089cc2..28ad1dd 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/fcm/FcmRegistration.ios.kt @@ -1,5 +1,15 @@ package ru.fromchat.fcm actual suspend fun uploadPendingFcmTokenIfAvailable() { - // This should remain a placeholder because iOS is stupid + // iOS does not use FCM token management in this app build. +} + +actual suspend fun ensureFcmTokenRegistered(): Boolean { + // iOS does not use FCM token management in this app build. + return false +} + +actual suspend fun unregisterFcmTokenFromServer(): Boolean { + // iOS does not use FCM token management in this app build. + return false } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt index 036d8ff..8e80644 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt @@ -10,3 +10,7 @@ actual fun openAppNotificationSettings(): Boolean { val url = NSURL.URLWithString(urlString) ?: return false return UIApplication.sharedApplication.openURL(url) } + +actual fun areAppNotificationsEnabled(): Boolean { + return true +}