Add push notifications via local sync loop and FCM

Local notifications:
- NotificationHelper posts per-room notifications from the sync loop
- Notifications suppressed when room is active (ChatScreen DisposableEffect)
- Deferred for encrypted rooms until room key arrives; fires with real body
- Notification tap navigates to the room via pendingRoomId StateFlow
- POST_NOTIFICATIONS permission requested at runtime (API 33+)

FCM push (app killed):
- MatrixFirebaseMessagingService handles token refresh and data messages
- Pusher registered with homeserver pointing to Cloudflare Worker gateway
- gateway/src/index.js: Cloudflare Worker forwards Matrix pushes to FCM v1 API
  using a service-account JWT signed with Web Crypto
- last_sync_token persisted in SharedPreferences so restored sessions start with
  an incremental sync and fire notifications immediately (fixes missed-on-wake bug)
- Default FCM notification channel declared in manifest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-05-17 19:55:59 -03:00
parent 1f5e6ca691
commit de6386d614
15 changed files with 489 additions and 5 deletions

View file

@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.google.services)
}
android {
@ -64,6 +65,8 @@ dependencies {
implementation(libs.androidx.browser)
implementation(libs.androidx.appcompat)
implementation(files("libs/olm-sdk-3.2.16.jar"))
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.okhttp.logging)
}

View file

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
@ -11,6 +12,10 @@
android:supportsRtl="false"
android:theme="@style/Theme.MatrixAndroid">
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="matrix_messages" />
<activity
android:name=".MainActivity"
android:exported="true"
@ -34,6 +39,15 @@
</intent-filter>
</activity>
<service
android:name=".MatrixFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -1,25 +1,48 @@
package com.ltadeu6.matrix
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import com.ltadeu6.matrix.matrix.MatrixSession
import com.ltadeu6.matrix.matrix.NotificationHelper
import com.ltadeu6.matrix.navigation.AppNavigation
import com.ltadeu6.matrix.ui.theme.MatrixTheme
class MainActivity : ComponentActivity() {
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
val session = MatrixSession.getInstance(this)
// Fetch current FCM token and register pusher (no-op if gateway URL not set yet)
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
session.registerFcmPusher(token)
}
// Token may arrive cold (app launched from SSO redirect)
val loginToken = intent.data
?.takeIf { it.scheme == "matrixandroid" }
?.getQueryParameter("loginToken")
// Room may arrive cold (app launched from notification tap)
intent.getStringExtra(NotificationHelper.EXTRA_ROOM_ID)
?.let { session.handleRoomIntent(it) }
setContent {
MatrixTheme {
AppNavigation(initialLoginToken = loginToken)
@ -29,10 +52,23 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Token may arrive hot (app already running when SSO redirect fires)
val token = intent.data
val session = MatrixSession.getInstance(this)
// Hot SSO callback
intent.data
?.takeIf { it.scheme == "matrixandroid" }
?.getQueryParameter("loginToken") ?: return
MatrixSession.getInstance(this).handleSsoCallback(token)
?.getQueryParameter("loginToken")
?.let { session.handleSsoCallback(it) }
// Hot notification tap
intent.getStringExtra(NotificationHelper.EXTRA_ROOM_ID)
?.let { session.handleRoomIntent(it) }
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) return
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

View file

@ -0,0 +1,39 @@
package com.ltadeu6.matrix
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() {
override fun onNewToken(token: String) {
Log.i(TAG, "FCM token refreshed")
MatrixSession.getInstance(applicationContext).registerFcmPusher(token)
}
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"
)
}
companion object {
private const val TAG = "MatrixFCM"
}
}

View file

