diff --git a/.gitignore b/.gitignore
index e21ca82..f5e9805 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,14 @@
+# Secrets & credentials
+.secrets/
+*.env
+*.env.local
+google-services.json
+*-adminsdk-*.json
+firebase-debug.log
+.firebaserc
+.wrangler/
+gateway/node_modules/
+
.mc/
*.iml
.gradle/
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 11571d6..bc605d0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0deaa1e..413b59f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt b/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt
index 02ae1bf..2528319 100644
--- a/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt
+++ b/app/src/main/java/com/ltadeu6/matrix/MainActivity.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt b/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt
new file mode 100644
index 0000000..2ad612d
--- /dev/null
+++ b/app/src/main/java/com/ltadeu6/matrix/MatrixFirebaseMessagingService.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt b/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt
index 5427683..679bebf 100644
--- a/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt
+++ b/app/src/main/java/com/ltadeu6/matrix/chat/ChatScreen.kt
@@ -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(null) }
+ DisposableEffect(roomId) {
+ session.setActiveRoom(roomId)
+ onDispose { session.setActiveRoom(null) }
+ }
+
LaunchedEffect(messages.size, searchQuery) {
if (messages.isNotEmpty() && searchQuery.isBlank()) {
listState.animateScrollToItem(messages.size - 1)
diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt
index c7d9e9b..5f16e34 100644
--- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt
+++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixApi.kt
@@ -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,
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 359d0c9..5c3d870 100644
--- a/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt
+++ b/app/src/main/java/com/ltadeu6/matrix/matrix/MatrixSession.kt
@@ -59,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
private val _pendingSsoToken = MutableStateFlow(null)
val pendingSsoToken: StateFlow = _pendingSsoToken.asStateFlow()
+ private val _pendingRoomId = MutableStateFlow(null)
+ val pendingRoomId: StateFlow = _pendingRoomId.asStateFlow()
+
private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
val deviceSyncState: StateFlow = _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()
+
+ /** Rooms with new messages that couldn't be decrypted yet; value = prevCount before the new messages. */
+ private val pendingNotificationRooms = mutableMapOf()
+
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 = 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 = 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 =
diff --git a/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt b/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt
new file mode 100644
index 0000000..970a2ce
--- /dev/null
+++ b/app/src/main/java/com/ltadeu6/matrix/matrix/NotificationHelper.kt
@@ -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())
+ }
+}
diff --git a/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt b/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt
index 31b3d7e..757a79b 100644
--- a/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/ltadeu6/matrix/navigation/AppNavigation.kt
@@ -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) {
diff --git a/build.gradle.kts b/build.gradle.kts
index 132ad8d..f840f36 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.google.services) apply false
}
diff --git a/flake.nix b/flake.nix
index bd4eb95..6ce9696 100644
--- a/flake.nix
+++ b/flake.nix
@@ -37,6 +37,8 @@
pkgs.jdk17
androidSdk
pkgs.matrix-commander
+ pkgs.nodejs_20
+ pkgs.wrangler
];
ANDROID_HOME = androidSdkRoot;
diff --git a/gateway/src/index.js b/gateway/src/index.js
new file mode 100644
index 0000000..47fc928
--- /dev/null
+++ b/gateway/src/index.js
@@ -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;
+}
diff --git a/gateway/wrangler.toml b/gateway/wrangler.toml
new file mode 100644
index 0000000..6395345
--- /dev/null
+++ b/gateway/wrangler.toml
@@ -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..workers.dev
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 592029f..e5c24dc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,8 @@ browser = "1.8.0"
retrofitKotlinxConverter = "1.0.0"
appcompat = "1.7.0"
olmSdk = "3.2.12"
+firebaseBom = "33.5.1"
+googleServices = "4.4.2"
[libraries]
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" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", 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" }
+google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }