Fix FCM push notifications when app is killed

- onMessageReceived now calls MatrixSession.getInstance() so the session
  initializes and the sync loop starts in FCM-woken processes (the critical
  missing piece — without this, a fresh process never ran any sync)
- Replace global lastSyncTimestamp with per-room lastMsgTsPerRoom persisted
  in prefs as last_msg_ts; correctly identifies new messages after restart
  regardless of room history size, without clock-skew issues
- Seed lastMsgTsPerRoom in processSyncResponse else branch on first login;
  save to prefs in isFirstSync block and after every subsequent sync
- Fix deferred notification filter: use >= instead of > so messages whose
  timestamp equals the stored value are included after key arrival
- Change notification title from room name to sender display name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-05-17 21:18:11 -03:00
parent de6386d614
commit 5791742c95
3 changed files with 85 additions and 36 deletions

View file

@ -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 {

View file

@ -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<String, Long>()
/**
* 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<String, Int>()
@ -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<String> = 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
}

View file

@ -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()