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" }