@ -25,6 +25,7 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -72,6 +73,11 @@ fun ChatScreen(
val listState = rememberLazyListState()
var selectedMessage by remember { mutableStateOf<Message?>(null) }
DisposableEffect(roomId) {
session.setActiveRoom(roomId)
onDispose { session.setActiveRoom(null) }
}
LaunchedEffect(messages.size, searchQuery) {
if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(messages.size - 1)

View file

@ -116,6 +116,12 @@ interface MatrixApi {
@Query("limit") limit: Int = 50
): MessagesResponse
@POST("/_matrix/client/v3/pushers/set")
suspend fun setPusher(
@Header("Authorization") auth: String,
@Body body: JsonObject
): JsonObject
@PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
suspend fun sendToDevice(
@Header("Authorization") auth: String,

View file

@ -59,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private val _pendingSsoToken = MutableStateFlow<String?>(null)
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
private val _pendingRoomId = MutableStateFlow<String?>(null)
val pendingRoomId: StateFlow<String?> = _pendingRoomId.asStateFlow()
private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
val deviceSyncState: StateFlow<DeviceSyncState> = _deviceSyncState.asStateFlow()
@ -89,6 +92,23 @@ class MatrixSession private constructor(private val appContext: Context) {
/** Sessions we failed to decrypt this sync batch and need to request. */
private data class MissingSession(val roomId: String, val senderKey: String, val sessionId: String)
/** Room currently open in the UI — suppress notifications for it. */
var activeRoomId: String? = null
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.
*/
private var hasCompletedInitialSync = prefs.getString("last_sync_token", null) != null
/** Last known message count per room, used to detect new messages across syncs. */
private val prevMessageCounts = mutableMapOf<String, Int>()
/** Rooms with new messages that couldn't be decrypted yet; value = prevCount before the new messages. */
private val pendingNotificationRooms = mutableMapOf<String, Int>()
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
@ -103,6 +123,8 @@ class MatrixSession private constructor(private val appContext: Context) {
""".trimIndent().replace("\n", "")
init {
NotificationHelper.createChannel(appContext)
verificationService.onSend = { toUser, toDevice, eventType, txnId, content ->
sendVerificationToDevice(toUser, toDevice, eventType, txnId, content)
}
@ -138,6 +160,7 @@ class MatrixSession private constructor(private val appContext: Context) {
refreshDeviceSyncState()
}
}
prefs.getString("fcm_token", null)?.let { registerFcmPusher(it) }
startSync()
}
}
@ -150,6 +173,46 @@ class MatrixSession private constructor(private val appContext: Context) {
return t
}
fun setActiveRoom(roomId: String?) {
activeRoomId = roomId
if (roomId != null) {
NotificationHelper.cancelRoomNotification(appContext, roomId)
}
}
fun handleRoomIntent(roomId: String) {
_pendingRoomId.value = roomId
}
fun consumePendingRoomId(): String? {
val r = _pendingRoomId.value
_pendingRoomId.value = null
return r
}
fun registerFcmPusher(token: String) {
prefs.edit().putString("fcm_token", token).apply()
if (PUSH_GATEWAY_URL.isEmpty()) return
val t = accessToken ?: return
scope.launch {
runCatching {
api?.setPusher("Bearer $t", buildJsonObject {
put("kind", "http")
put("app_id", "com.ltadeu6.matrix")
put("app_display_name", "Matrix Android")
put("device_display_name", android.os.Build.MODEL)
put("pushkey", token)
put("lang", "en")
put("data", buildJsonObject {
put("url", "$PUSH_GATEWAY_URL/_matrix/push/v1/notify")
put("format", "event_id_only")
})
})
}.onSuccess { Log.i(TAG, "FCM pusher registered") }
.onFailure { Log.w(TAG, "FCM pusher registration failed: $it") }
}
}
suspend fun loginWithToken(homeserver: String, token: String): Result<Unit> = runCatching {
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
buildApi(hs)
@ -193,6 +256,10 @@ class MatrixSession private constructor(private val appContext: Context) {
roomEventCache.clear()
roomPrevBatches.clear()
directRoomTargets.clear()
prevMessageCounts.clear()
pendingNotificationRooms.clear()
hasCompletedInitialSync = false
prefs.edit().remove("last_sync_token").apply()
verificationService.dismiss()
verificationService.userId = ""
verificationService.deviceId = ""
@ -357,6 +424,7 @@ class MatrixSession private constructor(private val appContext: Context) {
scope.launch {
uploadKeys()
refreshDeviceSyncState()
prefs.getString("fcm_token", null)?.let { registerFcmPusher(it) }
startSync()
}
}
@ -672,7 +740,7 @@ class MatrixSession private constructor(private val appContext: Context) {
private fun startSync() {
syncJob?.cancel()
syncJob = scope.launch {
var since: String? = null
var since: String? = prefs.getString("last_sync_token", null)
var isFirstSync = true
while (isActive) {
val token = accessToken ?: break
@ -685,6 +753,7 @@ 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()
@ -715,6 +784,32 @@ class MatrixSession private constructor(private val appContext: Context) {
}
}
_syncState.value = SyncState(rooms = rooms, messages = messages)
// Fire deferred notifications now that messages are decrypted
for (roomId in newKeyRooms) {
val baseCount = pendingNotificationRooms[roomId] ?: continue
if (roomId == activeRoomId) {
pendingNotificationRooms.remove(roomId)
continue
}
val newMsgs = messages[roomId] ?: continue
val incoming = newMsgs.drop(baseCount)
.filter { !it.isOutgoing && it.body != "[encrypted message]" }
if (incoming.isNotEmpty()) {
val last = incoming.last()
val roomName = rooms[roomId]?.name ?: roomId
val senderName = last.sender.substringBefore(":").trimStart('@')
NotificationHelper.postMessageNotification(
context = appContext,
roomId = roomId,
roomName = roomName,
senderName = senderName,
body = last.body
)
pendingNotificationRooms.remove(roomId)
}
}
if (extraMissing.isNotEmpty()) {
sendKeyRequests(extraMissing, "Bearer $token", matrixApi)
}
@ -733,6 +828,7 @@ class MatrixSession private constructor(private val appContext: Context) {
if (isFirstSync) {
isFirstSync = false
hasCompletedInitialSync = true
refreshDirectRoomTargets(token, matrixApi)
refreshDeviceSyncState(token, matrixApi)
fetchMissingRoomNames(token, matrixApi)
@ -916,6 +1012,37 @@ class MatrixSession private constructor(private val appContext: Context) {
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
}
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 (incoming.isNotEmpty()) {
val last = incoming.last()
if (last.body == "[encrypted message]") {
// Defer: room key hasn't arrived yet — fire after newKeyRooms re-decrypt
pendingNotificationRooms[roomId] = prevCount
} else {
val roomName = rooms[roomId]?.name ?: roomId
val senderName = last.sender.substringBefore(":").trimStart('@')
NotificationHelper.postMessageNotification(
context = appContext,
roomId = roomId,
roomName = roomName,
senderName = senderName,
body = last.body
)
pendingNotificationRooms.remove(roomId)
}
}
}
prevMessageCounts[roomId] = newMsgs.size
}
} else {
// Seed counts so we don't fire on resume after initial sync
for ((roomId, msgs) in messages) prevMessageCounts[roomId] = msgs.size
}
_syncState.value = SyncState(rooms = rooms, messages = messages)
return missingSessions
}
@ -1164,6 +1291,9 @@ class MatrixSession private constructor(private val appContext: Context) {
companion object {
private const val TAG = "MatrixCrypto"
/** Cloudflare Worker push gateway URL. */
const val PUSH_GATEWAY_URL = "https://matrix-push-gateway.ltadeu6.workers.dev"
@Volatile private var instance: MatrixSession? = null
fun getInstance(context: Context): MatrixSession =

View file

@ -0,0 +1,63 @@
package com.ltadeu6.matrix.matrix
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.ltadeu6.matrix.MainActivity
import com.ltadeu6.matrix.R
object NotificationHelper {
const val CHANNEL_ID = "matrix_messages"
const val EXTRA_ROOM_ID = "room_id"
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL_ID,
"Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "New message notifications"
}
context.getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
fun postMessageNotification(
context: Context,
roomId: String,
roomName: String,
senderName: String,
body: String
) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ROOM_ID, roomId)
}
val pendingIntent = PendingIntent.getActivity(
context,
roomId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(roomName)
.setContentText("$senderName: $body")
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
context.getSystemService(NotificationManager::class.java)
.notify(roomId.hashCode(), notification)
}
fun cancelRoomNotification(context: Context, roomId: String) {
context.getSystemService(NotificationManager::class.java)
.cancel(roomId.hashCode())
}
}

View file

@ -1,6 +1,7 @@
package com.ltadeu6.matrix.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
@ -31,6 +32,7 @@ fun AppNavigation(initialLoginToken: String? = null) {
val context = LocalContext.current
val session = MatrixSession.getInstance(context)
val authState by session.authState.collectAsState()
val pendingRoomId by session.pendingRoomId.collectAsState()
val startDest = when (authState) {
is AuthState.LoggedIn -> Route.ROOMS
@ -39,6 +41,18 @@ fun AppNavigation(initialLoginToken: String? = null) {
val navController = rememberNavController()
// Navigate to room when a notification is tapped
LaunchedEffect(pendingRoomId) {
val roomId = pendingRoomId ?: return@LaunchedEffect
if (authState is AuthState.LoggedIn) {
session.consumePendingRoomId()
navController.navigate(Route.chat(roomId)) {
// Don't add duplicate chat destinations
launchSingleTop = true
}
}
}
NavHost(navController = navController, startDestination = startDest) {
composable(Route.LOGIN) {