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
|
|
@ -2,6 +2,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
|
@ -11,6 +12,10 @@
|
|||
android:supportsRtl="false"
|
||||
android:theme="@style/Theme.MatrixAndroid">
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="matrix_messages" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
|
@ -34,6 +39,15 @@
|
|||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MatrixFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -72,6 +73,11 @@ fun ChatScreen(
|
|||
val listState = rememberLazyListState()
|
||||
var selectedMessage by remember { mutableStateOf<Message?>(null) }
|
||||
|
||||
DisposableEffect(roomId) {
|
||||
session.setActiveRoom(roomId)
|
||||
onDispose { session.setActiveRoom(null) }
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size, searchQuery) {
|
||||
if (messages.isNotEmpty() && searchQuery.isBlank()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
private val _pendingSsoToken = MutableStateFlow<String?>(null)
|
||||
val pendingSsoToken: StateFlow<String?> = _pendingSsoToken.asStateFlow()
|
||||
|
||||
private val _pendingRoomId = MutableStateFlow<String?>(null)
|
||||
val pendingRoomId: StateFlow<String?> = _pendingRoomId.asStateFlow()
|
||||
|
||||
private val _deviceSyncState = MutableStateFlow(DeviceSyncState())
|
||||
val deviceSyncState: StateFlow<DeviceSyncState> = _deviceSyncState.asStateFlow()
|
||||
|
||||
|
|
@ -89,6 +92,23 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
/** Sessions we failed to decrypt this sync batch and need to request. */
|
||||
private data class MissingSession(val roomId: String, val senderKey: String, val sessionId: String)
|
||||
|
||||
/** Room currently open in the UI — suppress notifications for it. */
|
||||
var activeRoomId: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* True once we've processed at least one sync batch. Prevents burst notifications on a
|
||||
* fresh-install initial sync (since=null). When restored from a saved sync token this is
|
||||
* initialised to true so FCM-woken restarts fire notifications immediately.
|
||||
*/
|
||||
private var hasCompletedInitialSync = prefs.getString("last_sync_token", null) != null
|
||||
|
||||
/** Last known message count per room, used to detect new messages across syncs. */
|
||||
private val prevMessageCounts = mutableMapOf<String, Int>()
|
||||
|
||||
/** Rooms with new messages that couldn't be decrypted yet; value = prevCount before the new messages. */
|
||||
private val pendingNotificationRooms = mutableMapOf<String, Int>()
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
|
|
@ -103,6 +123,8 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
""".trimIndent().replace("\n", "")
|
||||
|
||||
init {
|
||||
NotificationHelper.createChannel(appContext)
|
||||
|
||||
verificationService.onSend = { toUser, toDevice, eventType, txnId, content ->
|
||||
sendVerificationToDevice(toUser, toDevice, eventType, txnId, content)
|
||||
}
|
||||
|
|
@ -138,6 +160,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
refreshDeviceSyncState()
|
||||
}
|
||||
}
|
||||
prefs.getString("fcm_token", null)?.let { registerFcmPusher(it) }
|
||||
startSync()
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +173,46 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
return t
|
||||
}
|
||||
|
||||
fun setActiveRoom(roomId: String?) {
|
||||
activeRoomId = roomId
|
||||
if (roomId != null) {
|
||||
NotificationHelper.cancelRoomNotification(appContext, roomId)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleRoomIntent(roomId: String) {
|
||||
_pendingRoomId.value = roomId
|
||||
}
|
||||
|
||||
fun consumePendingRoomId(): String? {
|
||||
val r = _pendingRoomId.value
|
||||
_pendingRoomId.value = null
|
||||
return r
|
||||
}
|
||||
|
||||
fun registerFcmPusher(token: String) {
|
||||
prefs.edit().putString("fcm_token", token).apply()
|
||||
if (PUSH_GATEWAY_URL.isEmpty()) return
|
||||
val t = accessToken ?: return
|
||||
scope.launch {
|
||||
runCatching {
|
||||
api?.setPusher("Bearer $t", buildJsonObject {
|
||||
put("kind", "http")
|
||||
put("app_id", "com.ltadeu6.matrix")
|
||||
put("app_display_name", "Matrix Android")
|
||||
put("device_display_name", android.os.Build.MODEL)
|
||||
put("pushkey", token)
|
||||
put("lang", "en")
|
||||
put("data", buildJsonObject {
|
||||
put("url", "$PUSH_GATEWAY_URL/_matrix/push/v1/notify")
|
||||
put("format", "event_id_only")
|
||||
})
|
||||
})
|
||||
}.onSuccess { Log.i(TAG, "FCM pusher registered") }
|
||||
.onFailure { Log.w(TAG, "FCM pusher registration failed: $it") }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loginWithToken(homeserver: String, token: String): Result<Unit> = runCatching {
|
||||
val hs = discoverHomeserver(normalizeHomeserver(homeserver))
|
||||
buildApi(hs)
|
||||
|
|
@ -193,6 +256,10 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
roomEventCache.clear()
|
||||
roomPrevBatches.clear()
|
||||
directRoomTargets.clear()
|
||||
prevMessageCounts.clear()
|
||||
pendingNotificationRooms.clear()
|
||||
hasCompletedInitialSync = false
|
||||
prefs.edit().remove("last_sync_token").apply()
|
||||
verificationService.dismiss()
|
||||
verificationService.userId = ""
|
||||
verificationService.deviceId = ""
|
||||
|
|
@ -357,6 +424,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
scope.launch {
|
||||
uploadKeys()
|
||||
refreshDeviceSyncState()
|
||||
prefs.getString("fcm_token", null)?.let { registerFcmPusher(it) }
|
||||
startSync()
|
||||
}
|
||||
}
|
||||
|
|
@ -672,7 +740,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
private fun startSync() {
|
||||
syncJob?.cancel()
|
||||
syncJob = scope.launch {
|
||||
var since: String? = null
|
||||
var since: String? = prefs.getString("last_sync_token", null)
|
||||
var isFirstSync = true
|
||||
while (isActive) {
|
||||
val token = accessToken ?: break
|
||||
|
|
@ -685,6 +753,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
filter = syncFilter
|
||||
)
|
||||
since = response.nextBatch
|
||||
prefs.edit().putString("last_sync_token", since).apply()
|
||||
|
||||
// Feed to-device events so incoming room keys are available before decryption
|
||||
var newKeyRooms: Set<String> = emptySet()
|
||||
|
|
@ -715,6 +784,32 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
}
|
||||
}
|
||||
_syncState.value = SyncState(rooms = rooms, messages = messages)
|
||||
|
||||
// Fire deferred notifications now that messages are decrypted
|
||||
for (roomId in newKeyRooms) {
|
||||
val baseCount = pendingNotificationRooms[roomId] ?: continue
|
||||
if (roomId == activeRoomId) {
|
||||
pendingNotificationRooms.remove(roomId)
|
||||
continue
|
||||
}
|
||||
val newMsgs = messages[roomId] ?: continue
|
||||
val incoming = newMsgs.drop(baseCount)
|
||||
.filter { !it.isOutgoing && it.body != "[encrypted message]" }
|
||||
if (incoming.isNotEmpty()) {
|
||||
val last = incoming.last()
|
||||
val roomName = rooms[roomId]?.name ?: roomId
|
||||
val senderName = last.sender.substringBefore(":").trimStart('@')
|
||||
NotificationHelper.postMessageNotification(
|
||||
context = appContext,
|
||||
roomId = roomId,
|
||||
roomName = roomName,
|
||||
senderName = senderName,
|
||||
body = last.body
|
||||
)
|
||||
pendingNotificationRooms.remove(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
if (extraMissing.isNotEmpty()) {
|
||||
sendKeyRequests(extraMissing, "Bearer $token", matrixApi)
|
||||
}
|
||||
|
|
@ -733,6 +828,7 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
|
||||
if (isFirstSync) {
|
||||
isFirstSync = false
|
||||
hasCompletedInitialSync = true
|
||||
refreshDirectRoomTargets(token, matrixApi)
|
||||
refreshDeviceSyncState(token, matrixApi)
|
||||
fetchMissingRoomNames(token, matrixApi)
|
||||
|
|
@ -916,6 +1012,37 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
rebuildRoomMessages(roomId, rooms, messages, missingSessions)
|
||||
}
|
||||
|
||||
if (hasCompletedInitialSync) {
|
||||
for ((roomId, newMsgs) in messages) {
|
||||
val prevCount = prevMessageCounts[roomId] ?: 0
|
||||
if (newMsgs.size > prevCount && roomId != activeRoomId) {
|
||||
val incoming = newMsgs.drop(prevCount).filter { !it.isOutgoing }
|
||||
if (incoming.isNotEmpty()) {
|
||||
val last = incoming.last()
|
||||
if (last.body == "[encrypted message]") {
|
||||
// Defer: room key hasn't arrived yet — fire after newKeyRooms re-decrypt
|
||||
pendingNotificationRooms[roomId] = prevCount
|
||||
} else {
|
||||
val roomName = rooms[roomId]?.name ?: roomId
|
||||
val senderName = last.sender.substringBefore(":").trimStart('@')
|
||||
NotificationHelper.postMessageNotification(
|
||||
context = appContext,
|
||||
roomId = roomId,
|
||||
roomName = roomName,
|
||||
senderName = senderName,
|
||||
body = last.body
|
||||
)
|
||||
pendingNotificationRooms.remove(roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
prevMessageCounts[roomId] = newMsgs.size
|
||||
}
|
||||
} else {
|
||||
// Seed counts so we don't fire on resume after initial sync
|
||||
for ((roomId, msgs) in messages) prevMessageCounts[roomId] = msgs.size
|
||||
}
|
||||
|
||||
_syncState.value = SyncState(rooms = rooms, messages = messages)
|
||||
return missingSessions
|
||||
}
|
||||
|
|
@ -1164,6 +1291,9 @@ class MatrixSession private constructor(private val appContext: Context) {
|
|||
|
||||
companion object {
|
||||
private const val TAG = "MatrixCrypto"
|
||||
|
||||
/** Cloudflare Worker push gateway URL. */
|
||||
const val PUSH_GATEWAY_URL = "https://matrix-push-gateway.ltadeu6.workers.dev"
|
||||
@Volatile private var instance: MatrixSession? = null
|
||||
|
||||
fun getInstance(context: Context): MatrixSession =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue