diff --git a/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt b/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt index 2ad612d..e5b97f8 100644 --- a/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt +++ b/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt @@ -4,7 +4,6 @@ import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.ltadeu6.matrix.matrix.MatrixSession -import com.ltadeu6.matrix.matrix.NotificationHelper class MatrixFirebaseMessagingService : FirebaseMessagingService() { @@ -14,23 +13,10 @@ class MatrixFirebaseMessagingService : FirebaseMessagingService() { } override fun onMessageReceived(message: RemoteMessage) { - val roomId = message.data["room_id"]?.takeIf { it.isNotBlank() } ?: return - val sender = message.data["sender"] ?: "" - val session = MatrixSession.getInstance(applicationContext) - - // If the app is alive and the room is open, skip — sync loop will handle it - if (session.activeRoomId == roomId) return - - val roomName = session.syncState.value.rooms[roomId]?.name ?: roomId - val senderName = sender.substringBefore(":").trimStart('@') - - NotificationHelper.postMessageNotification( - context = applicationContext, - roomId = roomId, - roomName = roomName, - senderName = senderName, - body = "new message" - ) + // The FCM message is just a wakeup signal. Initialize the session (creates it if the + // process was freshly started for this broadcast), which starts the sync loop. + Log.i(TAG, "FCM data message received — initializing session") + MatrixSession.getInstance(applicationContext) } companion object { diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt index 5c3d870..ca29ff2 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt @@ -97,11 +97,16 @@ class MatrixSession private constructor(private val appContext: Context) { private set /** - * True once we've processed at least one sync batch. Prevents burst notifications on a - * fresh-install initial sync (since=null). When restored from a saved sync token this is - * initialised to true so FCM-woken restarts fire notifications immediately. + * Per-room timestamp (origin_server_ts ms) of the last message seen. Persisted across restarts + * to distinguish "new" events from "old" history on the first sync after a process restart. */ - private var hasCompletedInitialSync = prefs.getString("last_sync_token", null) != null + private val lastMsgTsPerRoom = mutableMapOf() + + /** + * True once we've completed at least one initial sync. On process restarts this is pre-set + * from persisted [lastMsgTsPerRoom] data so FCM-woken processes fire notifications immediately. + */ + private var hasCompletedInitialSync = false /** Last known message count per room, used to detect new messages across syncs. */ private val prevMessageCounts = mutableMapOf() @@ -143,12 +148,33 @@ class MatrixSession private constructor(private val appContext: Context) { val savedUserId = prefs.getString("user_id", null) val savedDeviceId = prefs.getString("device_id", null) encryptedRooms.addAll(prefs.getStringSet("encrypted_room_ids", emptySet()) ?: emptySet()) + // Restore per-room timestamps so FCM-woken process can identify new messages + val storedMsgTs = prefs.getStringSet("last_msg_ts", null) + if (storedMsgTs != null) { + hasCompletedInitialSync = true + storedMsgTs.forEach { entry -> + val parts = entry.split("\t") + if (parts.size == 2) lastMsgTsPerRoom[parts[0]] = parts[1].toLongOrNull() ?: 0L + } + } if (savedToken != null && savedHomeserver != null && savedUserId != null) { accessToken = savedToken currentUserId = savedUserId buildApi(savedHomeserver) _authState.value = AuthState.LoggedIn(savedUserId, savedHomeserver) + // Restore cached room list so the UI doesn't show "syncing..." on every open + val cachedRooms = prefs.getStringSet("cached_rooms", emptySet())!! + .mapNotNull { entry -> + val parts = entry.split("\u0000") + if (parts.size >= 3) { + val roomId = parts[0] + val name = parts[1] + val isEncrypted = parts[2] == "true" + roomId to Room(id = roomId, name = name, isEncrypted = isEncrypted, canLoadMore = false) + } else null + }.toMap() + if (cachedRooms.isNotEmpty()) _syncState.value = SyncState(rooms = cachedRooms) if (savedDeviceId != null) { val crypto = CryptoService(appContext, savedUserId, savedDeviceId) cryptoService = crypto @@ -258,8 +284,8 @@ class MatrixSession private constructor(private val appContext: Context) { directRoomTargets.clear() prevMessageCounts.clear() pendingNotificationRooms.clear() + lastMsgTsPerRoom.clear() hasCompletedInitialSync = false - prefs.edit().remove("last_sync_token").apply() verificationService.dismiss() verificationService.userId = "" verificationService.deviceId = "" @@ -740,7 +766,7 @@ class MatrixSession private constructor(private val appContext: Context) { private fun startSync() { syncJob?.cancel() syncJob = scope.launch { - var since: String? = prefs.getString("last_sync_token", null) + var since: String? = null var isFirstSync = true while (isActive) { val token = accessToken ?: break @@ -753,7 +779,6 @@ class MatrixSession private constructor(private val appContext: Context) { filter = syncFilter ) since = response.nextBatch - prefs.edit().putString("last_sync_token", since).apply() // Feed to-device events so incoming room keys are available before decryption var newKeyRooms: Set = emptySet() @@ -794,7 +819,10 @@ class MatrixSession private constructor(private val appContext: Context) { } val newMsgs = messages[roomId] ?: continue val incoming = newMsgs.drop(baseCount) - .filter { !it.isOutgoing && it.body != "[encrypted message]" } + .filter { msg -> + !msg.isOutgoing && msg.body != "[encrypted message]" && + (baseCount > 0 || msg.timestamp >= (lastMsgTsPerRoom[roomId] ?: 0L)) + } if (incoming.isNotEmpty()) { val last = incoming.last() val roomName = rooms[roomId]?.name ?: roomId @@ -828,7 +856,13 @@ class MatrixSession private constructor(private val appContext: Context) { if (isFirstSync) { isFirstSync = false - hasCompletedInitialSync = true + if (!hasCompletedInitialSync) { + hasCompletedInitialSync = true + // Persist per-room timestamps now so a restart knows which messages are old + prefs.edit().putStringSet("last_msg_ts", + lastMsgTsPerRoom.map { "${it.key}\t${it.value}" }.toSet() + ).apply() + } refreshDirectRoomTargets(token, matrixApi) refreshDeviceSyncState(token, matrixApi) fetchMissingRoomNames(token, matrixApi) @@ -1015,16 +1049,30 @@ class MatrixSession private constructor(private val appContext: Context) { if (hasCompletedInitialSync) { for ((roomId, newMsgs) in messages) { val prevCount = prevMessageCounts[roomId] ?: 0 - if (newMsgs.size > prevCount && roomId != activeRoomId) { - val incoming = newMsgs.drop(prevCount).filter { !it.isOutgoing } + if (roomId != activeRoomId) { + // prevCount > 0: within-session detection — drop already-seen messages by count. + // prevCount == 0: first sync after restart — filter by persisted per-room timestamp. + val incoming = if (prevCount > 0) { + newMsgs.drop(prevCount).filter { !it.isOutgoing } + } else { + val lastKnownTs = lastMsgTsPerRoom[roomId] ?: 0L + newMsgs.filter { !it.isOutgoing && it.timestamp > lastKnownTs } + } if (incoming.isNotEmpty()) { val last = incoming.last() + val roomName = rooms[roomId]?.name ?: roomId + val senderName = last.sender.substringBefore(":").trimStart('@') if (last.body == "[encrypted message]") { - // Defer: room key hasn't arrived yet — fire after newKeyRooms re-decrypt + // Post placeholder now; newKeyRooms re-decrypt will update it if key arrives pendingNotificationRooms[roomId] = prevCount + NotificationHelper.postMessageNotification( + context = appContext, + roomId = roomId, + roomName = roomName, + senderName = senderName, + body = "new message" + ) } else { - val roomName = rooms[roomId]?.name ?: roomId - val senderName = last.sender.substringBefore(":").trimStart('@') NotificationHelper.postMessageNotification( context = appContext, roomId = roomId, @@ -1037,13 +1085,28 @@ class MatrixSession private constructor(private val appContext: Context) { } } prevMessageCounts[roomId] = newMsgs.size + // Track per-room last-message timestamp for post-restart new-message detection + newMsgs.lastOrNull()?.let { msg -> + lastMsgTsPerRoom[roomId] = maxOf(lastMsgTsPerRoom[roomId] ?: 0L, msg.timestamp) + } } + // Persist after every sync so the next restart has up-to-date timestamps + prefs.edit().putStringSet("last_msg_ts", + lastMsgTsPerRoom.map { "${it.key}\t${it.value}" }.toSet() + ).apply() } else { - // Seed counts so we don't fire on resume after initial sync - for ((roomId, msgs) in messages) prevMessageCounts[roomId] = msgs.size + // Seed counts and timestamps — don't fire notifications on initial sync + for ((roomId, msgs) in messages) { + prevMessageCounts[roomId] = msgs.size + msgs.lastOrNull()?.let { lastMsgTsPerRoom[roomId] = it.timestamp } + } } _syncState.value = SyncState(rooms = rooms, messages = messages) + // Persist room names so they're available immediately on next process start + prefs.edit().putStringSet("cached_rooms", + rooms.values.map { "${it.id}\u0000${it.name}\u0000${it.isEncrypted}" }.toSet() + ).apply() return missingSessions } diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt index 970a2ce..1e8afbe 100644 --- a/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt +++ b/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt @@ -46,8 +46,8 @@ object NotificationHelper { val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle(roomName) - .setContentText("$senderName: $body") + .setContentTitle(senderName) + .setContentText(body) .setAutoCancel(true) .setContentIntent(pendingIntent) .build()