mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix notifications
This commit is contained in:
@@ -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
|
||||||
|
?.content
|
||||||
|
|
||||||
|
if (type == "newMessage") {
|
||||||
|
shouldFetchPublic = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "dmNew") {
|
||||||
|
shouldFetchDm = true
|
||||||
|
val envelopeId = item
|
||||||
|
.jsonObject["data"]
|
||||||
|
?.jsonObject
|
||||||
|
?.get("id")
|
||||||
?.jsonPrimitive
|
?.jsonPrimitive
|
||||||
?.content
|
?.content
|
||||||
in arrayOf("newMessage", "dmNew")
|
?.toIntOrNull()
|
||||||
) {
|
if (envelopeId != null) {
|
||||||
shouldFetch = true
|
latestDmMessageId = envelopeId.coerceAtLeast(latestDmMessageId ?: 0)
|
||||||
break
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
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) {
|
} 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
|
settings.putString("pending_fcm_token", token)
|
||||||
if (!authToken.isNullOrEmpty()) {
|
uploadPendingFcmTokenIfAvailable()
|
||||||
// Call backend endpoint to register token
|
Log.d("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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())
|
||||||
|
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())
|
if (includeDmMessages) {
|
||||||
|
fetchAndNotifyDirectMessages(context, currentUserId, dmMessageId, dmSenderName)
|
||||||
// Display notifications on main thread
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
createChannel(context)
|
|
||||||
displayNotifications(context, messages)
|
|
||||||
}
|
}
|
||||||
} 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
|
}
|
||||||
}
|
if (newMessages.isEmpty()) {
|
||||||
.ifEmpty { return@launch }
|
Log.d(
|
||||||
.apply { forEach { shown.add(it.id.toString()) } }
|
"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,
|
||||||
@@ -169,12 +492,16 @@ object NotificationHelper {
|
|||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.addAction(
|
.addAction(
|
||||||
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)
|
||||||
.setLabel("Reply to chat...")
|
.setLabel("Reply to chat...")
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-10
@@ -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()}"
|
||||||
|
)
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
val replyText = RemoteInput.getResultsFromIntent(intent)?.let { input ->
|
||||||
try {
|
(input.getCharSequence(NotificationHelper.KEY_TEXT_REPLY)
|
||||||
ApiClient.sendMessage(it)
|
?: input.getCharSequence("key_text_reply"))
|
||||||
Log.d("NotificationReply", "Reply sent successfully")
|
}
|
||||||
} catch (e: Exception) {
|
?.toString()
|
||||||
Log.e("NotificationReply", "Failed to send reply", e)
|
?.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
@@ -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 phone’s notification settings.</string>
|
<string name="settings_notifications_body">To allow or block alerts from FromChat, use your phone’s 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 = {
|
||||||
ListItem(
|
Icon(
|
||||||
headline = stringResource(Res.string.settings_category_server_tools),
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
supportingText = stringResource(Res.string.settings_category_server_tools_d),
|
contentDescription = null,
|
||||||
onClick = onServerTools,
|
modifier = Modifier.size(24.dp),
|
||||||
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) },
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
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,22 +291,49 @@ 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 = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_security),
|
headline = stringResource(Res.string.settings_category_appearance),
|
||||||
supportingText = stringResource(Res.string.settings_category_security_d),
|
supportingText = stringResource(Res.string.settings_category_appearance_d),
|
||||||
onClick = onSecurity,
|
onClick = onAppearance,
|
||||||
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Lock) },
|
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Palette) },
|
||||||
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(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_account),
|
headline = stringResource(Res.string.settings_category_notifications),
|
||||||
supportingText = stringResource(Res.string.settings_category_account_d),
|
supportingText = stringResource(Res.string.settings_category_notifications_d),
|
||||||
onClick = onAccount,
|
onClick = onNotifications,
|
||||||
leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) }
|
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))
|
Spacer(Modifier.height(20.dp))
|
||||||
@@ -319,37 +341,20 @@ fun SettingsHubScreen(
|
|||||||
modifier = Modifier.padding(bottom = 24.dp),
|
modifier = Modifier.padding(bottom = 24.dp),
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
) {
|
) {
|
||||||
val aboutInteraction = remember { MutableInteractionSource() }
|
ListItem(
|
||||||
Row(
|
headline = stringResource(Res.string.about),
|
||||||
modifier = Modifier
|
supportingText = stringResource(Res.string.settings_hub_about_sub),
|
||||||
.fillMaxWidth()
|
onClick = onAbout,
|
||||||
.clickable(
|
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Info) },
|
||||||
interactionSource = aboutInteraction,
|
trailingContent = {
|
||||||
indication = LocalIndication.current,
|
Icon(
|
||||||
onClick = onAbout
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
)
|
contentDescription = null,
|
||||||
.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
) {
|
|
||||||
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(
|
)
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user