Fix notifications

This commit is contained in:
2026-04-08 18:27:15 +03:00
Unverified
parent d015b1b2a6
commit 3b9dca0079
22 changed files with 947 additions and 253 deletions
+48 -49
View File
@@ -2,11 +2,7 @@ package ru.fromchat
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.UtilsLibrary 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.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -16,16 +12,23 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config import ru.fromchat.fcm.ensureFcmTokenRegistered
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
import ru.fromchat.notifications.NotificationHelper import ru.fromchat.notifications.NotificationHelper
class App: Application() { class App: Application() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
private fun fetchAndNotify() { private fun fetchAndNotify(
includeDmMessages: Boolean = false,
dmMessageId: Int? = null
) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
runCatching { runCatching {
NotificationHelper.fetchAndNotify(applicationContext) NotificationHelper.fetchAndNotify(
applicationContext,
includeDmMessages = includeDmMessages,
dmMessageId = dmMessageId
)
} }
} }
} }
@@ -39,23 +42,50 @@ class App: Application() {
runCatching { runCatching {
if (msg.type == "newMessage") { if (msg.type == "newMessage") {
fetchAndNotify() 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") { } else if (msg.type == "updates") {
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { 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) { for (item in updates) {
if ( val type = item
item
.jsonObject["type"] .jsonObject["type"]
?.jsonPrimitive ?.jsonPrimitive
?.content ?.content
in arrayOf("newMessage", "dmNew")
) { if (type == "newMessage") {
shouldFetch = true shouldFetchPublic = true
break continue
}
if (type == "dmNew") {
shouldFetchDm = true
val envelopeId = item
.jsonObject["data"]
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content
?.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 // If we have an auth token, try to get current FCM token and register it immediately
runCatching { runCatching {
val auth = ApiClient.token val isRegistered = ensureFcmTokenRegistered()
if (!auth.isNullOrEmpty()) { if (!isRegistered) {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> Log.d("AppFCM", "FCM token registration skipped or deferred")
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}"
)
}
}
}
} }
} }
} }
@@ -1,5 +1,4 @@
package ru.fromchat package ru.fromchat
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@@ -30,18 +29,34 @@ import ru.fromchat.core.config.Config
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.isPublicChatVisible 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() { class MainActivity : ComponentActivity() {
private var scrollToMessageId by mutableStateOf<Int?>(null) private var scrollToMessageId by mutableStateOf<Int?>(null)
private var startAtPublicChat by mutableStateOf(false) private var startAtPublicChat by mutableStateOf(false)
private var startAtDmConversationUserId by mutableStateOf<Int?>(null)
private var prevIsPublicChatVisible: Boolean? = null private var prevIsPublicChatVisible: Boolean? = null
private fun handleIntent(intent: Intent?) { 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 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 // 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() markMessagesAsRead()
} }
} }
@@ -101,7 +116,8 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
App( App(
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
startAtPublicChat = startAtPublicChat startAtPublicChat = startAtPublicChat,
startAtDmConversationUserId = startAtDmConversationUserId
) )
} }
@@ -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}")
}
}
@@ -4,24 +4,55 @@ import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import com.pr0gramm3r101.utils.settings.settings 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.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config
import ru.fromchat.notifications.NotificationHelper import ru.fromchat.notifications.NotificationHelper
import ru.fromchat.fcm.uploadPendingFcmTokenIfAvailable
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
class FromChatFirebaseMessagingService : FirebaseMessagingService() { class FromChatFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) { 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) { GlobalScope.launch(Dispatchers.IO) {
try { try {
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) NotificationHelper.fetchAndNotify(applicationContext)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e) Log.e("FromChatFCM", "onMessageReceived error: ${e.message}", e)
} }
@@ -31,25 +62,10 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
Log.d("FromChatFCM", "onNewToken: $token") Log.d("FromChatFCM", "onNewToken: $token")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
// Upload token to backend if authenticated, otherwise save locally (TODO: persist and upload on login)
try { 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) { } catch (e: Exception) {
Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e) Log.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
} }
@@ -17,6 +17,7 @@ import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get import io.ktor.client.request.get
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
@@ -26,25 +27,52 @@ import kotlinx.coroutines.launch
import ru.fromchat.MainActivity import ru.fromchat.MainActivity
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmHistoryResponse
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.MessagesResponse import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config 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 import ru.fromchat.ui.isPublicChatVisible
object NotificationHelper { 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 CHANNEL_ID = "fromchat_messages"
private const val SUMMARY_NOTIFICATION_ID = 1000000 // Use a high unique ID for summary 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_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 PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
const val KEY_TEXT_REPLY = "key_text_reply" 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, context,
messageId, if (targetDmUserId != null) -messageId else messageId,
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("scroll_to_message_id", messageId) putExtra(EXTRA_MESSAGE_ID, messageId)
putExtra("mark_message_read", true) 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 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@@ -52,12 +80,26 @@ object NotificationHelper {
private fun notificationReplyAction(context: Context) = private fun notificationReplyAction(context: Context) =
"${context.packageName}.NOTIFICATION_REPLY" "${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, context,
SUMMARY_NOTIFICATION_ID, if (isDirectMessage && targetDmUserId != null) {
-targetDmUserId
} else {
parentMessageId ?: SUMMARY_NOTIFICATION_ID
},
Intent(context, NotificationReplyReceiver::class.java).apply { Intent(context, NotificationReplyReceiver::class.java).apply {
action = notificationReplyAction(context) action = notificationReplyAction(context)
putExtra("notification_id", SUMMARY_NOTIFICATION_ID) 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 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") Log.d("NotificationHelper", "fetchAndNotify: starting fetch")
try { 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 val messages = ApiClient.http
.get("${Config.apiBaseUrl}/messages/new") .get("${Config.apiBaseUrl}/messages/new")
.body<MessagesResponse>() .body<MessagesResponse>()
.messages .messages
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} messages") Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages")
if (messages.isEmpty()) return if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
// Display notifications on main thread
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
createChannel(context) createChannel(context)
displayNotifications(context, messages) displayNotifications(context, messages)
} }
} else {
Log.d("NotificationHelper", "fetchAndNotify: no public messages returned")
}
if (includeDmMessages) {
fetchAndNotifyDirectMessages(context, currentUserId, dmMessageId, dmSenderName)
}
} catch (e: Exception) { } 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<MessagesResponse>()
.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) 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) @OptIn(DelicateCoroutinesApi::class)
private fun displayNotifications(context: Context, messages: List<Message>) { private fun displayNotifications(context: Context, messages: List<Message>) {
Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages") Log.d("NotificationHelper", "displayNotifications: ${messages.size} messages")
@@ -127,15 +441,24 @@ object NotificationHelper {
if (currentUserId == -1) return@launch if (currentUserId == -1) return@launch
val newMessages = messages val newMessages = messages.filter { msg ->
.filter { msg ->
!shown.contains(msg.id.toString()) && // Not already shown !shown.contains(msg.id.toString()) && // Not already shown
msg.user_id != currentUserId // Not from current user msg.user_id != currentUserId // Not from current user
} }
.ifEmpty { return@launch } if (newMessages.isEmpty()) {
.apply { forEach { shown.add(it.id.toString()) } } 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 newMessageCount = newMessages.size
Log.d(
"NotificationHelper",
"displayNotifications: user=$currentUserId totalMessages=${messages.size} newMessages=${newMessageCount}"
)
notify( notify(
SUMMARY_NOTIFICATION_ID, SUMMARY_NOTIFICATION_ID,
@@ -173,7 +496,11 @@ object NotificationHelper {
NotificationCompat.Action.Builder( NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send, android.R.drawable.ic_menu_send,
"Reply", "Reply",
createReplyIntent(context) createReplyIntent(
context = context,
isDirectMessage = false,
parentMessageId = newMessages.last().id
)
) )
.addRemoteInput( .addRemoteInput(
RemoteInput.Builder(KEY_TEXT_REPLY) RemoteInput.Builder(KEY_TEXT_REPLY)
@@ -183,19 +510,14 @@ object NotificationHelper {
.setAllowGeneratedReplies(true) .setAllowGeneratedReplies(true)
.build() .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)) .setContentIntent(createMessageIntent(context, newMessages.last().id))
.build() .build()
) )
} else {
Log.w(
"NotificationHelper",
"displayNotifications: POST_NOTIFICATIONS permission missing, skipping"
)
} }
} }
@@ -5,27 +5,77 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient 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) @OptIn(DelicateCoroutinesApi::class)
class NotificationReplyReceiver : BroadcastReceiver() { class NotificationReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
RemoteInput.getResultsFromIntent(intent)?.getCharSequence("key_text_reply")?.toString()?.let { Log.e(
if (it.isNotBlank()) { "NotificationReply",
Log.d("NotificationReply", "Received reply: $it") "onReceive called action=${intent.action} extras=${intent.extras?.keySet()?.joinToString()}"
)
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) { GlobalScope.launch(Dispatchers.IO) {
try { try {
ApiClient.sendMessage(it) if (ApiClient.token.isNullOrBlank()) {
Log.d("NotificationReply", "Reply sent successfully") 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) { } catch (e: Exception) {
Log.e("NotificationReply", "Failed to send reply", e) Log.w("NotificationReply", "Failed to send reply", e)
}
}
} }
} }
} }
+1
View File
@@ -83,6 +83,7 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.firebase.messaging)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.tweetnacl.java) implementation(libs.tweetnacl.java)
@@ -1,18 +1,58 @@
package ru.fromchat.fcm package ru.fromchat.fcm
import android.util.Log import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.pr0gramm3r101.utils.settings.settings 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.header
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.SimpleStatusResponse
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.core.config.Config 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<String> ->
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<SimpleStatusResponse>()
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) { actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.IO) {
try { try {
val pending = settings.getString("pending_fcm_token", "") val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
// Only upload if we have auth token // Only upload if we have auth token
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) { if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
@@ -20,17 +60,66 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
return@withContext return@withContext
} }
try { if (postFcmToken(pending)) {
ApiClient.http.post("${Config.apiBaseUrl}/push/register") { settings.putString(CURRENT_FCM_TOKEN_KEY, pending)
header("Content-Type", "application/json") settings.remove(PENDING_FCM_TOKEN_KEY)
setBody(ApiClient.json.encodeToString(mapOf("token" to pending))) } else {
} Log.d("FcmReg", "Deferring pending FCM token upload")
settings.remove("pending_fcm_token")
} catch (e: Exception) {
Log.e("FcmReg", "Failed to upload pending FCM token: ${e.message}")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("FcmReg", "uploadPendingFcmTokenIfAvailable error: ${e.message}") 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
}
}
@@ -1,8 +1,12 @@
package ru.fromchat.platform package ru.fromchat.platform
import android.content.Intent import android.content.Intent
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import android.provider.Settings import android.provider.Settings
import com.pr0gramm3r101.utils.UtilsLibrary.context import com.pr0gramm3r101.utils.UtilsLibrary.context
@@ -24,3 +28,16 @@ actual fun openAppNotificationSettings(): Boolean =
} catch (_: Exception) { } catch (_: Exception) {
false 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
}
}
@@ -133,6 +133,11 @@
<string name="settings_notifications_title">Уведомления</string> <string name="settings_notifications_title">Уведомления</string>
<string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</string> <string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</string>
<string name="settings_open_notification_settings">Открыть настройки уведомлений</string> <string name="settings_open_notification_settings">Открыть настройки уведомлений</string>
<string name="settings_notifications_enable">Включить push-уведомления</string>
<string name="settings_notifications_disable">Отключить push-уведомления</string>
<string name="settings_notifications_permission_required">Откройте системные настройки и разрешите уведомления.</string>
<string name="settings_push_notifications_enabled">Push-уведомления включены</string>
<string name="settings_push_notifications_disabled">Push-уведомления отключены</string>
<string name="settings_devices_title">Устройства</string> <string name="settings_devices_title">Устройства</string>
<string name="settings_devices_empty">Нет активных сессий</string> <string name="settings_devices_empty">Нет активных сессий</string>
@@ -163,6 +163,11 @@
<string name="settings_notifications_title">Notifications</string> <string name="settings_notifications_title">Notifications</string>
<string name="settings_notifications_body">To allow or block alerts from FromChat, use your phones notification settings.</string> <string name="settings_notifications_body">To allow or block alerts from FromChat, use your phones notification settings.</string>
<string name="settings_open_notification_settings">Open notification settings</string> <string name="settings_open_notification_settings">Open notification settings</string>
<string name="settings_notifications_enable">Enable push notifications</string>
<string name="settings_notifications_disable">Disable push notifications</string>
<string name="settings_notifications_permission_required">Open system notification settings to allow alerts.</string>
<string name="settings_push_notifications_enabled">Push notifications enabled</string>
<string name="settings_push_notifications_disabled">Push notifications disabled</string>
<string name="settings_devices_title">Devices</string> <string name="settings_devices_title">Devices</string>
<string name="settings_devices_empty">No active sessions</string> <string name="settings_devices_empty">No active sessions</string>
@@ -41,6 +41,7 @@ import ru.fromchat.crypto.IdentityKeyManager
import ru.fromchat.crypto.transport.TransportCiphertext import ru.fromchat.crypto.transport.TransportCiphertext
import ru.fromchat.crypto.transport.TransportCrypto import ru.fromchat.crypto.transport.TransportCrypto
import ru.fromchat.platform.currentDeviceInfo import ru.fromchat.platform.currentDeviceInfo
import ru.fromchat.fcm.unregisterFcmTokenFromServer
/** /**
* Creates a platform-specific HTTP client that supports WebSockets * Creates a platform-specific HTTP client that supports WebSockets
@@ -267,6 +268,15 @@ object ApiClient {
.body<DmConversationsResponse>() .body<DmConversationsResponse>()
.conversations .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( suspend fun getDmHistory(
otherUserId: Int, otherUserId: Int,
limit: Int = 50, 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( suspend fun changePassword(
currentPasswordDerived: String, currentPasswordDerived: String,
newPasswordDerived: String, newPasswordDerived: String,
@@ -556,6 +592,8 @@ object ApiClient {
secureSettings.remove("auth_token") secureSettings.remove("auth_token")
settings.remove("user_info") settings.remove("user_info")
settings.remove("current_user_id") settings.remove("current_user_id")
settings.remove("pending_fcm_token")
settings.remove("current_fcm_token")
token = null token = null
user = null user = null
uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) }
@@ -567,14 +605,20 @@ object ApiClient {
} }
suspend fun logout() { suspend fun logout() {
runCatching { runCatching { http.get("${Config.apiBaseUrl}/logout") }
http.get("${Config.apiBaseUrl}/logout") runCatching { unregisterFcmTokenFromServer() }
}
clearLocalSession() clearLocalSession()
} }
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") 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 // WebSocket send helpers
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) { suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
WebSocketManager.send( WebSocketManager.send(
@@ -234,6 +234,7 @@ data class DmEnvelope(
val id: Int, val id: Int,
val senderId: Int, val senderId: Int,
val recipientId: Int, val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null,
@SerialName("iv_b64") val ivB64: String, @SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String, @SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null, @SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
@@ -412,3 +413,8 @@ data class SimilarityResult(
data class VerifyResponse( data class VerifyResponse(
val verified: Boolean val verified: Boolean
) )
@Serializable
data class FcmTokenRequest(
val token: String
)
@@ -1,3 +1,15 @@
package ru.fromchat.fcm package ru.fromchat.fcm
expect suspend fun uploadPendingFcmTokenIfAvailable() 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
@@ -5,3 +5,8 @@ package ru.fromchat.platform
* @return true if an intent/URL was fired (best effort). * @return true if an intent/URL was fired (best effort).
*/ */
expect fun openAppNotificationSettings(): Boolean expect fun openAppNotificationSettings(): Boolean
/**
* Returns true when notifications are currently enabled for this app, including runtime permission.
*/
expect fun areAppNotificationsEnabled(): Boolean
@@ -89,7 +89,11 @@ private fun NavGraphBuilder.settingsSlideComposable(
} }
@Composable @Composable
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { fun App(
scrollToMessageId: Int? = null,
startAtPublicChat: Boolean = false,
startAtDmConversationUserId: Int? = null
) {
setSingletonImageLoaderFactory { context -> setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
@@ -120,6 +124,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
// Now determine start destination based on loaded token // Now determine start destination based on loaded token
val hasToken = ApiClient.token?.isNotEmpty() == true val hasToken = ApiClient.token?.isNotEmpty() == true
startDestination = when { startDestination = when {
hasToken && startAtDmConversationUserId != null -> "chat"
hasToken && startAtPublicChat -> "chats/publicChat" hasToken && startAtPublicChat -> "chats/publicChat"
hasToken && !startAtPublicChat -> "chat" hasToken && !startAtPublicChat -> "chat"
else -> "login" else -> "login"
@@ -156,9 +161,22 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
SharedTransitionLayout { SharedTransitionLayout {
val navController = rememberNavController() val navController = rememberNavController()
// Handle navigation to public chat when requested (e.g., from notification) // Handle navigation to the target chat when launched from notification
LaunchedEffect(startAtPublicChat) { LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) {
if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { 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") { navController.navigate("chats/publicChat") {
launchSingleTop = true launchSingleTop = true
} }
@@ -284,7 +302,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
composable( composable(
route = DmNav.CHAT_ROUTE, 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 = { enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
@@ -311,9 +332,11 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}, },
) { entry -> ) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0 val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
if (otherUserId <= 0) return@composable if (otherUserId <= 0) return@composable
DmChatRoute( DmChatRoute(
otherUserId = otherUserId, otherUserId = otherUserId,
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
navController = navController, navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this, animatedVisibilityScope = this,
@@ -373,14 +396,15 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
SettingsSecurityPasswordFlowScreen( SettingsSecurityPasswordFlowScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onDonePopToHub = { onDonePopToHub = {
navController.popBackStack(SettingsRoutes.Security, inclusive = false) navController.popBackStack()
} }
) )
} }
settingsSlideComposable(SettingsRoutes.Account, animationSpec) { settingsSlideComposable(SettingsRoutes.Account, animationSpec) {
SettingsAccountScreen( SettingsAccountScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onLogout = navigateToLoginClearingChat onLogout = navigateToLoginClearingChat,
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
) )
} }
} }
@@ -14,10 +14,16 @@ import ru.fromchat.ui.rememberHapticFeedback
/** Route patterns and builders for DM chat + in-DM profile (stacked for predictive / system back). */ /** Route patterns and builders for DM chat + in-DM profile (stacked for predictive / system back). */
object DmNav { 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" 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" fun profileRoute(otherUserId: Int) = "dm/$otherUserId/profile"
} }
@@ -27,6 +33,7 @@ private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-"
@Composable @Composable
fun DmChatRoute( fun DmChatRoute(
otherUserId: Int, otherUserId: Int,
scrollToMessageId: Int? = null,
navController: NavController, navController: NavController,
sharedTransitionScope: SharedTransitionScope, sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope, animatedVisibilityScope: AnimatedVisibilityScope,
@@ -40,6 +47,7 @@ fun DmChatRoute(
DmScreen( DmScreen(
panel = panel, panel = panel,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
scrollToMessageId = scrollToMessageId,
onTitleClick = { onTitleClick = {
haptic(HapticFeedbackEvent.ProfileOpened) haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId)) navController.navigate(DmNav.profileRoute(otherUserId))
@@ -13,6 +13,7 @@ import ru.fromchat.ui.chat.ChatScreen
@Composable @Composable
fun DmScreen( fun DmScreen(
panel: DmPanel, panel: DmPanel,
scrollToMessageId: Int? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTitleClick: (() -> Unit)? = null, onTitleClick: (() -> Unit)? = null,
hideTitleBarAvatar: Boolean = false, hideTitleBarAvatar: Boolean = false,
@@ -34,6 +35,7 @@ fun DmScreen(
panel = panel, panel = panel,
currentUserId = currentUserId, currentUserId = currentUserId,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
scrollToMessageId = scrollToMessageId,
onTitleClick = onTitleClick, onTitleClick = onTitleClick,
hideTitleBarAvatar = hideTitleBarAvatar, hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds, onAvatarSlotBounds = onAvatarSlotBounds,
@@ -15,10 +15,8 @@ fun SettingsTab() {
val nav = LocalNavController.current val nav = LocalNavController.current
SettingsHubScreen( SettingsHubScreen(
onAppearance = { nav.navigate(SettingsRoutes.Appearance) }, onAppearance = { nav.navigate(SettingsRoutes.Appearance) },
onServerTools = { nav.navigate(SettingsRoutes.ServerTools) },
onNotifications = { nav.navigate(SettingsRoutes.Notifications) }, onNotifications = { nav.navigate(SettingsRoutes.Notifications) },
onDevices = { nav.navigate(SettingsRoutes.Devices) }, onDevices = { nav.navigate(SettingsRoutes.Devices) },
onSecurity = { nav.navigate(SettingsRoutes.Security) },
onAccount = { nav.navigate(SettingsRoutes.Account) }, onAccount = { nav.navigate(SettingsRoutes.Account) },
onAbout = { nav.navigate("about") }, onAbout = { nav.navigate("about") },
title = stringResource(Res.string.settings), title = stringResource(Res.string.settings),
@@ -58,7 +58,6 @@ import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LaptopMac import androidx.compose.material.icons.filled.LaptopMac
import androidx.compose.material.icons.filled.LightMode 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.Notifications
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
@@ -149,6 +148,9 @@ import ru.fromchat.materialYou
import ru.fromchat.materialYou_d import ru.fromchat.materialYou_d
import ru.fromchat.password_length_error import ru.fromchat.password_length_error
import ru.fromchat.passwords_dont_match 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.openAppNotificationSettings
import ru.fromchat.platform.currentDeviceInfo import ru.fromchat.platform.currentDeviceInfo
import ru.fromchat.settings_account_delete 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_devices_title
import ru.fromchat.settings_new_password import ru.fromchat.settings_new_password
import ru.fromchat.settings_notifications_body 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_title
import ru.fromchat.settings_notifications_permission_required
import ru.fromchat.settings_open_notification_settings 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_hub_about_sub
import ru.fromchat.settings_next import ru.fromchat.settings_next
import ru.fromchat.settings_password_changed import ru.fromchat.settings_password_changed
@@ -231,10 +238,8 @@ private fun SettingsListLeadingIcon(imageVector: ImageVector) {
@Composable @Composable
fun SettingsHubScreen( fun SettingsHubScreen(
onAppearance: () -> Unit, onAppearance: () -> Unit,
onServerTools: () -> Unit,
onNotifications: () -> Unit, onNotifications: () -> Unit,
onDevices: () -> Unit, onDevices: () -> Unit,
onSecurity: () -> Unit,
onAccount: () -> Unit, onAccount: () -> Unit,
onAbout: () -> Unit, onAbout: () -> Unit,
title: String, title: String,
@@ -263,31 +268,21 @@ fun SettingsHubScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) { ) {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_appearance), headline = stringResource(Res.string.settings_category_account),
supportingText = stringResource(Res.string.settings_category_appearance_d), supportingText = stringResource(Res.string.settings_category_account_d),
onClick = onAppearance, onClick = onAccount,
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Palette) }, leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) },
divider = true, divider = true,
dividerColor = settingsSurfaceCutDividerColor(), 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_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
) )
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_devices), headline = stringResource(Res.string.settings_category_devices),
@@ -296,53 +291,8 @@ fun SettingsHubScreen(
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Devices) }, leadingContent = { SettingsListLeadingIcon(Icons.Filled.Devices) },
divider = true, divider = true,
dividerColor = settingsSurfaceCutDividerColor(), dividerColor = settingsSurfaceCutDividerColor(),
dividerThickness = SettingsSurfaceCutDividerThickness dividerThickness = SettingsSurfaceCutDividerThickness,
) trailingContent = {
ListItem(
headline = stringResource(Res.string.settings_category_security),
supportingText = stringResource(Res.string.settings_category_security_d),
onClick = onSecurity,
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Lock) },
divider = true,
dividerColor = settingsSurfaceCutDividerColor(),
dividerThickness = SettingsSurfaceCutDividerThickness
)
ListItem(
headline = stringResource(Res.string.settings_category_account),
supportingText = stringResource(Res.string.settings_category_account_d),
onClick = onAccount,
leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) }
)
}
Spacer(Modifier.height(20.dp))
Category(
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
)
}
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null, contentDescription = null,
@@ -350,6 +300,61 @@ fun SettingsHubScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
)
ListItem(
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,
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_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))
Category(
modifier = Modifier.padding(bottom = 24.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) {
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
)
}
)
} }
} }
} }
@@ -542,8 +547,27 @@ fun SettingsSecurityHubScreen(onBack: () -> Unit, onChangePassword: () -> Unit)
@Composable @Composable
fun SettingsNotificationsScreen(onBack: () -> Unit) { fun SettingsNotificationsScreen(onBack: () -> Unit) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) 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( Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState) {
Snackbar(
snackbarData = it,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
@@ -575,6 +599,62 @@ fun SettingsNotificationsScreen(onBack: () -> Unit) {
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 8.dp) .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( FilledTonalButton(
onClick = { openAppNotificationSettings() }, onClick = { openAppNotificationSettings() },
modifier = Modifier modifier = Modifier
@@ -1802,7 +1882,7 @@ private fun SecurityPasswordStepPage(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit) { fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: () -> Unit) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -1834,6 +1914,23 @@ fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> Unit) {
Modifier.padding(top = 16.dp), Modifier.padding(top = 16.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow 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( ListItem(
headline = stringResource(Res.string.logout), headline = stringResource(Res.string.logout),
onClick = { onClick = {
@@ -1,5 +1,15 @@
package ru.fromchat.fcm package ru.fromchat.fcm
actual suspend fun uploadPendingFcmTokenIfAvailable() { 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
} }
@@ -10,3 +10,7 @@ actual fun openAppNotificationSettings(): Boolean {
val url = NSURL.URLWithString(urlString) ?: return false val url = NSURL.URLWithString(urlString) ?: return false
return UIApplication.sharedApplication.openURL(url) return UIApplication.sharedApplication.openURL(url)
} }
actual fun areAppNotificationsEnabled(): Boolean {
return true
}