@tagea/capacitor-matrix 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1362 @@
1
+ package de.tremaze.capacitor.matrix
2
+
3
+ import android.content.Context
4
+ import androidx.security.crypto.EncryptedSharedPreferences
5
+ import androidx.security.crypto.MasterKey
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.Dispatchers
8
+ import kotlinx.coroutines.SupervisorJob
9
+ import kotlinx.coroutines.launch
10
+ import org.matrix.rustcomponents.sdk.BackupState
11
+ import org.matrix.rustcomponents.sdk.Client
12
+ import org.matrix.rustcomponents.sdk.ClientBuilder
13
+ import org.matrix.rustcomponents.sdk.CreateRoomParameters
14
+ import org.matrix.rustcomponents.sdk.EnableRecoveryProgress
15
+ import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener
16
+ import org.matrix.rustcomponents.sdk.EventOrTransactionId
17
+ import org.matrix.rustcomponents.sdk.EventSendState
18
+ import org.matrix.rustcomponents.sdk.EventTimelineItem
19
+ import org.matrix.rustcomponents.sdk.MembershipState
20
+ import org.matrix.rustcomponents.sdk.MessageType
21
+ import org.matrix.rustcomponents.sdk.MsgLikeKind
22
+ import org.matrix.rustcomponents.sdk.ReceiptType
23
+ import org.matrix.rustcomponents.sdk.RecoveryException
24
+ import org.matrix.rustcomponents.sdk.RecoveryState
25
+ import org.matrix.rustcomponents.sdk.Room
26
+ import org.matrix.rustcomponents.sdk.RoomPreset
27
+ import org.matrix.rustcomponents.sdk.RoomVisibility
28
+ import org.matrix.rustcomponents.sdk.Session
29
+ import org.matrix.rustcomponents.sdk.SlidingSyncVersion
30
+ import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
31
+ import org.matrix.rustcomponents.sdk.SqliteStoreBuilder
32
+ import org.matrix.rustcomponents.sdk.SyncService
33
+ import org.matrix.rustcomponents.sdk.SyncServiceState
34
+ import org.matrix.rustcomponents.sdk.SyncServiceStateObserver
35
+ import org.matrix.rustcomponents.sdk.TimelineDiff
36
+ import org.matrix.rustcomponents.sdk.TimelineItem
37
+ import org.matrix.rustcomponents.sdk.TimelineItemContent
38
+ import org.matrix.rustcomponents.sdk.TimelineListener
39
+ import org.matrix.rustcomponents.sdk.VerificationState
40
+ import uniffi.matrix_sdk_base.EncryptionState
41
+ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
42
+ import kotlinx.coroutines.Job
43
+ import kotlinx.coroutines.isActive
44
+ import java.io.OutputStreamWriter
45
+ import java.net.HttpURLConnection
46
+ import java.net.URL
47
+ import java.net.URLEncoder
48
+ import java.util.Collections
49
+
50
+ data class SessionInfo(
51
+ val accessToken: String,
52
+ val userId: String,
53
+ val deviceId: String,
54
+ val homeserverUrl: String,
55
+ )
56
+
57
+ class MatrixSDKBridge(private val context: Context) {
58
+
59
+ private var client: Client? = null
60
+ private var syncService: SyncService? = null
61
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
62
+ private val subscribedRoomIds = mutableSetOf<String>()
63
+ private val roomTimelines = mutableMapOf<String, org.matrix.rustcomponents.sdk.Timeline>()
64
+ // Keep strong references to listener handles so GC doesn't cancel the subscriptions
65
+ private val timelineListenerHandles = mutableListOf<Any>()
66
+ // Rooms currently being paginated by getRoomMessages — live listener suppresses events for these
67
+ private val paginatingRooms = Collections.synchronizedSet(mutableSetOf<String>())
68
+ private var receiptSyncJob: Job? = null
69
+ // Receipt cache: roomId → (eventId → set of userIds who sent a read receipt)
70
+ // Populated by the parallel v2 receipt sync since sliding sync doesn't deliver
71
+ // other users' read receipts via the Rust SDK's readReceipts property.
72
+ private val receiptCache = Collections.synchronizedMap(
73
+ mutableMapOf<String, MutableMap<String, MutableSet<String>>>()
74
+ )
75
+
76
+ private val sessionStore by lazy { MatrixSessionStore(context) }
77
+
78
+ // ── Auth ──────────────────────────────────────────────
79
+
80
+ suspend fun login(homeserverUrl: String, userId: String, password: String): SessionInfo {
81
+ // Use a per-user data directory to avoid crypto store conflicts
82
+ val safeUserId = userId.replace(Regex("[^a-zA-Z0-9_.-]"), "_")
83
+ val dataDir = context.filesDir.resolve("matrix_sdk/$safeUserId")
84
+ // Clear previous session data to avoid device ID mismatches
85
+ dataDir.deleteRecursively()
86
+ dataDir.mkdirs()
87
+ val dataDirPath = dataDir.absolutePath
88
+
89
+ val newClient = ClientBuilder()
90
+ .homeserverUrl(homeserverUrl)
91
+ .slidingSyncVersionBuilder(SlidingSyncVersionBuilder.NATIVE)
92
+ .autoEnableCrossSigning(true)
93
+ .sqliteStore(SqliteStoreBuilder(dataDirPath, dataDirPath))
94
+ .build()
95
+
96
+ newClient.login(userId, password, "Capacitor Matrix Plugin", null)
97
+
98
+ client = newClient
99
+ val session = newClient.session()
100
+ val info = session.toSessionInfo(homeserverUrl)
101
+ sessionStore.save(info)
102
+ return info
103
+ }
104
+
105
+ suspend fun loginWithToken(
106
+ homeserverUrl: String,
107
+ accessToken: String,
108
+ userId: String,
109
+ deviceId: String,
110
+ ): SessionInfo {
111
+ val safeUserId = userId.replace(Regex("[^a-zA-Z0-9_.-]"), "_")
112
+ val dataDir = context.filesDir.resolve("matrix_sdk/$safeUserId")
113
+ dataDir.mkdirs()
114
+ val dataDirPath = dataDir.absolutePath
115
+
116
+ val newClient = ClientBuilder()
117
+ .homeserverUrl(homeserverUrl)
118
+ .slidingSyncVersionBuilder(SlidingSyncVersionBuilder.NATIVE)
119
+ .autoEnableCrossSigning(true)
120
+ .sqliteStore(SqliteStoreBuilder(dataDirPath, dataDirPath))
121
+ .build()
122
+
123
+ val session = Session(
124
+ accessToken = accessToken,
125
+ refreshToken = null,
126
+ userId = userId,
127
+ deviceId = deviceId,
128
+ homeserverUrl = homeserverUrl,
129
+ oidcData = null,
130
+ slidingSyncVersion = SlidingSyncVersion.NATIVE,
131
+ )
132
+
133
+ newClient.restoreSession(session)
134
+ client = newClient
135
+
136
+ val info = SessionInfo(
137
+ accessToken = accessToken,
138
+ userId = userId,
139
+ deviceId = deviceId,
140
+ homeserverUrl = homeserverUrl,
141
+ )
142
+ sessionStore.save(info)
143
+ return info
144
+ }
145
+
146
+ suspend fun logout() {
147
+ syncService?.stop()
148
+ syncService = null
149
+ receiptSyncJob?.cancel()
150
+ receiptSyncJob = null
151
+ receiptCache.clear()
152
+ timelineListenerHandles.clear()
153
+ roomTimelines.clear()
154
+ subscribedRoomIds.clear()
155
+ client?.logout()
156
+ client = null
157
+ sessionStore.clear()
158
+ }
159
+
160
+ fun clearAllData() {
161
+ syncService = null
162
+ client = null
163
+ receiptSyncJob?.cancel()
164
+ receiptSyncJob = null
165
+ receiptCache.clear()
166
+ timelineListenerHandles.clear()
167
+ roomTimelines.clear()
168
+ subscribedRoomIds.clear()
169
+ sessionStore.clear()
170
+ val sdkDir = context.filesDir.resolve("matrix_sdk")
171
+ sdkDir.deleteRecursively()
172
+ }
173
+
174
+ fun getSession(): SessionInfo? {
175
+ return sessionStore.load()
176
+ }
177
+
178
+ // ── Sync ──────────────────────────────────────────────
179
+
180
+ suspend fun startSync(
181
+ onSyncState: (String) -> Unit,
182
+ onMessage: (Map<String, Any?>) -> Unit,
183
+ onRoomUpdate: (String, Map<String, Any?>) -> Unit,
184
+ onReceipt: (String) -> Unit,
185
+ ) {
186
+ val c = requireClient()
187
+ val service = c.syncService().finish()
188
+ syncService = service
189
+
190
+ service.state(object : SyncServiceStateObserver {
191
+ override fun onUpdate(state: SyncServiceState) {
192
+ val mapped = mapSyncState(state)
193
+ onSyncState(mapped)
194
+ // When sync reaches SYNCING, subscribe to room timelines
195
+ if (mapped == "SYNCING") {
196
+ scope.launch {
197
+ subscribeToRoomTimelines(c, onMessage, onRoomUpdate)
198
+ }
199
+ }
200
+ }
201
+ })
202
+
203
+ // Start a parallel v2 sync connection that only listens for m.receipt
204
+ // ephemeral events. Sliding sync doesn't deliver other users'
205
+ // read receipts, so this provides live receipt updates.
206
+ android.util.Log.d("CapMatrix", "startSync: launching receiptSync before service.start()")
207
+ startReceiptSync(onReceipt)
208
+
209
+ // service.start() blocks until sync stops, so it must be last
210
+ android.util.Log.d("CapMatrix", "startSync: calling service.start() (blocking)")
211
+ service.start()
212
+ }
213
+
214
+ private suspend fun subscribeToRoomTimelines(
215
+ c: Client,
216
+ onMessage: (Map<String, Any?>) -> Unit,
217
+ onRoomUpdate: (String, Map<String, Any?>) -> Unit,
218
+ ) {
219
+ for (room in c.rooms()) {
220
+ val roomId = room.id()
221
+ if (subscribedRoomIds.contains(roomId)) continue
222
+ subscribedRoomIds.add(roomId)
223
+ try {
224
+ val timeline = getOrCreateTimeline(room)
225
+ val handle = timeline.addListener(object : TimelineListener {
226
+ override fun onUpdate(diff: List<TimelineDiff>) {
227
+ // Suppress live events while getRoomMessages is paginating this room
228
+ if (paginatingRooms.contains(roomId)) return
229
+ try {
230
+ for (d in diff) {
231
+ when (d) {
232
+ is TimelineDiff.PushBack -> {
233
+ // Skip local echoes — the Set diff will follow with the real EventId
234
+ val isLocalEcho = d.value.asEvent()?.eventOrTransactionId is EventOrTransactionId.TransactionId
235
+ if (!isLocalEcho) {
236
+ serializeTimelineItem(d.value, roomId)?.let { onMessage(it) }
237
+ }
238
+ onRoomUpdate(roomId, mapOf("roomId" to roomId))
239
+ }
240
+ is TimelineDiff.Append -> {
241
+ d.values.forEach { item ->
242
+ serializeTimelineItem(item, roomId)?.let { onMessage(it) }
243
+ }
244
+ onRoomUpdate(roomId, mapOf("roomId" to roomId))
245
+ }
246
+ is TimelineDiff.Set -> {
247
+ serializeTimelineItem(d.value, roomId)?.let { onMessage(it) }
248
+ }
249
+ is TimelineDiff.Reset -> {
250
+ d.values.forEach { item ->
251
+ serializeTimelineItem(item, roomId)?.let { onMessage(it) }
252
+ }
253
+ onRoomUpdate(roomId, mapOf("roomId" to roomId))
254
+ }
255
+ is TimelineDiff.Insert -> {
256
+ serializeTimelineItem(d.value, roomId)?.let { onMessage(it) }
257
+ onRoomUpdate(roomId, mapOf("roomId" to roomId))
258
+ }
259
+ is TimelineDiff.PushFront -> {
260
+ serializeTimelineItem(d.value, roomId)?.let { onMessage(it) }
261
+ onRoomUpdate(roomId, mapOf("roomId" to roomId))
262
+ }
263
+ else -> { /* Remove, Clear, Truncate, PopBack, PopFront — no JS event needed */ }
264
+ }
265
+ }
266
+ } catch (e: Exception) {
267
+ android.util.Log.e("CapMatrix", "Error in timeline listener for $roomId: ${e.message}", e)
268
+ }
269
+ }
270
+ })
271
+ timelineListenerHandles.add(handle)
272
+ } catch (e: Exception) {
273
+ android.util.Log.e("CapMatrix", "Failed to subscribe to room $roomId: ${e.message}")
274
+ }
275
+ }
276
+ }
277
+
278
+ suspend fun stopSync() {
279
+ syncService?.stop()
280
+ subscribedRoomIds.clear()
281
+ timelineListenerHandles.clear()
282
+ roomTimelines.clear()
283
+ receiptSyncJob?.cancel()
284
+ receiptSyncJob = null
285
+ receiptCache.clear()
286
+ }
287
+
288
+ fun getSyncState(): String {
289
+ return "SYNCING" // Will reflect actual state once sync observers are fully wired
290
+ }
291
+
292
+ // ── Rooms ─────────────────────────────────────────────
293
+
294
+ suspend fun getRooms(): List<Map<String, Any?>> {
295
+ val c = requireClient()
296
+ val result = mutableListOf<Map<String, Any?>>()
297
+ for (room in c.rooms()) {
298
+ result.add(serializeRoom(room))
299
+ }
300
+ return result
301
+ }
302
+
303
+ suspend fun getRoomMembers(roomId: String): List<Map<String, Any?>> {
304
+ val c = requireClient()
305
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
306
+ val iterator = room.members()
307
+ val result = mutableListOf<Map<String, Any?>>()
308
+ while (true) {
309
+ val chunk = iterator.nextChunk(100u) ?: break
310
+ for (member in chunk) {
311
+ result.add(mapOf(
312
+ "userId" to member.userId,
313
+ "displayName" to member.displayName,
314
+ "membership" to when (member.membership) {
315
+ is MembershipState.Ban -> "ban"
316
+ is MembershipState.Invite -> "invite"
317
+ is MembershipState.Join -> "join"
318
+ is MembershipState.Knock -> "knock"
319
+ is MembershipState.Leave -> "leave"
320
+ else -> "unknown"
321
+ },
322
+ "avatarUrl" to member.avatarUrl,
323
+ ))
324
+ }
325
+ }
326
+ return result
327
+ }
328
+
329
+ suspend fun joinRoom(roomIdOrAlias: String): String {
330
+ val c = requireClient()
331
+ val room = c.joinRoomByIdOrAlias(roomIdOrAlias, emptyList())
332
+ return room.id()
333
+ }
334
+
335
+ suspend fun leaveRoom(roomId: String) {
336
+ val c = requireClient()
337
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
338
+ room.leave()
339
+ }
340
+
341
+ suspend fun forgetRoom(roomId: String) {
342
+ val c = requireClient()
343
+ // The Rust SDK doesn't have a dedicated forget method on the Room type.
344
+ // After leaving, the room is removed from the room list on next sync.
345
+ // This is a no-op placeholder for API compatibility.
346
+ }
347
+
348
+ suspend fun createRoom(
349
+ name: String?,
350
+ topic: String?,
351
+ isEncrypted: Boolean,
352
+ isDirect: Boolean = false,
353
+ invite: List<String>?,
354
+ preset: String? = null,
355
+ ): String {
356
+ val c = requireClient()
357
+ val roomPreset = when (preset) {
358
+ "trusted_private_chat" -> RoomPreset.TRUSTED_PRIVATE_CHAT
359
+ "public_chat" -> RoomPreset.PUBLIC_CHAT
360
+ else -> RoomPreset.PRIVATE_CHAT
361
+ }
362
+ val params = CreateRoomParameters(
363
+ name = name,
364
+ topic = topic,
365
+ isEncrypted = isEncrypted,
366
+ isDirect = isDirect,
367
+ visibility = RoomVisibility.Private,
368
+ preset = roomPreset,
369
+ invite = invite,
370
+ )
371
+ return c.createRoom(params)
372
+ }
373
+
374
+ // ── Messaging ─────────────────────────────────────────
375
+
376
+ suspend fun sendMessage(roomId: String, body: String, msgtype: String): String {
377
+ val c = requireClient()
378
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
379
+ val timeline = getOrCreateTimeline(room)
380
+
381
+ val content = messageEventContentFromMarkdown(body)
382
+ timeline.send(content)
383
+
384
+ // The Rust SDK's send() is fire-and-forget; the real eventId arrives via
385
+ // timeline listener when the server acknowledges. Use the messageReceived
386
+ // event listener to capture sent message IDs.
387
+ return ""
388
+ }
389
+
390
+ suspend fun editMessage(roomId: String, eventId: String, newBody: String): String {
391
+ val c = requireClient()
392
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
393
+ val timeline = getOrCreateTimeline(room)
394
+ val editContent = org.matrix.rustcomponents.sdk.EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody))
395
+ timeline.edit(EventOrTransactionId.EventId(eventId), editContent)
396
+ return ""
397
+ }
398
+
399
+ suspend fun sendReply(roomId: String, body: String, replyToEventId: String, msgtype: String): String {
400
+ val c = requireClient()
401
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
402
+ val timeline = getOrCreateTimeline(room)
403
+ val content = messageEventContentFromMarkdown(body)
404
+ timeline.sendReply(content, replyToEventId)
405
+ return ""
406
+ }
407
+
408
+ suspend fun getRoomMessages(roomId: String, limit: Int?, from: String?): Map<String, Any?> {
409
+ val c = requireClient()
410
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
411
+ val timeline = getOrCreateTimeline(room)
412
+ val requestedLimit = limit ?: 20
413
+
414
+ // Suppress live listener while we paginate to avoid flooding JS with historical events
415
+ paginatingRooms.add(roomId)
416
+ val collector = TimelineItemCollector(roomId)
417
+ val handle = timeline.addListener(collector)
418
+
419
+ try {
420
+ // Wait for the initial Reset snapshot before paginating
421
+ collector.waitForUpdate(timeoutMs = 5000)
422
+
423
+ // Only paginate if we don't have enough items yet
424
+ if (collector.events.size < requestedLimit) {
425
+ val hitStart = timeline.paginateBackwards(requestedLimit.toUShort())
426
+ if (!hitStart) {
427
+ collector.waitForUpdate(timeoutMs = 5000)
428
+ }
429
+ }
430
+ } finally {
431
+ handle.cancel()
432
+ paginatingRooms.remove(roomId)
433
+ }
434
+
435
+ var events = collector.events.takeLast(requestedLimit).map { it.toMutableMap() }
436
+
437
+ // Apply receipt watermark: if any own event has readBy data,
438
+ // all earlier own events in the timeline are also read.
439
+ val myUserId = try { c.userId() } catch (_: Exception) { null }
440
+ var watermarkReadBy: List<String>? = null
441
+ var watermarkIndex = -1
442
+ // Walk backwards (newest first) to find the newest own event with a read receipt
443
+ for (i in events.indices.reversed()) {
444
+ val sender = events[i]["senderId"] as? String
445
+ if (sender == myUserId) {
446
+ val rb = events[i]["readBy"] as? List<*>
447
+ if (rb != null && rb.isNotEmpty()) {
448
+ watermarkReadBy = rb.filterIsInstance<String>()
449
+ watermarkIndex = i
450
+ break
451
+ }
452
+ }
453
+ }
454
+ // Apply watermark only to own events BEFORE the watermark (older),
455
+ // not to events after it — a receipt on event N doesn't mean N+1 is read
456
+ if (watermarkReadBy != null && watermarkIndex >= 0) {
457
+ for (i in 0 until watermarkIndex) {
458
+ val sender = events[i]["senderId"] as? String
459
+ if (sender == myUserId) {
460
+ val existing = events[i]["readBy"] as? List<*>
461
+ if (existing == null || existing.isEmpty()) {
462
+ events[i]["status"] = "read"
463
+ events[i]["readBy"] = watermarkReadBy
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ return mapOf(
470
+ "events" to events,
471
+ "nextBatch" to null,
472
+ )
473
+ }
474
+
475
+ suspend fun markRoomAsRead(roomId: String, eventId: String) {
476
+ val c = requireClient()
477
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
478
+ getOrCreateTimeline(room).markAsRead(receiptType = ReceiptType.READ)
479
+ }
480
+
481
+ suspend fun refreshEventStatuses(roomId: String, eventIds: List<String>): List<Map<String, Any?>> {
482
+ val c = requireClient()
483
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
484
+ val timeline = getOrCreateTimeline(room)
485
+ val myUserId = try { c.userId() } catch (_: Exception) { null }
486
+ val results = mutableListOf<MutableMap<String, Any?>>()
487
+ for (eid in eventIds) {
488
+ try {
489
+ val eventItem = timeline.getEventTimelineItemByEventId(eid)
490
+ serializeEventTimelineItem(eventItem, roomId)?.let { results.add(it.toMutableMap()) }
491
+ } catch (_: Exception) {
492
+ // Event may no longer be in timeline; skip
493
+ }
494
+ }
495
+ // Apply receipt watermark: a read receipt on event N means all prior own events are read,
496
+ // but NOT events after N
497
+ var watermarkReadBy: List<String>? = null
498
+ var watermarkIndex = -1
499
+ for (i in results.indices.reversed()) {
500
+ val sender = results[i]["senderId"] as? String
501
+ if (sender == myUserId) {
502
+ val rb = results[i]["readBy"] as? List<*>
503
+ if (rb != null && rb.isNotEmpty()) {
504
+ watermarkReadBy = rb.filterIsInstance<String>()
505
+ watermarkIndex = i
506
+ break
507
+ }
508
+ }
509
+ }
510
+ if (watermarkReadBy != null && watermarkIndex >= 0) {
511
+ for (i in 0 until watermarkIndex) {
512
+ val sender = results[i]["senderId"] as? String
513
+ if (sender == myUserId) {
514
+ val existing = results[i]["readBy"] as? List<*>
515
+ if (existing == null || existing.isEmpty()) {
516
+ results[i]["status"] = "read"
517
+ results[i]["readBy"] = watermarkReadBy
518
+ }
519
+ }
520
+ }
521
+ }
522
+ return results
523
+ }
524
+
525
+ // ── Redactions & Reactions ─────────────────────────────
526
+
527
+ suspend fun redactEvent(roomId: String, eventId: String, reason: String?) {
528
+ val c = requireClient()
529
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
530
+ val timeline = getOrCreateTimeline(room)
531
+ timeline.redactEvent(EventOrTransactionId.EventId(eventId), reason)
532
+ }
533
+
534
+ fun getMediaUrl(mxcUrl: String): String {
535
+ // Convert mxc://server/media_id to authenticated download URL
536
+ val session = sessionStore.load() ?: throw IllegalStateException("Not logged in")
537
+ val baseUrl = session.homeserverUrl.trimEnd('/')
538
+ val mxcPath = mxcUrl.removePrefix("mxc://")
539
+ return "$baseUrl/_matrix/client/v1/media/download/$mxcPath?access_token=${session.accessToken}"
540
+ }
541
+
542
+ suspend fun sendReaction(roomId: String, eventId: String, key: String) {
543
+ val c = requireClient()
544
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
545
+ val timeline = getOrCreateTimeline(room)
546
+ timeline.toggleReaction(EventOrTransactionId.EventId(eventId), key)
547
+ }
548
+
549
+ // ── Room Management ──────────────────────────────────
550
+
551
+ suspend fun setRoomName(roomId: String, name: String) {
552
+ val c = requireClient()
553
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
554
+ room.setName(name)
555
+ }
556
+
557
+ suspend fun setRoomTopic(roomId: String, topic: String) {
558
+ val c = requireClient()
559
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
560
+ room.setTopic(topic)
561
+ }
562
+
563
+ suspend fun setRoomAvatar(roomId: String, mxcUrl: String) {
564
+ val c = requireClient()
565
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
566
+ room.uploadAvatar("image/png", ByteArray(0), null)
567
+ // The Rust SDK doesn't have a direct setAvatar(mxcUrl) method.
568
+ // For now this is a placeholder - the actual implementation would need
569
+ // to use the raw state event API if available.
570
+ }
571
+
572
+ suspend fun uploadContent(fileUri: String, fileName: String, mimeType: String): String {
573
+ val session = sessionStore.load() ?: throw IllegalStateException("Not logged in")
574
+ val baseUrl = session.homeserverUrl.trimEnd('/')
575
+ val url = URL("$baseUrl/_matrix/media/v3/upload?filename=${URLEncoder.encode(fileName, "UTF-8")}")
576
+ val connection = url.openConnection() as HttpURLConnection
577
+ connection.requestMethod = "POST"
578
+ connection.setRequestProperty("Authorization", "Bearer ${session.accessToken}")
579
+ connection.setRequestProperty("Content-Type", mimeType)
580
+ connection.doOutput = true
581
+
582
+ // Read file from URI
583
+ val inputStream = if (fileUri.startsWith("content://") || fileUri.startsWith("file://")) {
584
+ val uri = android.net.Uri.parse(fileUri)
585
+ context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("Cannot open file: $fileUri")
586
+ } else {
587
+ java.io.File(fileUri).inputStream()
588
+ }
589
+
590
+ inputStream.use { input ->
591
+ connection.outputStream.use { output ->
592
+ input.copyTo(output)
593
+ }
594
+ }
595
+
596
+ val responseCode = connection.responseCode
597
+ if (responseCode !in 200..299) {
598
+ throw Exception("Upload failed with status $responseCode")
599
+ }
600
+
601
+ val responseBody = connection.inputStream.bufferedReader().readText()
602
+ val json = org.json.JSONObject(responseBody)
603
+ return json.getString("content_uri")
604
+ }
605
+
606
+ fun getThumbnailUrl(mxcUrl: String, width: Int, height: Int, method: String): String {
607
+ val session = sessionStore.load() ?: throw IllegalStateException("Not logged in")
608
+ val baseUrl = session.homeserverUrl.trimEnd('/')
609
+ val mxcPath = mxcUrl.removePrefix("mxc://")
610
+ return "$baseUrl/_matrix/client/v1/media/thumbnail/$mxcPath?width=$width&height=$height&method=$method&access_token=${session.accessToken}"
611
+ }
612
+
613
+ suspend fun getDevices(): List<Map<String, Any?>> {
614
+ val session = sessionStore.load() ?: throw IllegalStateException("Not logged in")
615
+ val baseUrl = session.homeserverUrl.trimEnd('/')
616
+ val url = URL("$baseUrl/_matrix/client/v3/devices")
617
+ val connection = url.openConnection() as HttpURLConnection
618
+ connection.requestMethod = "GET"
619
+ connection.setRequestProperty("Authorization", "Bearer ${session.accessToken}")
620
+
621
+ val responseCode = connection.responseCode
622
+ if (responseCode !in 200..299) {
623
+ throw Exception("getDevices failed with status $responseCode")
624
+ }
625
+
626
+ val responseBody = connection.inputStream.bufferedReader().readText()
627
+ val json = org.json.JSONObject(responseBody)
628
+ val devicesArray = json.getJSONArray("devices")
629
+ val devices = mutableListOf<Map<String, Any?>>()
630
+ for (i in 0 until devicesArray.length()) {
631
+ val device = devicesArray.getJSONObject(i)
632
+ devices.add(mapOf(
633
+ "deviceId" to device.getString("device_id"),
634
+ "displayName" to device.optString("display_name", null),
635
+ "lastSeenTs" to if (device.has("last_seen_ts")) device.getLong("last_seen_ts") else null,
636
+ "lastSeenIp" to device.optString("last_seen_ip", null),
637
+ ))
638
+ }
639
+ return devices
640
+ }
641
+
642
+ suspend fun deleteDevice(deviceId: String) {
643
+ val session = sessionStore.load() ?: throw IllegalStateException("Not logged in")
644
+ val baseUrl = session.homeserverUrl.trimEnd('/')
645
+ val url = URL("$baseUrl/_matrix/client/v3/devices/$deviceId")
646
+ val connection = url.openConnection() as HttpURLConnection
647
+ connection.requestMethod = "DELETE"
648
+ connection.setRequestProperty("Authorization", "Bearer ${session.accessToken}")
649
+ connection.setRequestProperty("Content-Type", "application/json")
650
+ connection.doOutput = true
651
+
652
+ val body = org.json.JSONObject()
653
+ val writer = OutputStreamWriter(connection.outputStream)
654
+ writer.write(body.toString())
655
+ writer.flush()
656
+ writer.close()
657
+
658
+ val responseCode = connection.responseCode
659
+ // 401 means UIA is required - for now we just throw
660
+ if (responseCode !in 200..299) {
661
+ throw Exception("deleteDevice failed with status $responseCode")
662
+ }
663
+ }
664
+
665
+ suspend fun inviteUser(roomId: String, userId: String) {
666
+ val c = requireClient()
667
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
668
+ room.inviteUserById(userId)
669
+ }
670
+
671
+ suspend fun kickUser(roomId: String, userId: String, reason: String?) {
672
+ val c = requireClient()
673
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
674
+ room.kickUser(userId, reason)
675
+ }
676
+
677
+ suspend fun banUser(roomId: String, userId: String, reason: String?) {
678
+ val c = requireClient()
679
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
680
+ room.banUser(userId, reason)
681
+ }
682
+
683
+ suspend fun unbanUser(roomId: String, userId: String) {
684
+ val c = requireClient()
685
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
686
+ room.unbanUser(userId, null)
687
+ }
688
+
689
+ // ── Typing ───────────────────────────────────────────
690
+
691
+ suspend fun sendTyping(roomId: String, isTyping: Boolean) {
692
+ val c = requireClient()
693
+ val room = c.getRoom(roomId) ?: throw IllegalArgumentException("Room $roomId not found")
694
+ room.typingNotice(isTyping)
695
+ }
696
+
697
+ // ── Encryption ────────────────────────────────────────
698
+
699
+ suspend fun initializeCrypto() {
700
+ // No-op on native — Rust SDK handles crypto automatically
701
+ requireClient()
702
+ }
703
+
704
+ suspend fun bootstrapCrossSigning() {
705
+ val c = requireClient()
706
+ // Cross-signing is auto-enabled via ClientBuilder.autoEnableCrossSigning(true)
707
+ // Wait for E2EE initialization tasks to complete
708
+ c.encryption().waitForE2eeInitializationTasks()
709
+ }
710
+
711
+ suspend fun getEncryptionStatus(): Map<String, Any?> {
712
+ val c = requireClient()
713
+ val enc = c.encryption()
714
+ val backupState = enc.backupState()
715
+ val isBackupEnabled = backupState == BackupState.ENABLED ||
716
+ backupState == BackupState.CREATING ||
717
+ backupState == BackupState.RESUMING
718
+ val recoveryState = enc.recoveryState()
719
+ val verificationState = enc.verificationState()
720
+ val isVerified = verificationState == VerificationState.VERIFIED
721
+
722
+ return mapOf(
723
+ "isCrossSigningReady" to isVerified,
724
+ "crossSigningStatus" to mapOf(
725
+ "hasMaster" to isVerified,
726
+ "hasSelfSigning" to isVerified,
727
+ "hasUserSigning" to isVerified,
728
+ "isReady" to isVerified,
729
+ ),
730
+ "isKeyBackupEnabled" to isBackupEnabled,
731
+ "isSecretStorageReady" to (recoveryState == RecoveryState.ENABLED),
732
+ )
733
+ }
734
+
735
+ suspend fun setupKeyBackup(): Map<String, Any?> {
736
+ val c = requireClient()
737
+ try {
738
+ c.encryption().enableBackups()
739
+ } catch (e: Exception) {
740
+ throw IllegalStateException(
741
+ "Failed to enable key backup. You may need to set up recovery first: ${e.message}",
742
+ e,
743
+ )
744
+ }
745
+ return mapOf(
746
+ "exists" to true,
747
+ "enabled" to true,
748
+ )
749
+ }
750
+
751
+ suspend fun getKeyBackupStatus(): Map<String, Any?> {
752
+ val c = requireClient()
753
+ val existsOnServer = c.encryption().backupExistsOnServer()
754
+ val state = c.encryption().backupState()
755
+ val enabled = state == BackupState.ENABLED ||
756
+ state == BackupState.CREATING ||
757
+ state == BackupState.RESUMING
758
+ return mapOf(
759
+ "exists" to existsOnServer,
760
+ "enabled" to enabled,
761
+ )
762
+ }
763
+
764
+ suspend fun restoreKeyBackup(recoveryKey: String?): Map<String, Any?> {
765
+ val c = requireClient()
766
+ if (recoveryKey != null) {
767
+ c.encryption().recover(recoveryKey)
768
+ }
769
+ return mapOf("importedKeys" to -1)
770
+ }
771
+
772
+ suspend fun setupRecovery(passphrase: String?): Map<String, Any?> {
773
+ val c = requireClient()
774
+ try {
775
+ val key = c.encryption().enableRecovery(
776
+ waitForBackupsToUpload = false,
777
+ passphrase = passphrase,
778
+ progressListener = object : EnableRecoveryProgressListener {
779
+ override fun onUpdate(status: EnableRecoveryProgress) {
780
+ // no-op — callers get the key from the return value
781
+ }
782
+ },
783
+ )
784
+ return mapOf("recoveryKey" to key)
785
+ } catch (e: RecoveryException.BackupExistsOnServer) {
786
+ throw IllegalStateException(
787
+ "BACKUP_EXISTS",
788
+ e,
789
+ )
790
+ } catch (e: Exception) {
791
+ throw IllegalStateException("Failed to set up recovery: ${e::class.simpleName}: ${e.message}", e)
792
+ }
793
+ }
794
+
795
+ suspend fun isRecoveryEnabled(): Boolean {
796
+ val c = requireClient()
797
+ return c.encryption().recoveryState() == RecoveryState.ENABLED
798
+ }
799
+
800
+ suspend fun recoverAndSetup(recoveryKey: String) {
801
+ val c = requireClient()
802
+ c.encryption().recover(recoveryKey)
803
+ }
804
+
805
+ suspend fun resetRecoveryKey(): Map<String, Any?> {
806
+ val c = requireClient()
807
+ val key = c.encryption().resetRecoveryKey()
808
+ return mapOf("recoveryKey" to key)
809
+ }
810
+
811
+ // ── User Discovery ─────────────────────────────────────
812
+
813
+ suspend fun searchUsers(searchTerm: String, limit: Long): Map<String, Any?> {
814
+ val c = requireClient()
815
+ val result = c.searchUsers(searchTerm, limit.toULong())
816
+ return mapOf(
817
+ "results" to result.results.map { u ->
818
+ mapOf(
819
+ "userId" to u.userId,
820
+ "displayName" to u.displayName,
821
+ "avatarUrl" to u.avatarUrl,
822
+ )
823
+ },
824
+ "limited" to result.limited,
825
+ )
826
+ }
827
+
828
+ // ── Helpers ───────────────────────────────────────────
829
+
830
+ private fun requireClient(): Client {
831
+ return client ?: throw IllegalStateException("Not logged in. Call login() or loginWithToken() first.")
832
+ }
833
+
834
+ private suspend fun getOrCreateTimeline(room: Room): org.matrix.rustcomponents.sdk.Timeline {
835
+ return roomTimelines.getOrPut(room.id()) { room.timeline() }
836
+ }
837
+
838
+ private suspend fun serializeRoom(room: Room): Map<String, Any?> {
839
+ val info = room.roomInfo()
840
+ val membership = try {
841
+ when (room.membership()) {
842
+ org.matrix.rustcomponents.sdk.Membership.JOINED -> "join"
843
+ org.matrix.rustcomponents.sdk.Membership.INVITED -> "invite"
844
+ org.matrix.rustcomponents.sdk.Membership.LEFT -> "leave"
845
+ org.matrix.rustcomponents.sdk.Membership.BANNED -> "ban"
846
+ else -> "leave"
847
+ }
848
+ } catch (_: Exception) { "join" }
849
+ // Check if room is a DM
850
+ val isDirect = info.isDirect
851
+
852
+ // Get avatar URL (mxc://)
853
+ val avatarUrl = info.rawName?.let { null } // Rust SDK doesn't expose avatar URL via RoomInfo
854
+ // TODO: Expose room avatar from Rust SDK when available
855
+
856
+ return mapOf(
857
+ "roomId" to room.id(),
858
+ "name" to (info.displayName ?: ""),
859
+ "topic" to info.topic,
860
+ "memberCount" to info.joinedMembersCount.toInt(),
861
+ "isEncrypted" to (info.encryptionState != EncryptionState.NOT_ENCRYPTED),
862
+ "unreadCount" to (info.numUnreadMessages?.toInt() ?: 0),
863
+ "lastEventTs" to null,
864
+ "membership" to membership,
865
+ "avatarUrl" to avatarUrl,
866
+ "isDirect" to isDirect,
867
+ )
868
+ }
869
+
870
+ /**
871
+ * Collects timeline items from diffs, handling all diff types properly.
872
+ * Used by getRoomMessages to gather a consistent snapshot.
873
+ */
874
+ /**
875
+ * Mirrors the SDK's full timeline (including virtual/null items) so that
876
+ * index-based diffs (Insert, Remove, Set) stay correct. The public `events`
877
+ * property filters out nulls to return only real event items.
878
+ */
879
+ private inner class TimelineItemCollector(private val roomId: String) : TimelineListener {
880
+ private val lock = Object()
881
+ // Full mirror of the SDK timeline — null entries represent virtual items
882
+ // (day separators, read markers, etc.) that serializeTimelineItem skips.
883
+ private val _items = mutableListOf<MutableMap<String, Any?>?>()
884
+ private var _updateCount = 0
885
+ private var _lastWaitedCount = 0
886
+ private var _pendingDeferred: kotlinx.coroutines.CompletableDeferred<Boolean>? = null
887
+
888
+ /** Returns only the non-null (real event) items, in timeline order. */
889
+ val events: List<Map<String, Any?>>
890
+ get() = synchronized(lock) { _items.filterNotNull().toList() }
891
+
892
+ suspend fun waitForUpdate(timeoutMs: Long = 0): Boolean {
893
+ val deferred: kotlinx.coroutines.CompletableDeferred<Boolean>
894
+ synchronized(lock) {
895
+ if (_updateCount > _lastWaitedCount) {
896
+ _lastWaitedCount = _updateCount
897
+ return true
898
+ }
899
+ deferred = kotlinx.coroutines.CompletableDeferred()
900
+ _pendingDeferred = deferred
901
+ }
902
+ return if (timeoutMs > 0) {
903
+ try {
904
+ kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
905
+ } catch (_: kotlinx.coroutines.TimeoutCancellationException) {
906
+ synchronized(lock) {
907
+ _pendingDeferred = null
908
+ _lastWaitedCount = _updateCount
909
+ }
910
+ false
911
+ }
912
+ } else {
913
+ deferred.await()
914
+ }
915
+ }
916
+
917
+ override fun onUpdate(diff: List<TimelineDiff>) {
918
+ synchronized(lock) {
919
+ for (d in diff) {
920
+ when (d) {
921
+ is TimelineDiff.Reset -> {
922
+ _items.clear()
923
+ d.values.forEach { item ->
924
+ _items.add(serializeTimelineItem(item, roomId)?.toMutableMap())
925
+ }
926
+ }
927
+ is TimelineDiff.Append -> {
928
+ d.values.forEach { item ->
929
+ _items.add(serializeTimelineItem(item, roomId)?.toMutableMap())
930
+ }
931
+ }
932
+ is TimelineDiff.PushBack -> {
933
+ _items.add(serializeTimelineItem(d.value, roomId)?.toMutableMap())
934
+ }
935
+ is TimelineDiff.PushFront -> {
936
+ _items.add(0, serializeTimelineItem(d.value, roomId)?.toMutableMap())
937
+ }
938
+ is TimelineDiff.Set -> {
939
+ val idx = d.index.toInt()
940
+ if (idx in _items.indices) {
941
+ _items[idx] = serializeTimelineItem(d.value, roomId)?.toMutableMap()
942
+ }
943
+ }
944
+ is TimelineDiff.Insert -> {
945
+ val idx = minOf(d.index.toInt(), _items.size)
946
+ _items.add(idx, serializeTimelineItem(d.value, roomId)?.toMutableMap())
947
+ }
948
+ is TimelineDiff.Clear -> _items.clear()
949
+ is TimelineDiff.Remove -> {
950
+ val idx = d.index.toInt()
951
+ if (idx in _items.indices) _items.removeAt(idx)
952
+ }
953
+ is TimelineDiff.Truncate -> {
954
+ val len = d.length.toInt()
955
+ while (_items.size > len) _items.removeAt(_items.lastIndex)
956
+ }
957
+ is TimelineDiff.PopBack -> {
958
+ if (_items.isNotEmpty()) _items.removeAt(_items.lastIndex)
959
+ }
960
+ is TimelineDiff.PopFront -> {
961
+ if (_items.isNotEmpty()) _items.removeAt(0)
962
+ }
963
+ else -> {}
964
+ }
965
+ }
966
+ _updateCount++
967
+ val pending = _pendingDeferred
968
+ _pendingDeferred = null
969
+ pending?.complete(true)
970
+ }
971
+ }
972
+ }
973
+
974
+ private fun serializeTimelineItem(item: TimelineItem, roomId: String): Map<String, Any?>? {
975
+ val eventItem = item.asEvent() ?: return null
976
+ return serializeEventTimelineItem(eventItem, roomId)
977
+ }
978
+
979
+ private fun serializeEventTimelineItem(eventItem: EventTimelineItem, roomId: String): Map<String, Any?>? {
980
+ val eventId = when (val id = eventItem.eventOrTransactionId) {
981
+ is EventOrTransactionId.EventId -> id.eventId
982
+ is EventOrTransactionId.TransactionId -> id.transactionId
983
+ else -> ""
984
+ }
985
+
986
+ val contentMap = mutableMapOf<String, Any?>()
987
+ var eventType = "m.room.message"
988
+
989
+ try {
990
+ val content = eventItem.content
991
+ when (content) {
992
+ is TimelineItemContent.MsgLike -> {
993
+ val kind = content.content.kind
994
+ when (kind) {
995
+ is MsgLikeKind.Message -> {
996
+ contentMap["body"] = kind.content.body
997
+ when (val msgType = kind.content.msgType) {
998
+ is MessageType.Text -> {
999
+ contentMap["msgtype"] = "m.text"
1000
+ }
1001
+ is MessageType.Image -> {
1002
+ contentMap["msgtype"] = "m.image"
1003
+ contentMap["filename"] = msgType.content.filename
1004
+ extractMediaUrl(msgType.content.source, contentMap)
1005
+ }
1006
+ is MessageType.File -> {
1007
+ contentMap["msgtype"] = "m.file"
1008
+ contentMap["filename"] = msgType.content.filename
1009
+ extractMediaUrl(msgType.content.source, contentMap)
1010
+ }
1011
+ is MessageType.Audio -> {
1012
+ contentMap["msgtype"] = "m.audio"
1013
+ contentMap["filename"] = msgType.content.filename
1014
+ extractMediaUrl(msgType.content.source, contentMap)
1015
+ }
1016
+ is MessageType.Video -> {
1017
+ contentMap["msgtype"] = "m.video"
1018
+ contentMap["filename"] = msgType.content.filename
1019
+ extractMediaUrl(msgType.content.source, contentMap)
1020
+ }
1021
+ is MessageType.Emote -> {
1022
+ contentMap["msgtype"] = "m.emote"
1023
+ }
1024
+ is MessageType.Notice -> {
1025
+ contentMap["msgtype"] = "m.notice"
1026
+ }
1027
+ else -> {
1028
+ contentMap["msgtype"] = "m.text"
1029
+ }
1030
+ }
1031
+ }
1032
+ is MsgLikeKind.UnableToDecrypt -> {
1033
+ contentMap["body"] = "Unable to decrypt message"
1034
+ contentMap["msgtype"] = "m.text"
1035
+ contentMap["encrypted"] = true
1036
+ }
1037
+ is MsgLikeKind.Redacted -> {
1038
+ eventType = "m.room.redaction"
1039
+ contentMap["body"] = "Message deleted"
1040
+ }
1041
+ is MsgLikeKind.Other -> {
1042
+ eventType = kind.eventType.toString()
1043
+ }
1044
+ else -> {}
1045
+ }
1046
+ // Aggregate reactions from the Rust SDK
1047
+ val reactions = content.content.reactions
1048
+ if (reactions.isNotEmpty()) {
1049
+ contentMap["reactions"] = reactions.map { r ->
1050
+ mapOf(
1051
+ "key" to r.key,
1052
+ "count" to r.senders.size,
1053
+ "senders" to r.senders.map { s -> s.senderId },
1054
+ )
1055
+ }
1056
+ }
1057
+ }
1058
+ is TimelineItemContent.RoomMembership -> eventType = "m.room.member"
1059
+ else -> {}
1060
+ }
1061
+ } catch (e: Exception) {
1062
+ android.util.Log.e("CapMatrix", "Error serializing timeline item: ${e.message}", e)
1063
+ }
1064
+
1065
+ // Determine delivery/read status
1066
+ // Combine SDK readReceipts (usually empty with sliding sync) with our v2 receipt cache
1067
+ var status: String?
1068
+ var readBy: List<String>?
1069
+ try {
1070
+ val sendState = eventItem.localSendState
1071
+ if (sendState is org.matrix.rustcomponents.sdk.EventSendState.NotSentYet ||
1072
+ sendState is org.matrix.rustcomponents.sdk.EventSendState.SendingFailed) {
1073
+ status = "sending"
1074
+ readBy = null
1075
+ } else {
1076
+ // Merge SDK receipts + cache
1077
+ val allReaders = mutableSetOf<String>()
1078
+ // From SDK (may be empty with sliding sync)
1079
+ try {
1080
+ val sdkReceipts = eventItem.readReceipts
1081
+ allReaders.addAll(sdkReceipts.keys.filter { it != eventItem.sender })
1082
+ } catch (_: Exception) {}
1083
+ // From our v2 receipt cache
1084
+ val cachedReaders = receiptCache[roomId]?.get(eventId)
1085
+ if (cachedReaders != null) {
1086
+ allReaders.addAll(cachedReaders.filter { it != eventItem.sender })
1087
+ }
1088
+ readBy = if (allReaders.isNotEmpty()) allReaders.toList() else null
1089
+ status = if (allReaders.isNotEmpty()) "read" else "sent"
1090
+ }
1091
+ } catch (e: Exception) {
1092
+ android.util.Log.e("CapMatrix", "Error reading status for $eventId: ${e.message}")
1093
+ readBy = null
1094
+ status = "sent"
1095
+ }
1096
+
1097
+ return mapOf(
1098
+ "eventId" to eventId,
1099
+ "roomId" to roomId,
1100
+ "senderId" to eventItem.sender,
1101
+ "type" to eventType,
1102
+ "content" to contentMap,
1103
+ "originServerTs" to eventItem.timestamp.toLong(),
1104
+ "status" to status,
1105
+ "readBy" to readBy,
1106
+ )
1107
+ }
1108
+
1109
+ // ── Receipt Sync (parallel v2 sync for read receipts) ──────
1110
+
1111
+ private fun startReceiptSync(onReceipt: (String) -> Unit) {
1112
+ val session = sessionStore.load()
1113
+ if (session == null) {
1114
+ android.util.Log.e("CapMatrix", "receiptSync: NO SESSION FOUND, cannot start receipt sync")
1115
+ return
1116
+ }
1117
+ android.util.Log.d("CapMatrix", "receiptSync: session loaded, userId=${session.userId}, homeserver=${session.homeserverUrl}")
1118
+ receiptSyncJob?.cancel()
1119
+ receiptSyncJob = scope.launch {
1120
+ val baseUrl = session.homeserverUrl.trimEnd('/')
1121
+ val token = session.accessToken
1122
+ val userId = session.userId
1123
+
1124
+ android.util.Log.d("CapMatrix", "receiptSync: starting, uploading filter...")
1125
+
1126
+ val filterId = uploadSyncFilter(baseUrl, token, userId)
1127
+ android.util.Log.d("CapMatrix", "receiptSync: filterId=$filterId")
1128
+
1129
+ var since: String? = null
1130
+ val apiPaths = listOf("/_matrix/client/v3/sync", "/_matrix/client/r0/sync")
1131
+ var workingPath: String? = null
1132
+
1133
+ for (apiPath in apiPaths) {
1134
+ if (!isActive) return@launch
1135
+ try {
1136
+ val url = buildSyncUrl(baseUrl, apiPath, filterId, since = null, timeout = 0)
1137
+ val conn = (URL(url).openConnection() as HttpURLConnection).apply {
1138
+ requestMethod = "GET"
1139
+ setRequestProperty("Authorization", "Bearer $token")
1140
+ setRequestProperty("Accept", "application/json")
1141
+ connectTimeout = 30_000
1142
+ readTimeout = 30_000
1143
+ }
1144
+ try {
1145
+ val code = conn.responseCode
1146
+ if (code == 200) {
1147
+ val body = conn.inputStream.bufferedReader().readText()
1148
+ workingPath = apiPath
1149
+ val json = org.json.JSONObject(body)
1150
+ since = json.optString("next_batch", null)
1151
+ processReceiptResponse(body, onReceipt)
1152
+ android.util.Log.d("CapMatrix", "receiptSync: $apiPath works, since=$since")
1153
+ break
1154
+ } else {
1155
+ val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { "" }
1156
+ android.util.Log.d("CapMatrix", "receiptSync: $apiPath returned HTTP $code: ${errBody?.take(500)}")
1157
+ }
1158
+ } finally {
1159
+ conn.disconnect()
1160
+ }
1161
+ } catch (e: Exception) {
1162
+ android.util.Log.d("CapMatrix", "receiptSync: $apiPath failed: ${e.message}")
1163
+ }
1164
+ }
1165
+
1166
+ if (workingPath == null) {
1167
+ android.util.Log.d("CapMatrix", "receiptSync: no working sync endpoint found, giving up")
1168
+ return@launch
1169
+ }
1170
+
1171
+ android.util.Log.d("CapMatrix", "receiptSync: entering long-poll loop on $workingPath")
1172
+
1173
+ while (isActive) {
1174
+ try {
1175
+ val url = buildSyncUrl(baseUrl, workingPath!!, filterId, since, timeout = 30000)
1176
+ val conn = (URL(url).openConnection() as HttpURLConnection).apply {
1177
+ requestMethod = "GET"
1178
+ setRequestProperty("Authorization", "Bearer $token")
1179
+ setRequestProperty("Accept", "application/json")
1180
+ connectTimeout = 60_000
1181
+ readTimeout = 60_000
1182
+ }
1183
+ try {
1184
+ val code = conn.responseCode
1185
+ if (code != 200) {
1186
+ val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { "" }
1187
+ android.util.Log.d("CapMatrix", "receiptSync: HTTP $code: ${errBody?.take(300)}")
1188
+ kotlinx.coroutines.delay(5000)
1189
+ continue
1190
+ }
1191
+ val body = conn.inputStream.bufferedReader().readText()
1192
+ val json = org.json.JSONObject(body)
1193
+ json.optString("next_batch", null)?.let { since = it }
1194
+ processReceiptResponse(body, onReceipt)
1195
+ } finally {
1196
+ conn.disconnect()
1197
+ }
1198
+ } catch (e: kotlinx.coroutines.CancellationException) {
1199
+ break
1200
+ } catch (e: Exception) {
1201
+ android.util.Log.d("CapMatrix", "receiptSync: error: ${e.message}")
1202
+ if (isActive) kotlinx.coroutines.delay(5000)
1203
+ }
1204
+ }
1205
+ android.util.Log.d("CapMatrix", "receiptSync: loop ended")
1206
+ }
1207
+ }
1208
+
1209
+ private fun uploadSyncFilter(baseUrl: String, accessToken: String, userId: String): String? {
1210
+ return try {
1211
+ val encodedUserId = URLEncoder.encode(userId, "UTF-8")
1212
+ val url = URL("$baseUrl/_matrix/client/v3/user/$encodedUserId/filter")
1213
+ val conn = (url.openConnection() as HttpURLConnection).apply {
1214
+ requestMethod = "POST"
1215
+ setRequestProperty("Authorization", "Bearer $accessToken")
1216
+ setRequestProperty("Content-Type", "application/json")
1217
+ connectTimeout = 15_000
1218
+ readTimeout = 15_000
1219
+ doOutput = true
1220
+ }
1221
+ val filterJson = """{"room":{"timeline":{"limit":0},"state":{"types":[]},"ephemeral":{"types":["m.receipt"]}},"presence":{"types":[]}}"""
1222
+ conn.outputStream.use { OutputStreamWriter(it).apply { write(filterJson); flush() } }
1223
+ val code = conn.responseCode
1224
+ if (code == 200) {
1225
+ val body = conn.inputStream.bufferedReader().readText()
1226
+ val json = org.json.JSONObject(body)
1227
+ json.optString("filter_id", null)
1228
+ } else {
1229
+ val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { "" }
1230
+ android.util.Log.d("CapMatrix", "receiptSync: filter upload HTTP $code: ${errBody?.take(300)}")
1231
+ null
1232
+ }
1233
+ } catch (e: Exception) {
1234
+ android.util.Log.d("CapMatrix", "receiptSync: filter upload failed: ${e.message}")
1235
+ null
1236
+ }
1237
+ }
1238
+
1239
+ private fun buildSyncUrl(baseUrl: String, apiPath: String, filterId: String?, since: String?, timeout: Int): String {
1240
+ val sb = StringBuilder("$baseUrl$apiPath?timeout=$timeout")
1241
+ if (filterId != null) {
1242
+ sb.append("&filter=").append(URLEncoder.encode(filterId, "UTF-8"))
1243
+ } else {
1244
+ val inlineFilter = """{"room":{"timeline":{"limit":0},"state":{"types":[]},"ephemeral":{"types":["m.receipt"]}},"presence":{"types":[]}}"""
1245
+ sb.append("&filter=").append(URLEncoder.encode(inlineFilter, "UTF-8"))
1246
+ }
1247
+ if (since != null) {
1248
+ sb.append("&since=").append(URLEncoder.encode(since, "UTF-8"))
1249
+ }
1250
+ return sb.toString()
1251
+ }
1252
+
1253
+ private fun processReceiptResponse(body: String, onReceipt: (String) -> Unit) {
1254
+ try {
1255
+ val json = org.json.JSONObject(body)
1256
+ val join = json.optJSONObject("rooms")?.optJSONObject("join") ?: return
1257
+ val myUserId = try { client?.userId() } catch (_: Exception) { null }
1258
+ for (roomId in join.keys()) {
1259
+ val roomData = join.optJSONObject(roomId) ?: continue
1260
+ val ephemeral = roomData.optJSONObject("ephemeral") ?: continue
1261
+ val events = ephemeral.optJSONArray("events") ?: continue
1262
+ var hasReceipts = false
1263
+ for (i in 0 until events.length()) {
1264
+ val event = events.optJSONObject(i) ?: continue
1265
+ if (event.optString("type") != "m.receipt") continue
1266
+ hasReceipts = true
1267
+ // Content format: { "$eventId": { "m.read": { "@user:server": { "ts": 123 } } } }
1268
+ val content = event.optJSONObject("content") ?: continue
1269
+ val roomReceipts = receiptCache.getOrPut(roomId) { mutableMapOf() }
1270
+ for (eventId in content.keys()) {
1271
+ val receiptTypes = content.optJSONObject(eventId) ?: continue
1272
+ // Check both m.read and m.read.private
1273
+ for (rType in listOf("m.read", "m.read.private")) {
1274
+ val readers = receiptTypes.optJSONObject(rType) ?: continue
1275
+ for (userId in readers.keys()) {
1276
+ // Skip own receipts — we only care about other people reading our messages
1277
+ if (userId == myUserId) continue
1278
+ roomReceipts.getOrPut(eventId) { mutableSetOf() }.add(userId)
1279
+ android.util.Log.d("CapMatrix", "receiptSync: cached receipt roomId=$roomId eventId=$eventId userId=$userId")
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+ if (hasReceipts) {
1285
+ android.util.Log.d("CapMatrix", "receiptSync: receipt in $roomId, cache size=${receiptCache[roomId]?.size ?: 0}")
1286
+ onReceipt(roomId)
1287
+ }
1288
+ }
1289
+ } catch (e: Exception) {
1290
+ android.util.Log.e("CapMatrix", "receiptSync: processReceiptResponse error: ${e.message}")
1291
+ }
1292
+ }
1293
+
1294
+ private fun mapSyncState(state: SyncServiceState): String {
1295
+ return when (state) {
1296
+ SyncServiceState.IDLE -> "STOPPED"
1297
+ SyncServiceState.RUNNING -> "SYNCING"
1298
+ SyncServiceState.TERMINATED -> "STOPPED"
1299
+ SyncServiceState.ERROR -> "ERROR"
1300
+ SyncServiceState.OFFLINE -> "ERROR"
1301
+ }
1302
+ }
1303
+
1304
+ private fun extractMediaUrl(source: org.matrix.rustcomponents.sdk.MediaSource, contentMap: MutableMap<String, Any?>) {
1305
+ try {
1306
+ val url = source.url()
1307
+ contentMap["url"] = url
1308
+ } catch (_: Exception) { }
1309
+ // Always try toJson as well — for encrypted media the url() may be empty or fail
1310
+ if (contentMap["url"] == null || (contentMap["url"] as? String).isNullOrEmpty()) {
1311
+ try {
1312
+ val json = source.toJson()
1313
+ val parsed = org.json.JSONObject(json)
1314
+ // Encrypted media has the URL nested in the JSON
1315
+ val url = parsed.optString("url", "")
1316
+ if (url.isNotEmpty()) {
1317
+ contentMap["url"] = url
1318
+ }
1319
+ } catch (_: Exception) { }
1320
+ }
1321
+ }
1322
+
1323
+ private fun Session.toSessionInfo(homeserverUrl: String): SessionInfo {
1324
+ return SessionInfo(
1325
+ accessToken = this.accessToken,
1326
+ userId = this.userId,
1327
+ deviceId = this.deviceId,
1328
+ homeserverUrl = homeserverUrl,
1329
+ )
1330
+ }
1331
+ }
1332
+
1333
+ class MatrixSessionStore(context: Context) {
1334
+ private val prefs = EncryptedSharedPreferences.create(
1335
+ context,
1336
+ "matrix_session",
1337
+ MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
1338
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
1339
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
1340
+ )
1341
+
1342
+ fun save(session: SessionInfo) {
1343
+ prefs.edit()
1344
+ .putString("accessToken", session.accessToken)
1345
+ .putString("userId", session.userId)
1346
+ .putString("deviceId", session.deviceId)
1347
+ .putString("homeserverUrl", session.homeserverUrl)
1348
+ .apply()
1349
+ }
1350
+
1351
+ fun load(): SessionInfo? {
1352
+ val accessToken = prefs.getString("accessToken", null) ?: return null
1353
+ val userId = prefs.getString("userId", null) ?: return null
1354
+ val deviceId = prefs.getString("deviceId", null) ?: return null
1355
+ val homeserverUrl = prefs.getString("homeserverUrl", null) ?: return null
1356
+ return SessionInfo(accessToken, userId, deviceId, homeserverUrl)
1357
+ }
1358
+
1359
+ fun clear() {
1360
+ prefs.edit().clear().apply()
1361
+ }
1362
+ }