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:
parent
1f5e6ca691
commit
de6386d614
15 changed files with 489 additions and 5 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
143
gateway/src/index.js
Normal 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
11
gateway/wrangler.toml
Normal 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
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue