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.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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+60
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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 phone’s 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,
|
||||
@@ -411,4 +412,9 @@ data class SimilarityResult(
|
||||
@Serializable
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user