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
+49 -50
View File
@@ -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")
}
}
}
@@ -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<Int?>(null)
private var startAtPublicChat by mutableStateOf(false)
private var startAtDmConversationUserId by mutableStateOf<Int?>(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
)
}
@@ -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.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)
}
@@ -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<MessagesResponse>()
.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<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)
}
}
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<Message>) {
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"
)
}
}
@@ -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)
}
}
}
+1
View File
@@ -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)
@@ -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<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) {
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
}
}
@@ -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
}
}
@@ -133,6 +133,11 @@
<string name="settings_notifications_title">Уведомления</string>
<string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</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_empty">Нет активных сессий</string>
@@ -163,6 +163,11 @@
<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_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_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.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<DmConversationsResponse>()
.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(
@@ -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,
@@ -412,3 +413,8 @@ data class SimilarityResult(
data class VerifyResponse(
val verified: Boolean
)
@Serializable
data class FcmTokenRequest(
val token: String
)
@@ -1,3 +1,15 @@
package ru.fromchat.fcm
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).
*/
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
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<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("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) }
)
}
}
@@ -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))
@@ -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,
@@ -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),
@@ -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 = {
@@ -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
}
@@ -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
}