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

11
.gitignore vendored
View file

@ -1,3 +1,14 @@
# Secrets & credentials
.secrets/
*.env
*.env.local
google-services.json
*-adminsdk-*.json
firebase-debug.log
.firebaserc
.wrangler/
gateway/node_modules/
.mc/ .mc/
*.iml *.iml
.gradle/ .gradle/

View file

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

View file

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

View file

@ -1,25 +1,48 @@
package com.ltadeu6.matrix package com.ltadeu6.matrix
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.MatrixSession
import com.ltadeu6.matrix.matrix.NotificationHelper
import com.ltadeu6.matrix.navigation.AppNavigation import com.ltadeu6.matrix.navigation.AppNavigation
import com.ltadeu6.matrix.ui.theme.MatrixTheme import com.ltadeu6.matrix.ui.theme.MatrixTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() 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) // Token may arrive cold (app launched from SSO redirect)
val loginToken = intent.data val loginToken = intent.data
?.takeIf { it.scheme == "matrixandroid" } ?.takeIf { it.scheme == "matrixandroid" }
?.getQueryParameter("loginToken") ?.getQueryParameter("loginToken")
// Room may arrive cold (app launched from notification tap)
intent.getStringExtra(NotificationHelper.EXTRA_ROOM_ID)
?.let { session.handleRoomIntent(it) }
setContent { setContent {
MatrixTheme { MatrixTheme {
AppNavigation(initialLoginToken = loginToken) AppNavigation(initialLoginToken = loginToken)
@ -29,10 +52,23 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
// Token may arrive hot (app already running when SSO redirect fires) val session = MatrixSession.getInstance(this)
val token = intent.data
// Hot SSO callback
intent.data
?.takeIf { it.scheme == "matrixandroid" } ?.takeIf { it.scheme == "matrixandroid" }
?.getQueryParameter("loginToken") ?: return ?.getQueryParameter("loginToken")
MatrixSession.getInstance(this).handleSsoCallback(token) ?.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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -72,6 +73,11 @@ fun ChatScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
var selectedMessage by remember { mutableStateOf<Message?>(null) } var selectedMessage by remember { mutableStateOf<Message?>(null) }
DisposableEffect(roomId) {
session.setActiveRoom(roomId)
onDispose { session.setActiveRoom(null) }
}
LaunchedEffect(messages.size, searchQuery) { LaunchedEffect(messages.size, searchQuery) {
if (messages.isNotEmpty() && searchQuery.isBlank()) { if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(messages.size - 1) listState.animateScrollToItem(messages.size - 1)

View file

@ -116,6 +116,12 @@ interface MatrixApi {
@Query("limit") limit: Int = 50 @Query("limit") limit: Int = 50
): MessagesResponse ): 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}") @PUT("/_matrix/client/v3/sendToDevice/{eventType}/{txnId}")
suspend fun sendToDevice( suspend fun sendToDevice(
@Header("Authorization") auth: String, @Header("Authorization") auth: String,

View file

@ -59,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private val _pendingSsoToken = MutableStateFlow<String?>(null) private val _pendingSsoToken = MutableStateFlow<String?>(null)
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow() val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
private val _pendingRoomId = MutableStateFlow<String?>(null)
val pendingRoomId: StateFlow<String?> = _pendingRoomId.asStateFlow()
private val _deviceSyncState = MutableStateFlow(DeviceSyncState()) private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
val deviceSyncState: StateFlow<DeviceSyncState> = _deviceSyncState.asStateFlow() 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. */ /** 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) 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 { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true isLenient = true
@ -103,6 +123,8 @@ class MatrixSession private constructor(private val appContext: Context) {
""".trimIndent().replace("\n", "") """.trimIndent().replace("\n", "")
init { init {
NotificationHelper.createChannel(appContext)
verificationService.onSend = { toUser, toDevice, eventType, txnId, content -> verificationService.onSend = { toUser, toDevice, eventType, txnId, content ->
sendVerificationToDevice(toUser, toDevice, eventType, txnId, content) sendVerificationToDevice(toUser, toDevice, eventType, txnId, content)
} }
@ -138,6 +160,7 @@ class MatrixSession private constructor(private val appContext: Context) {
refreshDeviceSyncState() refreshDeviceSyncState()
} }
} }
prefs.getString("fcm_token", null)?.let { registerFcmPusher(it) }
startSync() startSync()
} }
} }
@ -150,6 +173,46 @@ class MatrixSession private constructor(private val appContext: Context) {
return t 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 { suspend fun loginWithToken(homeserver: String, token: String): Result<Unit> = runCatching {
val hs = discoverHomeserver(normalizeHomeserver(homeserver)) val hs = discoverHomeserver(normalizeHomeserver(homeserver))
buildApi(hs) buildApi(hs)
@ -193,6 +256,10 @@ class MatrixSession private constructor(private val appContext: Context) {
roomEventCache.clear() roomEventCache.clear()
roomPrevBatches.clear() roomPrevBatches.clear()
directRoomTargets.clear() directRoomTargets.clear()
prevMessageCounts.clear()
pendingNotificationRooms.clear()
hasCompletedInitialSync = false
prefs.edit().remove("last_sync_token").apply()
verificationService.dismiss() verificationService.dismiss()
verificationService.userId = "" verificationService.userId = ""
verificationService.deviceId = "" verificationService.deviceId = ""
@ -357,6 +424,7 @@ class MatrixSession private constructor(private val appContext: Context) {
scope.launch { scope.launch {
uploadKeys() uploadKeys()
refreshDeviceSyncState() refreshDeviceSyncState()
prefs.getString("fcm_token", null)?.let { registerFcmPusher(it) }
startSync() startSync()
} }
} }
@ -672,7 +740,7 @@ class MatrixSession private constructor(private val appContext: Context) {
private fun startSync() { private fun startSync() {
syncJob?.cancel() syncJob?.cancel()
syncJob = scope.launch { syncJob = scope.launch {
var since: String? = null var since: String? = prefs.getString("last_sync_token", null)
var isFirstSync = true var isFirstSync = true
while (isActive) { while (isActive) {
val token = accessToken ?: break val token = accessToken ?: break
@ -685,6 +753,7 @@ class MatrixSession private constructor(private val appContext: Context) {
filter = syncFilter filter = syncFilter
) )
since = response.nextBatch since = response.nextBatch
prefs.edit().putString("last_sync_token", since).apply()
// Feed to-device events so incoming room keys are available before decryption // Feed to-device events so incoming room keys are available before decryption
var newKeyRooms: Set<String> = emptySet() var newKeyRooms: Set<String> = emptySet()
@ -715,6 +784,32 @@ class MatrixSession private constructor(private val appContext: Context) {
} }
} }
_syncState.value = SyncState(rooms = rooms, messages = messages) _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()) { if (extraMissing.isNotEmpty()) {
sendKeyRequests(extraMissing, "Bearer $token", matrixApi) sendKeyRequests(extraMissing, "Bearer $token", matrixApi)
} }
@ -733,6 +828,7 @@ class MatrixSession private constructor(private val appContext: Context) {
if (isFirstSync) { if (isFirstSync) {
isFirstSync = false isFirstSync = false
hasCompletedInitialSync = true
refreshDirectRoomTargets(token, matrixApi) refreshDirectRoomTargets(token, matrixApi)
refreshDeviceSyncState(token, matrixApi) refreshDeviceSyncState(token, matrixApi)
fetchMissingRoomNames(token, matrixApi) fetchMissingRoomNames(token, matrixApi)
@ -916,6 +1012,37 @@ class MatrixSession private constructor(private val appContext: Context) {
rebuildRoomMessages(roomId, rooms, messages, missingSessions) 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) _syncState.value = SyncState(rooms = rooms, messages = messages)
return missingSessions return missingSessions
} }
@ -1164,6 +1291,9 @@ class MatrixSession private constructor(private val appContext: Context) {
companion object { companion object {
private const val TAG = "MatrixCrypto" 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 @Volatile private var instance: MatrixSession? = null
fun getInstance(context: Context): MatrixSession = 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 package com.ltadeu6.matrix.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -31,6 +32,7 @@ fun AppNavigation(initialLoginToken: String? = null) {
val context = LocalContext.current val context = LocalContext.current
val session = MatrixSession.getInstance(context) val session = MatrixSession.getInstance(context)
val authState by session.authState.collectAsState() val authState by session.authState.collectAsState()
val pendingRoomId by session.pendingRoomId.collectAsState()
val startDest = when (authState) { val startDest = when (authState) {
is AuthState.LoggedIn -> Route.ROOMS is AuthState.LoggedIn -> Route.ROOMS
@ -39,6 +41,18 @@ fun AppNavigation(initialLoginToken: String? = null) {
val navController = rememberNavController() 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) { NavHost(navController = navController, startDestination = startDest) {
composable(Route.LOGIN) { composable(Route.LOGIN) {

View file

@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.google.services) apply false
} }

View file

@ -37,6 +37,8 @@
pkgs.jdk17 pkgs.jdk17
androidSdk androidSdk
pkgs.matrix-commander pkgs.matrix-commander
pkgs.nodejs_20
pkgs.wrangler
]; ];
ANDROID_HOME = androidSdkRoot; ANDROID_HOME = androidSdkRoot;

143
gateway/src/index.js Normal file
View file

@ -0,0 +1,143 @@
// Matrix push gateway — receives pushes from the Matrix homeserver and forwards to FCM.
// Runs on Cloudflare Workers (free tier: 100k requests/day).
//
// Required secrets (set via `wrangler secret put`):
// FIREBASE_PROJECT_ID — e.g. "matrix-f7c28"
// FIREBASE_SERVICE_ACCOUNT — full JSON content of the Firebase service account key
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
let body;
try {
body = await request.json();
} catch {
return new Response("Invalid JSON", { status: 400 });
}
const notification = body?.notification;
if (!notification) {
return new Response("Missing notification", { status: 400 });
}
const devices = notification.devices ?? [];
const rejected = [];
let accessToken;
try {
accessToken = await getFcmAccessToken(env.FIREBASE_SERVICE_ACCOUNT);
} catch (e) {
console.error("OAuth2 failed:", e.message);
return new Response("Gateway auth error", { status: 500 });
}
await Promise.all(
devices.map(async (device) => {
const token = device.pushkey;
try {
const resp = await fetch(
`https://fcm.googleapis.com/v1/projects/${env.FIREBASE_PROJECT_ID.trim()}/messages:send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: {
token,
data: {
room_id: notification.room_id ?? "",
sender: notification.sender ?? "",
event_id: notification.event_id ?? "",
},
android: { priority: "HIGH" },
},
}),
}
);
if (!resp.ok) {
console.error("FCM error:", await resp.text());
rejected.push(token);
} else {
console.log("FCM ok:", await resp.text());
}
} catch (e) {
console.error("FCM send failed:", e.message);
rejected.push(token);
}
})
);
return new Response(JSON.stringify({ rejected }), {
headers: { "Content-Type": "application/json" },
});
},
};
// Generate a Google OAuth2 access token from a service account JSON using Web Crypto.
async function getFcmAccessToken(serviceAccountJson) {
const sa = JSON.parse(serviceAccountJson);
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iss: sa.client_email,
scope: "https://www.googleapis.com/auth/firebase.messaging",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
};
const b64url = (obj) =>
btoa(JSON.stringify(obj))
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const signingInput = `${b64url(header)}.${b64url(payload)}`;
const pkcs8 = Uint8Array.from(
atob(
sa.private_key
.replace(/-----BEGIN PRIVATE KEY-----/, "")
.replace(/-----END PRIVATE KEY-----/, "")
.replace(/\n/g, "")
),
(c) => c.charCodeAt(0)
);
const key = await crypto.subtle.importKey(
"pkcs8",
pkcs8,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
new TextEncoder().encode(signingInput)
);
const encodedSig = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const jwt = `${signingInput}.${encodedSig}`;
const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
});
const tokenData = await tokenResp.json();
if (!tokenData.access_token) throw new Error(JSON.stringify(tokenData));
return tokenData.access_token;
}

11
gateway/wrangler.toml Normal file
View file

@ -0,0 +1,11 @@
name = "matrix-push-gateway"
main = "src/index.js"
compatibility_date = "2024-11-01"
workers_dev = true
# After deploying, set these secrets:
# wrangler secret put FIREBASE_PROJECT_ID
# wrangler secret put FIREBASE_SERVICE_ACCOUNT
#
# Then set PUSH_GATEWAY_URL in MatrixSession.kt to:
# https://matrix-push-gateway.<your-subdomain>.workers.dev

View file

@ -14,6 +14,8 @@ browser = "1.8.0"
retrofitKotlinxConverter = "1.0.0" retrofitKotlinxConverter = "1.0.0"
appcompat = "1.7.0" appcompat = "1.7.0"
olmSdk = "3.2.12" olmSdk = "3.2.12"
firebaseBom = "33.5.1"
googleServices = "4.4.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -36,9 +38,12 @@ androidx-browser = { group = "androidx.browser", name = "browser", version.ref =
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
olm-sdk = { group = "org.matrix.android", name = "olm-sdk", version.ref = "olmSdk" } olm-sdk = { group = "org.matrix.android", name = "olm-sdk", version.ref = "olmSdk" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }