@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.
- package/Package.swift +30 -0
- package/README.md +1474 -0
- package/TremazeCapacitorMatrix.podspec +17 -0
- package/android/build.gradle +70 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/de/tremaze/capacitor/matrix/CapMatrix.kt +1362 -0
- package/android/src/main/kotlin/de/tremaze/capacitor/matrix/CapMatrixPlugin.kt +775 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +1943 -0
- package/dist/esm/definitions.d.ts +347 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +193 -0
- package/dist/esm/web.js +950 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +964 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +964 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/CapMatrixPlugin/CapMatrix.swift +1552 -0
- package/ios/Sources/CapMatrixPlugin/CapMatrixPlugin.swift +780 -0
- package/ios/Tests/CapMatrixPluginTests/CapMatrixTests.swift +10 -0
- package/package.json +88 -0
|
@@ -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
|
+
}
|