@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,1552 @@
1
+ import Foundation
2
+ import MatrixRustSDK
3
+
4
+ struct MatrixSessionInfo {
5
+ let accessToken: String
6
+ let userId: String
7
+ let deviceId: String
8
+ let homeserverUrl: String
9
+
10
+ func toDictionary() -> [String: String] {
11
+ return [
12
+ "accessToken": accessToken,
13
+ "userId": userId,
14
+ "deviceId": deviceId,
15
+ "homeserverUrl": homeserverUrl
16
+ ]
17
+ }
18
+ }
19
+
20
+ class MatrixSDKBridge {
21
+
22
+ private var client: Client?
23
+ private var syncService: SyncService?
24
+ private let sessionStore = MatrixKeychainStore()
25
+ private var subscribedRoomIds = Set<String>()
26
+ private var roomTimelines: [String: Timeline] = [:]
27
+ // Keep strong references so GC doesn't cancel subscriptions
28
+ private var timelineListenerHandles: [Any] = []
29
+ private var syncStateHandle: TaskHandle?
30
+ private var syncStateObserver: SyncStateObserverProxy?
31
+ private let subscriptionLock = NSLock()
32
+ private var receiptSyncTask: Task<Void, Never>?
33
+
34
+ // MARK: - Auth
35
+
36
+ func login(homeserverUrl: String, userId: String, password: String) async throws -> [String: String] {
37
+ do {
38
+ return try await _login(homeserverUrl: homeserverUrl, userId: userId, password: password)
39
+ } catch {
40
+ if "\(error)".contains("account in the store") {
41
+ print("[CapMatrix] Crypto store mismatch — clearing data and retrying login")
42
+ clearAllData()
43
+ return try await _login(homeserverUrl: homeserverUrl, userId: userId, password: password)
44
+ }
45
+ throw error
46
+ }
47
+ }
48
+
49
+ private func _login(homeserverUrl: String, userId: String, password: String) async throws -> [String: String] {
50
+ let dataDir = Self.dataDirectory()
51
+ let cacheDir = Self.cacheDirectory()
52
+
53
+ let newClient = try await ClientBuilder()
54
+ .homeserverUrl(url: homeserverUrl)
55
+ .sessionPaths(dataPath: dataDir, cachePath: cacheDir)
56
+ .slidingSyncVersionBuilder(versionBuilder: .native)
57
+ .autoEnableCrossSigning(autoEnableCrossSigning: true)
58
+ .build()
59
+
60
+ try await newClient.login(
61
+ username: userId,
62
+ password: password,
63
+ initialDeviceName: "Capacitor Matrix Plugin",
64
+ deviceId: nil
65
+ )
66
+
67
+ client = newClient
68
+ let session = try newClient.session()
69
+ let info = MatrixSessionInfo(
70
+ accessToken: session.accessToken,
71
+ userId: session.userId,
72
+ deviceId: session.deviceId,
73
+ homeserverUrl: homeserverUrl
74
+ )
75
+ sessionStore.save(session: info)
76
+ return info.toDictionary()
77
+ }
78
+
79
+ func loginWithToken(homeserverUrl: String, accessToken: String, userId: String, deviceId: String) async throws -> [String: String] {
80
+ do {
81
+ return try await _loginWithToken(homeserverUrl: homeserverUrl, accessToken: accessToken, userId: userId, deviceId: deviceId)
82
+ } catch {
83
+ // If crypto store has mismatched account, wipe and retry
84
+ if "\(error)".contains("account in the store") {
85
+ print("[CapMatrix] Crypto store mismatch — clearing data and retrying login")
86
+ clearAllData()
87
+ return try await _loginWithToken(homeserverUrl: homeserverUrl, accessToken: accessToken, userId: userId, deviceId: deviceId)
88
+ }
89
+ throw error
90
+ }
91
+ }
92
+
93
+ private func _loginWithToken(homeserverUrl: String, accessToken: String, userId: String, deviceId: String) async throws -> [String: String] {
94
+ let dataDir = Self.dataDirectory()
95
+ let cacheDir = Self.cacheDirectory()
96
+
97
+ let newClient = try await ClientBuilder()
98
+ .homeserverUrl(url: homeserverUrl)
99
+ .sessionPaths(dataPath: dataDir, cachePath: cacheDir)
100
+ .slidingSyncVersionBuilder(versionBuilder: .native)
101
+ .autoEnableCrossSigning(autoEnableCrossSigning: true)
102
+ .build()
103
+
104
+ let session = Session(
105
+ accessToken: accessToken,
106
+ refreshToken: nil,
107
+ userId: userId,
108
+ deviceId: deviceId,
109
+ homeserverUrl: homeserverUrl,
110
+ oidcData: nil,
111
+ slidingSyncVersion: .native
112
+ )
113
+
114
+ try await newClient.restoreSession(session: session)
115
+ client = newClient
116
+
117
+ let info = MatrixSessionInfo(
118
+ accessToken: accessToken,
119
+ userId: userId,
120
+ deviceId: deviceId,
121
+ homeserverUrl: homeserverUrl
122
+ )
123
+ sessionStore.save(session: info)
124
+ return info.toDictionary()
125
+ }
126
+
127
+ func logout() async throws {
128
+ receiptSyncTask?.cancel()
129
+ receiptSyncTask = nil
130
+ try await syncService?.stop()
131
+ syncService = nil
132
+ syncStateHandle = nil
133
+ timelineListenerHandles.removeAll()
134
+ roomTimelines.removeAll()
135
+ subscribedRoomIds.removeAll()
136
+ try await client?.logout()
137
+ client = nil
138
+ sessionStore.clear()
139
+ }
140
+
141
+ func clearAllData() {
142
+ syncService = nil
143
+ syncStateHandle = nil
144
+ client = nil
145
+ timelineListenerHandles.removeAll()
146
+ roomTimelines.removeAll()
147
+ subscribedRoomIds.removeAll()
148
+ sessionStore.clear()
149
+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
150
+ .appendingPathComponent("matrix_sdk")
151
+ try? FileManager.default.removeItem(at: dir)
152
+ let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
153
+ .appendingPathComponent("matrix_sdk_cache")
154
+ try? FileManager.default.removeItem(at: cacheDir)
155
+ }
156
+
157
+ func getSession() -> [String: String]? {
158
+ return sessionStore.load()?.toDictionary()
159
+ }
160
+
161
+ // MARK: - Sync
162
+
163
+ func startSync(
164
+ onSyncState: @escaping (String) -> Void,
165
+ onMessage: @escaping ([String: Any]) -> Void,
166
+ onRoomUpdate: @escaping (String, [String: Any]) -> Void,
167
+ onReceipt: @escaping (String) -> Void
168
+ ) async throws {
169
+ guard let c = client else {
170
+ throw MatrixBridgeError.notLoggedIn
171
+ }
172
+
173
+ // Enable Rust SDK tracing to diagnose sync errors
174
+ let tracingConfig = TracingConfiguration(
175
+ filter: "warn,matrix_sdk=debug,matrix_sdk_ui=debug",
176
+ writeToStdoutOrSystem: true,
177
+ writeToFiles: nil
178
+ )
179
+ setupTracing(config: tracingConfig)
180
+
181
+ print("[CapMatrix] startSync: building sync service...")
182
+ let service = try await c.syncService().finish()
183
+ syncService = service
184
+ print("[CapMatrix] startSync: sync service built")
185
+
186
+ let observer = SyncStateObserverProxy(onUpdate: { [weak self] state in
187
+ let mapped = Self.mapSyncState(state)
188
+ print("[CapMatrix] SyncState changed: \(state) -> \(mapped)")
189
+ onSyncState(mapped)
190
+ if mapped == "SYNCING" {
191
+ Task { [weak self] in
192
+ await self?.subscribeToRoomTimelines(onMessage: onMessage, onRoomUpdate: onRoomUpdate)
193
+ }
194
+ }
195
+ })
196
+ syncStateObserver = observer
197
+ syncStateHandle = service.state(listener: observer)
198
+
199
+ // Start sync in a detached task (matches Android's service.start() which blocks)
200
+ Task.detached { [weak service] in
201
+ print("[CapMatrix] startSync: calling service.start()...")
202
+ await service?.start()
203
+ print("[CapMatrix] startSync: service.start() returned")
204
+ }
205
+
206
+ // Start a parallel v2 sync connection that only listens for m.receipt
207
+ // ephemeral events. Tuwunel's sliding sync doesn't deliver other users'
208
+ // read receipts, so this provides live receipt updates.
209
+ startReceiptSync(onReceipt: onReceipt)
210
+ }
211
+
212
+ /// Runs a lightweight v2 sync loop that only subscribes to m.receipt
213
+ /// ephemeral events. The Rust SDK receives receipts via sliding sync
214
+ /// but doesn't expose them through readReceipts() on timeline items,
215
+ /// so this parallel connection provides live receipt updates.
216
+ private func startReceiptSync(onReceipt: @escaping (String) -> Void) {
217
+ guard let session = sessionStore.load() else { return }
218
+
219
+ receiptSyncTask?.cancel()
220
+ receiptSyncTask = Task.detached {
221
+ let baseUrl = session.homeserverUrl.hasSuffix("/")
222
+ ? String(session.homeserverUrl.dropLast())
223
+ : session.homeserverUrl
224
+ let token = session.accessToken
225
+ let userId = session.userId
226
+
227
+ print("[CapMatrix] receiptSync: starting, uploading filter...")
228
+
229
+ // Upload filter first — some servers reject inline JSON filters
230
+ let filterId = await Self.uploadSyncFilter(
231
+ baseUrl: baseUrl, accessToken: token, userId: userId
232
+ )
233
+ print("[CapMatrix] receiptSync: filterId=\(filterId ?? "nil")")
234
+
235
+ var since: String? = nil
236
+
237
+ // Try both v3 and r0 API versions
238
+ let apiPaths = ["/_matrix/client/v3/sync", "/_matrix/client/r0/sync"]
239
+ var workingPath: String? = nil
240
+
241
+ for apiPath in apiPaths {
242
+ if Task.isCancelled { return }
243
+
244
+ let testUrl = Self.buildSyncUrl(
245
+ baseUrl: baseUrl, apiPath: apiPath,
246
+ filterId: filterId, since: nil, timeout: 0
247
+ )
248
+ guard let url = testUrl else { continue }
249
+
250
+ var request = URLRequest(url: url)
251
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
252
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
253
+ request.timeoutInterval = 30
254
+
255
+ do {
256
+ let (data, response) = try await URLSession.shared.data(for: request)
257
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
258
+
259
+ if statusCode == 200 {
260
+ workingPath = apiPath
261
+ // Extract since token from first response
262
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
263
+ let nextBatch = json["next_batch"] as? String {
264
+ since = nextBatch
265
+ }
266
+ // Process any receipts in the initial response
267
+ Self.processReceiptResponse(data: data, onReceipt: onReceipt)
268
+ print("[CapMatrix] receiptSync: \(apiPath) works, since=\(since ?? "nil")")
269
+ break
270
+ } else {
271
+ let body = String(data: data, encoding: .utf8) ?? "(no body)"
272
+ print("[CapMatrix] receiptSync: \(apiPath) returned HTTP \(statusCode): \(body.prefix(500))")
273
+ }
274
+ } catch {
275
+ print("[CapMatrix] receiptSync: \(apiPath) failed: \(error)")
276
+ }
277
+ }
278
+
279
+ guard let apiPath = workingPath else {
280
+ print("[CapMatrix] receiptSync: no working sync endpoint found, giving up")
281
+ return
282
+ }
283
+
284
+ print("[CapMatrix] receiptSync: entering long-poll loop on \(apiPath)")
285
+
286
+ while !Task.isCancelled {
287
+ let syncUrl = Self.buildSyncUrl(
288
+ baseUrl: baseUrl, apiPath: apiPath,
289
+ filterId: filterId, since: since, timeout: 30000
290
+ )
291
+ guard let url = syncUrl else {
292
+ print("[CapMatrix] receiptSync: invalid URL")
293
+ return
294
+ }
295
+
296
+ var request = URLRequest(url: url)
297
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
298
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
299
+ request.timeoutInterval = 60
300
+
301
+ do {
302
+ let (data, response) = try await URLSession.shared.data(for: request)
303
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
304
+
305
+ guard statusCode == 200 else {
306
+ let body = String(data: data, encoding: .utf8) ?? ""
307
+ print("[CapMatrix] receiptSync: HTTP \(statusCode): \(body.prefix(300))")
308
+ // Back off on error, but not too long
309
+ try await Task.sleep(nanoseconds: 5_000_000_000)
310
+ continue
311
+ }
312
+
313
+ if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
314
+ let nextBatch = json["next_batch"] as? String {
315
+ since = nextBatch
316
+ }
317
+
318
+ Self.processReceiptResponse(data: data, onReceipt: onReceipt)
319
+ } catch is CancellationError {
320
+ break
321
+ } catch {
322
+ print("[CapMatrix] receiptSync: error: \(error)")
323
+ if !Task.isCancelled {
324
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
325
+ }
326
+ }
327
+ }
328
+ print("[CapMatrix] receiptSync: loop ended")
329
+ }
330
+ }
331
+
332
+ /// Upload a sync filter that only subscribes to m.receipt ephemeral events.
333
+ /// Returns the filter ID, or nil if upload fails.
334
+ private static func uploadSyncFilter(
335
+ baseUrl: String, accessToken: String, userId: String
336
+ ) async -> String? {
337
+ let encodedUserId = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId
338
+ let urlStr = "\(baseUrl)/_matrix/client/v3/user/\(encodedUserId)/filter"
339
+ guard let url = URL(string: urlStr) else { return nil }
340
+
341
+ let filterJson: [String: Any] = [
342
+ "room": [
343
+ "timeline": ["limit": 0],
344
+ "state": ["types": [] as [String]],
345
+ "ephemeral": ["types": ["m.receipt"]]
346
+ ],
347
+ "presence": ["types": [] as [String]]
348
+ ]
349
+ guard let body = try? JSONSerialization.data(withJSONObject: filterJson) else { return nil }
350
+
351
+ var request = URLRequest(url: url)
352
+ request.httpMethod = "POST"
353
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
354
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
355
+ request.httpBody = body
356
+ request.timeoutInterval = 15
357
+
358
+ do {
359
+ let (data, response) = try await URLSession.shared.data(for: request)
360
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
361
+ if statusCode == 200,
362
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
363
+ let filterId = json["filter_id"] as? String {
364
+ return filterId
365
+ }
366
+ let respBody = String(data: data, encoding: .utf8) ?? ""
367
+ print("[CapMatrix] receiptSync: filter upload HTTP \(statusCode): \(respBody.prefix(300))")
368
+ return nil
369
+ } catch {
370
+ print("[CapMatrix] receiptSync: filter upload failed: \(error)")
371
+ return nil
372
+ }
373
+ }
374
+
375
+ /// Build a sync URL using URLComponents for correct encoding.
376
+ private static func buildSyncUrl(
377
+ baseUrl: String, apiPath: String,
378
+ filterId: String?, since: String?, timeout: Int
379
+ ) -> URL? {
380
+ var components = URLComponents(string: "\(baseUrl)\(apiPath)")
381
+ var queryItems: [URLQueryItem] = [
382
+ URLQueryItem(name: "timeout", value: "\(timeout)")
383
+ ]
384
+ if let fid = filterId {
385
+ queryItems.append(URLQueryItem(name: "filter", value: fid))
386
+ } else {
387
+ // Inline filter as fallback
388
+ let inlineFilter = #"{"room":{"timeline":{"limit":0},"state":{"types":[]},"ephemeral":{"types":["m.receipt"]}},"presence":{"types":[]}}"#
389
+ queryItems.append(URLQueryItem(name: "filter", value: inlineFilter))
390
+ }
391
+ if let s = since {
392
+ queryItems.append(URLQueryItem(name: "since", value: s))
393
+ }
394
+ components?.queryItems = queryItems
395
+ return components?.url
396
+ }
397
+
398
+ /// Parse receipt events from a v2 sync response and fire callbacks.
399
+ private static func processReceiptResponse(
400
+ data: Data, onReceipt: @escaping (String) -> Void
401
+ ) {
402
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
403
+ let rooms = (json["rooms"] as? [String: Any])?["join"] as? [String: Any] else {
404
+ return
405
+ }
406
+ for (roomId, roomData) in rooms {
407
+ guard let roomDict = roomData as? [String: Any],
408
+ let ephemeral = roomDict["ephemeral"] as? [String: Any],
409
+ let events = ephemeral["events"] as? [[String: Any]] else {
410
+ continue
411
+ }
412
+ for event in events {
413
+ guard (event["type"] as? String) == "m.receipt" else { continue }
414
+ print("[CapMatrix] receiptSync: receipt in \(roomId)")
415
+ onReceipt(roomId)
416
+ }
417
+ }
418
+ }
419
+
420
+ private func subscribeToRoomTimelines(
421
+ onMessage: @escaping ([String: Any]) -> Void,
422
+ onRoomUpdate: @escaping (String, [String: Any]) -> Void
423
+ ) async {
424
+ guard let c = client else { return }
425
+ let rooms = c.rooms()
426
+
427
+ var roomsToSubscribe: [(Room, String)] = []
428
+ subscriptionLock.lock()
429
+ let alreadyCount = subscribedRoomIds.count
430
+ for room in rooms {
431
+ let roomId = room.id()
432
+ if subscribedRoomIds.contains(roomId) { continue }
433
+ subscribedRoomIds.insert(roomId)
434
+ roomsToSubscribe.append((room, roomId))
435
+ }
436
+ subscriptionLock.unlock()
437
+
438
+ print("[CapMatrix] subscribeToRoomTimelines: \(alreadyCount) already subscribed, \(roomsToSubscribe.count) new")
439
+ if roomsToSubscribe.isEmpty { return }
440
+
441
+ for (room, roomId) in roomsToSubscribe {
442
+ do {
443
+ let timeline = try await getOrCreateTimeline(room: room)
444
+ let listener = LiveTimelineListener(roomId: roomId, onMessage: onMessage, onRoomUpdate: onRoomUpdate)
445
+ let handle = await timeline.addListener(listener: listener)
446
+ subscriptionLock.lock()
447
+ timelineListenerHandles.append(handle)
448
+ subscriptionLock.unlock()
449
+ print("[CapMatrix] room \(roomId): listener added ✓")
450
+ } catch {
451
+ print("[CapMatrix] room \(roomId): FAILED: \(error)")
452
+ }
453
+ }
454
+ }
455
+
456
+ func stopSync() async throws {
457
+ try await syncService?.stop()
458
+ syncStateHandle = nil
459
+ subscribedRoomIds.removeAll()
460
+ timelineListenerHandles.removeAll()
461
+ roomTimelines.removeAll()
462
+ receiptSyncTask?.cancel()
463
+ receiptSyncTask = nil
464
+ }
465
+
466
+ func getSyncState() -> String {
467
+ return "SYNCING"
468
+ }
469
+
470
+ // MARK: - Room Lookup
471
+
472
+ private func requireRoom(roomId: String) throws -> Room {
473
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
474
+ guard let room = c.rooms().first(where: { $0.id() == roomId }) else {
475
+ throw MatrixBridgeError.roomNotFound(roomId)
476
+ }
477
+ return room
478
+ }
479
+
480
+ private func getOrCreateTimeline(room: Room) async throws -> Timeline {
481
+ let roomId = room.id()
482
+ if let existing = roomTimelines[roomId] {
483
+ return existing
484
+ }
485
+ let timeline = try await room.timeline()
486
+ roomTimelines[roomId] = timeline
487
+ return timeline
488
+ }
489
+
490
+ // MARK: - Rooms
491
+
492
+ func getRooms() async throws -> [[String: Any]] {
493
+ guard let c = client else {
494
+ throw MatrixBridgeError.notLoggedIn
495
+ }
496
+ var result: [[String: Any]] = []
497
+ for room in c.rooms() {
498
+ result.append(try await Self.serializeRoom(room))
499
+ }
500
+ return result
501
+ }
502
+
503
+ func getRoomMembers(roomId: String) async throws -> [[String: Any]] {
504
+ let room = try requireRoom(roomId: roomId)
505
+ let iterator = try await room.members()
506
+ var result: [[String: Any]] = []
507
+ let total = iterator.len()
508
+ while let chunk = iterator.nextChunk(chunkSize: min(total, 100)) {
509
+ for member in chunk {
510
+ result.append([
511
+ "userId": member.userId,
512
+ "displayName": member.displayName as Any,
513
+ "membership": String(describing: member.membership).lowercased(),
514
+ "avatarUrl": member.avatarUrl as Any,
515
+ ])
516
+ }
517
+ }
518
+ return result
519
+ }
520
+
521
+ func joinRoom(roomIdOrAlias: String) async throws -> String {
522
+ guard let c = client else {
523
+ throw MatrixBridgeError.notLoggedIn
524
+ }
525
+ let room = try await c.joinRoomByIdOrAlias(roomIdOrAlias: roomIdOrAlias, serverNames: [])
526
+ return room.id()
527
+ }
528
+
529
+ func leaveRoom(roomId: String) async throws {
530
+ let room = try requireRoom(roomId: roomId)
531
+ try await room.leave()
532
+ }
533
+
534
+ func forgetRoom(roomId: String) async throws {
535
+ // The Rust SDK doesn't have a dedicated forget method on the Room type.
536
+ // After leaving, the room is removed from the room list on next sync.
537
+ // This is a no-op placeholder for API compatibility.
538
+ }
539
+
540
+ func createRoom(name: String?, topic: String?, isEncrypted: Bool, isDirect: Bool = false, invite: [String]?, preset: String? = nil) async throws -> String {
541
+ guard let c = client else {
542
+ throw MatrixBridgeError.notLoggedIn
543
+ }
544
+ let roomPreset: RoomPreset
545
+ switch preset {
546
+ case "trusted_private_chat":
547
+ roomPreset = .trustedPrivateChat
548
+ case "public_chat":
549
+ roomPreset = .publicChat
550
+ default:
551
+ roomPreset = .privateChat
552
+ }
553
+ let params = CreateRoomParameters(
554
+ name: name,
555
+ topic: topic,
556
+ isEncrypted: isEncrypted,
557
+ isDirect: isDirect,
558
+ visibility: .private,
559
+ preset: roomPreset,
560
+ invite: invite
561
+ )
562
+ return try await c.createRoom(request: params)
563
+ }
564
+
565
+ // MARK: - Messaging
566
+
567
+ func sendMessage(roomId: String, body: String, msgtype: String) async throws -> String {
568
+ let room = try requireRoom(roomId: roomId)
569
+ let timeline = try await getOrCreateTimeline(room: room)
570
+ let content = messageEventContentFromMarkdown(md: body)
571
+ try await timeline.send(msg: content)
572
+ return ""
573
+ }
574
+
575
+ func editMessage(roomId: String, eventId: String, newBody: String) async throws -> String {
576
+ let room = try requireRoom(roomId: roomId)
577
+ let content = messageEventContentFromMarkdown(md: newBody)
578
+ try await room.edit(eventId: eventId, newContent: content)
579
+ return ""
580
+ }
581
+
582
+ func sendReply(roomId: String, body: String, replyToEventId: String, msgtype: String) async throws -> String {
583
+ let room = try requireRoom(roomId: roomId)
584
+ let timeline = try await getOrCreateTimeline(room: room)
585
+ let content = messageEventContentFromMarkdown(md: body)
586
+ try await timeline.sendReply(msg: content, eventId: replyToEventId)
587
+ return ""
588
+ }
589
+
590
+ func getRoomMessages(roomId: String, limit: Int, from: String?) async throws -> [String: Any] {
591
+ let room = try requireRoom(roomId: roomId)
592
+ let timeline = try await getOrCreateTimeline(room: room)
593
+
594
+ let collector = TimelineItemCollector(roomId: roomId)
595
+ let handle = await timeline.addListener(listener: collector)
596
+
597
+ // Wait for the initial Reset snapshot before paginating
598
+ let gotInitial = await collector.waitForUpdate(timeoutNanos: 5_000_000_000)
599
+ print("[CapMatrix] getRoomMessages: initial snapshot: \(collector.events.count) items, gotInitial=\(gotInitial)")
600
+
601
+ // Only paginate if we don't have enough items yet
602
+ if collector.events.count < limit {
603
+ let hitStart = try await timeline.paginateBackwards(numEvents: UInt16(limit))
604
+ print("[CapMatrix] getRoomMessages: paginated, hitStart=\(hitStart)")
605
+
606
+ // If there were new events, wait for the diffs to arrive via the listener
607
+ if !hitStart {
608
+ _ = await collector.waitForUpdate(timeoutNanos: 5_000_000_000)
609
+ }
610
+ print("[CapMatrix] getRoomMessages: after pagination: \(collector.events.count) items")
611
+ }
612
+
613
+ handle.cancel()
614
+
615
+ var events = Array(collector.events.suffix(limit))
616
+
617
+ // Apply receipt watermark: if any own event has readBy data,
618
+ // all earlier own events in the timeline are also read.
619
+ // The SDK only attaches receipts to the specific event they target,
620
+ // but in Matrix a read receipt implies all prior events are read too.
621
+ // Only events BEFORE the watermark are marked — events after it are unread.
622
+ let myUserId = client.flatMap({ try? $0.userId() })
623
+ var watermarkReadBy: [String]? = nil
624
+ var watermarkIndex = -1
625
+ // Walk backwards (newest first) to find the newest own event with a receipt
626
+ for i in stride(from: events.count - 1, through: 0, by: -1) {
627
+ let evt = events[i]
628
+ let sender = evt["senderId"] as? String
629
+ if sender == myUserId {
630
+ if let rb = evt["readBy"] as? [String], !rb.isEmpty {
631
+ watermarkReadBy = rb
632
+ watermarkIndex = i
633
+ break
634
+ }
635
+ }
636
+ }
637
+ // Apply watermark only to own events BEFORE the watermark (older)
638
+ if let watermark = watermarkReadBy, watermarkIndex >= 0 {
639
+ for i in 0..<watermarkIndex {
640
+ let sender = events[i]["senderId"] as? String
641
+ if sender == myUserId {
642
+ let existing = events[i]["readBy"] as? [String]
643
+ if existing == nil || existing!.isEmpty {
644
+ events[i]["status"] = "read"
645
+ events[i]["readBy"] = watermark
646
+ }
647
+ }
648
+ }
649
+ }
650
+
651
+ return [
652
+ "events": events,
653
+ "nextBatch": nil as String? as Any
654
+ ]
655
+ }
656
+
657
+ func markRoomAsRead(roomId: String, eventId: String) async throws {
658
+ let room = try requireRoom(roomId: roomId)
659
+ let timeline = try await getOrCreateTimeline(room: room)
660
+ print("[CapMatrix] markRoomAsRead: roomId=\(roomId) eventId=\(eventId)")
661
+ try await timeline.markAsRead(receiptType: ReceiptType.read)
662
+ print("[CapMatrix] markRoomAsRead: done")
663
+ }
664
+
665
+ /// Re-fetch timeline items by event ID and return them with updated receipt status.
666
+ /// Uses the receipt watermark from the SDK's timeline data.
667
+ func refreshEventStatuses(roomId: String, eventIds: [String]) async throws -> [[String: Any]] {
668
+ let room = try requireRoom(roomId: roomId)
669
+ let timeline = try await getOrCreateTimeline(room: room)
670
+ let myUserId = client.flatMap({ try? $0.userId() })
671
+
672
+ // Collect all events
673
+ var items: [(id: String, item: EventTimelineItem, serialized: [String: Any])] = []
674
+
675
+ for eid in eventIds {
676
+ do {
677
+ let eventItem = try await timeline.getEventTimelineItemByEventId(eventId: eid)
678
+ if let serialized = serializeEventTimelineItem(eventItem, roomId: roomId) {
679
+ items.append((id: eid, item: eventItem, serialized: serialized))
680
+ }
681
+ } catch {
682
+ // skip
683
+ }
684
+ }
685
+
686
+ // Find the newest own event with a read receipt (watermark)
687
+ var watermarkReadBy: [String]? = nil
688
+ var watermarkIndex = -1
689
+ for i in stride(from: items.count - 1, through: 0, by: -1) {
690
+ if items[i].serialized["senderId"] as? String == myUserId,
691
+ let rb = items[i].serialized["readBy"] as? [String], !rb.isEmpty {
692
+ watermarkReadBy = rb
693
+ watermarkIndex = i
694
+ break
695
+ }
696
+ }
697
+
698
+ // Apply watermark only to own events BEFORE the watermark (older)
699
+ var results: [[String: Any]] = []
700
+ if let watermark = watermarkReadBy, watermarkIndex >= 0 {
701
+ for i in 0..<items.count {
702
+ var serialized = items[i].serialized
703
+ if i < watermarkIndex,
704
+ serialized["senderId"] as? String == myUserId {
705
+ let existing = serialized["readBy"] as? [String]
706
+ if existing == nil || existing!.isEmpty {
707
+ serialized["status"] = "read"
708
+ serialized["readBy"] = watermark
709
+ }
710
+ }
711
+ results.append(serialized)
712
+ }
713
+ } else {
714
+ results = items.map { $0.serialized }
715
+ }
716
+ return results
717
+ }
718
+
719
+ // MARK: - Redactions & Reactions
720
+
721
+ func redactEvent(roomId: String, eventId: String, reason: String?) async throws {
722
+ let room = try requireRoom(roomId: roomId)
723
+ try await room.redact(eventId: eventId, reason: reason)
724
+ }
725
+
726
+ func sendReaction(roomId: String, eventId: String, key: String) async throws {
727
+ let room = try requireRoom(roomId: roomId)
728
+ let timeline = try await getOrCreateTimeline(room: room)
729
+
730
+ // toggleReaction needs the timeline item's uniqueId, not the eventId
731
+ // addListener immediately fires a Reset diff with current items
732
+ let collector = TimelineItemCollector(roomId: roomId)
733
+ let handle = await timeline.addListener(listener: collector)
734
+ await collector.waitForUpdate()
735
+ handle.cancel()
736
+
737
+ guard let uniqueId = collector.uniqueIdForEvent(eventId) else {
738
+ throw MatrixBridgeError.notSupported("Could not find timeline item for event \(eventId)")
739
+ }
740
+ try await timeline.toggleReaction(uniqueId: uniqueId, key: key)
741
+ }
742
+
743
+ // MARK: - User Discovery
744
+
745
+ func searchUsers(searchTerm: String, limit: Int) async throws -> [String: Any] {
746
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
747
+ let result = try await c.searchUsers(searchTerm: searchTerm, limit: UInt64(limit))
748
+ let users = result.results.map { u -> [String: Any?] in
749
+ [
750
+ "userId": u.userId,
751
+ "displayName": u.displayName,
752
+ "avatarUrl": u.avatarUrl,
753
+ ]
754
+ }
755
+ return [
756
+ "results": users,
757
+ "limited": result.limited,
758
+ ]
759
+ }
760
+
761
+ // MARK: - Room Management
762
+
763
+ func setRoomName(roomId: String, name: String) async throws {
764
+ let room = try requireRoom(roomId: roomId)
765
+ try await room.setName(name: name)
766
+ }
767
+
768
+ func setRoomTopic(roomId: String, topic: String) async throws {
769
+ let room = try requireRoom(roomId: roomId)
770
+ try await room.setTopic(topic: topic)
771
+ }
772
+
773
+ func inviteUser(roomId: String, userId: String) async throws {
774
+ let room = try requireRoom(roomId: roomId)
775
+ try await room.inviteUserById(userId: userId)
776
+ }
777
+
778
+ func kickUser(roomId: String, userId: String, reason: String?) async throws {
779
+ let room = try requireRoom(roomId: roomId)
780
+ try await room.kickUser(userId: userId, reason: reason)
781
+ }
782
+
783
+ func banUser(roomId: String, userId: String, reason: String?) async throws {
784
+ let room = try requireRoom(roomId: roomId)
785
+ try await room.banUser(userId: userId, reason: reason)
786
+ }
787
+
788
+ func unbanUser(roomId: String, userId: String) async throws {
789
+ let room = try requireRoom(roomId: roomId)
790
+ try await room.unbanUser(userId: userId, reason: nil)
791
+ }
792
+
793
+ // MARK: - Media URL
794
+
795
+ func getMediaUrl(mxcUrl: String) throws -> String {
796
+ guard let session = sessionStore.load() else {
797
+ throw MatrixBridgeError.notLoggedIn
798
+ }
799
+ let baseUrl = session.homeserverUrl.hasSuffix("/")
800
+ ? String(session.homeserverUrl.dropLast())
801
+ : session.homeserverUrl
802
+ let mxcPath = mxcUrl.replacingOccurrences(of: "mxc://", with: "")
803
+ return "\(baseUrl)/_matrix/client/v1/media/download/\(mxcPath)?access_token=\(session.accessToken)"
804
+ }
805
+
806
+ func getThumbnailUrl(mxcUrl: String, width: Int, height: Int, method: String) throws -> String {
807
+ guard let session = sessionStore.load() else {
808
+ throw MatrixBridgeError.notLoggedIn
809
+ }
810
+ let baseUrl = session.homeserverUrl.hasSuffix("/")
811
+ ? String(session.homeserverUrl.dropLast())
812
+ : session.homeserverUrl
813
+ let mxcPath = mxcUrl.replacingOccurrences(of: "mxc://", with: "")
814
+ return "\(baseUrl)/_matrix/client/v1/media/thumbnail/\(mxcPath)?width=\(width)&height=\(height)&method=\(method)&access_token=\(session.accessToken)"
815
+ }
816
+
817
+ // MARK: - Content Upload
818
+
819
+ func uploadContent(fileUri: String, fileName: String, mimeType: String) async throws -> String {
820
+ guard let session = sessionStore.load() else {
821
+ throw MatrixBridgeError.notLoggedIn
822
+ }
823
+ let baseUrl = session.homeserverUrl.hasSuffix("/")
824
+ ? String(session.homeserverUrl.dropLast())
825
+ : session.homeserverUrl
826
+ let encodedFileName = fileName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? fileName
827
+ let urlString = "\(baseUrl)/_matrix/media/v3/upload?filename=\(encodedFileName)"
828
+ guard let url = URL(string: urlString) else {
829
+ throw MatrixBridgeError.notSupported("Invalid upload URL")
830
+ }
831
+
832
+ // Read file data from URI
833
+ let fileData: Data
834
+ if fileUri.hasPrefix("file://"), let fileUrl = URL(string: fileUri) {
835
+ fileData = try Data(contentsOf: fileUrl)
836
+ } else {
837
+ let fileUrl = URL(fileURLWithPath: fileUri)
838
+ fileData = try Data(contentsOf: fileUrl)
839
+ }
840
+
841
+ var request = URLRequest(url: url)
842
+ request.httpMethod = "POST"
843
+ request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
844
+ request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
845
+ request.httpBody = fileData
846
+
847
+ let (data, response) = try await URLSession.shared.data(for: request)
848
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
849
+ guard statusCode >= 200 && statusCode < 300 else {
850
+ throw MatrixBridgeError.notSupported("Upload failed with status \(statusCode)")
851
+ }
852
+
853
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
854
+ let contentUri = json["content_uri"] as? String else {
855
+ throw MatrixBridgeError.notSupported("Invalid upload response")
856
+ }
857
+ return contentUri
858
+ }
859
+
860
+ // MARK: - Devices
861
+
862
+ func getDevices() async throws -> [[String: Any]] {
863
+ guard let session = sessionStore.load() else {
864
+ throw MatrixBridgeError.notLoggedIn
865
+ }
866
+ let baseUrl = session.homeserverUrl.hasSuffix("/")
867
+ ? String(session.homeserverUrl.dropLast())
868
+ : session.homeserverUrl
869
+ let urlString = "\(baseUrl)/_matrix/client/v3/devices"
870
+ guard let url = URL(string: urlString) else {
871
+ throw MatrixBridgeError.notSupported("Invalid devices URL")
872
+ }
873
+
874
+ var request = URLRequest(url: url)
875
+ request.httpMethod = "GET"
876
+ request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
877
+
878
+ let (data, response) = try await URLSession.shared.data(for: request)
879
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
880
+ guard statusCode >= 200 && statusCode < 300 else {
881
+ throw MatrixBridgeError.notSupported("getDevices failed with status \(statusCode)")
882
+ }
883
+
884
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
885
+ let devicesArray = json["devices"] as? [[String: Any]] else {
886
+ throw MatrixBridgeError.notSupported("Invalid devices response")
887
+ }
888
+
889
+ return devicesArray.map { device in
890
+ [
891
+ "deviceId": device["device_id"] as? String ?? "",
892
+ "displayName": device["display_name"] as Any,
893
+ "lastSeenTs": device["last_seen_ts"] as Any,
894
+ "lastSeenIp": device["last_seen_ip"] as Any,
895
+ ]
896
+ }
897
+ }
898
+
899
+ func deleteDevice(deviceId: String) async throws {
900
+ guard let session = sessionStore.load() else {
901
+ throw MatrixBridgeError.notLoggedIn
902
+ }
903
+ let baseUrl = session.homeserverUrl.hasSuffix("/")
904
+ ? String(session.homeserverUrl.dropLast())
905
+ : session.homeserverUrl
906
+ let urlString = "\(baseUrl)/_matrix/client/v3/devices/\(deviceId)"
907
+ guard let url = URL(string: urlString) else {
908
+ throw MatrixBridgeError.notSupported("Invalid device URL")
909
+ }
910
+
911
+ var request = URLRequest(url: url)
912
+ request.httpMethod = "DELETE"
913
+ request.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
914
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
915
+ request.httpBody = "{}".data(using: .utf8)
916
+
917
+ let (_, response) = try await URLSession.shared.data(for: request)
918
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
919
+ // 401 means UIA is required - for now we just throw
920
+ guard statusCode >= 200 && statusCode < 300 else {
921
+ throw MatrixBridgeError.notSupported("deleteDevice failed with status \(statusCode)")
922
+ }
923
+ }
924
+
925
+ // MARK: - Typing
926
+
927
+ func sendTyping(roomId: String, isTyping: Bool) async throws {
928
+ let room = try requireRoom(roomId: roomId)
929
+ try await room.typingNotice(isTyping: isTyping)
930
+ }
931
+
932
+ // MARK: - Encryption
933
+
934
+ func initializeCrypto() async throws {
935
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
936
+ await c.encryption().waitForE2eeInitializationTasks()
937
+ }
938
+
939
+ func getEncryptionStatus() async throws -> [String: Any] {
940
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
941
+ let enc = c.encryption()
942
+ let vState = enc.verificationState()
943
+ let backupState = enc.backupState()
944
+ let recoveryState = enc.recoveryState()
945
+
946
+ let isVerified = vState == .verified
947
+ let isBackupEnabled = backupState == .enabled || backupState == .creating || backupState == .resuming
948
+
949
+ return [
950
+ "isCrossSigningReady": isVerified,
951
+ "crossSigningStatus": [
952
+ "hasMaster": isVerified,
953
+ "hasSelfSigning": isVerified,
954
+ "hasUserSigning": isVerified,
955
+ "isReady": isVerified,
956
+ ],
957
+ "isKeyBackupEnabled": isBackupEnabled,
958
+ "isSecretStorageReady": recoveryState == .enabled,
959
+ ]
960
+ }
961
+
962
+ func bootstrapCrossSigning() async throws {
963
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
964
+ await c.encryption().waitForE2eeInitializationTasks()
965
+ }
966
+
967
+ func setupKeyBackup() async throws -> [String: Any] {
968
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
969
+ try await c.encryption().enableBackups()
970
+ return ["exists": true, "enabled": true]
971
+ }
972
+
973
+ func getKeyBackupStatus() async throws -> [String: Any] {
974
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
975
+ let existsOnServer = try await c.encryption().backupExistsOnServer()
976
+ let state = c.encryption().backupState()
977
+ let enabled = state == .enabled || state == .creating || state == .resuming
978
+ return ["exists": existsOnServer, "enabled": enabled]
979
+ }
980
+
981
+ func restoreKeyBackup(recoveryKey: String?) async throws -> [String: Any] {
982
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
983
+ if let key = recoveryKey {
984
+ try await c.encryption().recover(recoveryKey: key)
985
+ }
986
+ return ["importedKeys": -1]
987
+ }
988
+
989
+ func setupRecovery(passphrase: String?) async throws -> [String: Any] {
990
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
991
+ let listener = NoopEnableRecoveryProgressListener()
992
+ let key = try await c.encryption().enableRecovery(
993
+ waitForBackupsToUpload: false,
994
+ progressListener: listener
995
+ )
996
+ return ["recoveryKey": key]
997
+ }
998
+
999
+ func isRecoveryEnabled() async throws -> Bool {
1000
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
1001
+ return c.encryption().recoveryState() == .enabled
1002
+ }
1003
+
1004
+ func recoverAndSetup(recoveryKey: String) async throws {
1005
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
1006
+ try await c.encryption().recover(recoveryKey: recoveryKey)
1007
+ }
1008
+
1009
+ func resetRecoveryKey(passphrase: String?) async throws -> [String: Any] {
1010
+ guard let c = client else { throw MatrixBridgeError.notLoggedIn }
1011
+ let key = try await c.encryption().resetRecoveryKey()
1012
+ return ["recoveryKey": key]
1013
+ }
1014
+
1015
+ func exportRoomKeys(passphrase: String) async throws -> String {
1016
+ throw MatrixBridgeError.notSupported("exportRoomKeys")
1017
+ }
1018
+
1019
+ func importRoomKeys(data: String, passphrase: String) async throws -> Int {
1020
+ throw MatrixBridgeError.notSupported("importRoomKeys")
1021
+ }
1022
+
1023
+ // MARK: - Helpers
1024
+
1025
+ private static func dataDirectory() -> String {
1026
+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
1027
+ .appendingPathComponent("matrix_sdk")
1028
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1029
+ return dir.path
1030
+ }
1031
+
1032
+ /// Separate cache directory for sliding sync state.
1033
+ /// Cleared on each login/restore to force a fresh sync, working around
1034
+ /// Tuwunel returning stale events when resuming from a cached sync position.
1035
+ private static func cacheDirectory() -> String {
1036
+ let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
1037
+ .appendingPathComponent("matrix_sdk_cache")
1038
+ // Clear stale sync cache on each startup
1039
+ try? FileManager.default.removeItem(at: dir)
1040
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1041
+ return dir.path
1042
+ }
1043
+
1044
+ private static func serializeRoom(_ room: Room) async throws -> [String: Any] {
1045
+ let info = try await room.roomInfo()
1046
+ let encrypted = (try? room.isEncrypted()) ?? false
1047
+ let membership: String = {
1048
+ switch room.membership() {
1049
+ case .joined: return "join"
1050
+ case .invited: return "invite"
1051
+ case .left: return "leave"
1052
+ @unknown default: return "join"
1053
+ }
1054
+ }()
1055
+ let isDirect = info.isDirect
1056
+ let avatarUrl: String? = nil // Rust SDK doesn't expose avatar URL via RoomInfo yet
1057
+
1058
+ return [
1059
+ "roomId": room.id(),
1060
+ "name": info.displayName ?? "",
1061
+ "topic": info.topic as Any,
1062
+ "memberCount": info.joinedMembersCount ?? 0,
1063
+ "isEncrypted": encrypted,
1064
+ "unreadCount": info.numUnreadMessages ?? 0,
1065
+ "lastEventTs": nil as Int? as Any,
1066
+ "membership": membership,
1067
+ "avatarUrl": avatarUrl as Any,
1068
+ "isDirect": isDirect,
1069
+ ]
1070
+ }
1071
+
1072
+ private static func mapSyncState(_ state: SyncServiceState) -> String {
1073
+ switch state {
1074
+ case .idle:
1075
+ return "STOPPED"
1076
+ case .running:
1077
+ return "SYNCING"
1078
+ case .terminated:
1079
+ return "STOPPED"
1080
+ case .error:
1081
+ return "ERROR"
1082
+ @unknown default:
1083
+ return "ERROR"
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ // MARK: - Timeline Serialization Helpers
1089
+
1090
+ private func extractMediaUrl(source: MediaSource, into contentDict: inout [String: Any]) {
1091
+ let url = source.url()
1092
+ if !url.isEmpty {
1093
+ contentDict["url"] = url
1094
+ }
1095
+ // Fallback: for encrypted media, try toJson to extract the mxc URL
1096
+ if contentDict["url"] == nil || (contentDict["url"] as? String)?.isEmpty == true {
1097
+ let json = source.toJson()
1098
+ if let jsonData = json.data(using: .utf8),
1099
+ let parsed = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
1100
+ let mxcUrl = parsed["url"] as? String, !mxcUrl.isEmpty {
1101
+ contentDict["url"] = mxcUrl
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ private func serializeTimelineItem(_ item: TimelineItem, roomId: String) -> [String: Any]? {
1107
+ guard let eventItem = item.asEvent() else { return nil }
1108
+ return serializeEventTimelineItem(eventItem, roomId: roomId)
1109
+ }
1110
+
1111
+ private func serializeEventTimelineItem(_ eventItem: EventTimelineItem, roomId: String) -> [String: Any]? {
1112
+ let eventId: String
1113
+ if let eid = eventItem.eventId() {
1114
+ eventId = eid
1115
+ } else if let tid = eventItem.transactionId() {
1116
+ eventId = tid
1117
+ } else {
1118
+ return nil
1119
+ }
1120
+
1121
+ var contentDict: [String: Any] = [:]
1122
+ var eventType = "m.room.message"
1123
+
1124
+ let content = eventItem.content()
1125
+ switch content.kind() {
1126
+ case .message:
1127
+ if let msg = content.asMessage() {
1128
+ contentDict["body"] = msg.body()
1129
+ switch msg.msgtype() {
1130
+ case .text:
1131
+ contentDict["msgtype"] = "m.text"
1132
+ case .image(let imgContent):
1133
+ contentDict["msgtype"] = "m.image"
1134
+ extractMediaUrl(source: imgContent.source, into: &contentDict)
1135
+ case .file(let fileContent):
1136
+ contentDict["msgtype"] = "m.file"
1137
+ contentDict["filename"] = fileContent.filename
1138
+ extractMediaUrl(source: fileContent.source, into: &contentDict)
1139
+ case .audio(let audioContent):
1140
+ contentDict["msgtype"] = "m.audio"
1141
+ contentDict["filename"] = audioContent.filename
1142
+ extractMediaUrl(source: audioContent.source, into: &contentDict)
1143
+ case .video(let videoContent):
1144
+ contentDict["msgtype"] = "m.video"
1145
+ contentDict["filename"] = videoContent.filename
1146
+ extractMediaUrl(source: videoContent.source, into: &contentDict)
1147
+ case .emote:
1148
+ contentDict["msgtype"] = "m.emote"
1149
+ case .notice:
1150
+ contentDict["msgtype"] = "m.notice"
1151
+ default:
1152
+ contentDict["msgtype"] = "m.text"
1153
+ }
1154
+ }
1155
+ case .unableToDecrypt:
1156
+ contentDict["body"] = "Unable to decrypt message"
1157
+ contentDict["msgtype"] = "m.text"
1158
+ contentDict["encrypted"] = true
1159
+ case .redactedMessage:
1160
+ eventType = "m.room.redaction"
1161
+ contentDict["body"] = "Message deleted"
1162
+ default:
1163
+ eventType = "m.room.unknown"
1164
+ }
1165
+
1166
+ // Reactions
1167
+ let reactions = eventItem.reactions()
1168
+ if !reactions.isEmpty {
1169
+ contentDict["reactions"] = reactions.map { r in
1170
+ [
1171
+ "key": r.key,
1172
+ "count": r.senders.count,
1173
+ "senders": r.senders.map { $0.senderId },
1174
+ ] as [String: Any]
1175
+ }
1176
+ }
1177
+
1178
+ // Delivery/read status
1179
+ var status: String = "sent"
1180
+ if let sendState = eventItem.localSendState() {
1181
+ switch sendState {
1182
+ case .notSentYet:
1183
+ status = "sending"
1184
+ case .sendingFailed(_, _):
1185
+ status = "sending"
1186
+ case .sent(_):
1187
+ // Check read receipts below
1188
+ break
1189
+ default:
1190
+ break
1191
+ }
1192
+ }
1193
+
1194
+ var readBy: [String]? = nil
1195
+ let receipts = eventItem.readReceipts()
1196
+ if !receipts.isEmpty {
1197
+ print("[CapMatrix] readReceipts for \(eventId): \(receipts.keys) sender=\(eventItem.sender())")
1198
+ }
1199
+ if status == "sent" {
1200
+ let others = receipts.keys.filter { $0 != eventItem.sender() }
1201
+ if !others.isEmpty {
1202
+ status = "read"
1203
+ readBy = Array(others)
1204
+ }
1205
+ }
1206
+
1207
+ return [
1208
+ "eventId": eventId,
1209
+ "roomId": roomId,
1210
+ "senderId": eventItem.sender(),
1211
+ "type": eventType,
1212
+ "content": contentDict,
1213
+ "originServerTs": eventItem.timestamp(),
1214
+ "status": status,
1215
+ "readBy": readBy as Any,
1216
+ ]
1217
+ }
1218
+
1219
+ // MARK: - Live Timeline Listener (for sync subscriptions)
1220
+
1221
+ class LiveTimelineListener: TimelineListener {
1222
+ private let roomId: String
1223
+ private let onMessage: ([String: Any]) -> Void
1224
+ private let onRoomUpdate: (String, [String: Any]) -> Void
1225
+
1226
+ init(roomId: String, onMessage: @escaping ([String: Any]) -> Void, onRoomUpdate: @escaping (String, [String: Any]) -> Void) {
1227
+ self.roomId = roomId
1228
+ self.onMessage = onMessage
1229
+ self.onRoomUpdate = onRoomUpdate
1230
+ }
1231
+
1232
+ func onUpdate(diff: [TimelineDiff]) {
1233
+ print("[CapMatrix] LiveTimelineListener onUpdate for \(roomId): \(diff.count) diffs")
1234
+ for d in diff {
1235
+ let change = d.change()
1236
+ print("[CapMatrix] diff type: \(change)")
1237
+ switch change {
1238
+ case .reset:
1239
+ let items = d.reset() ?? []
1240
+ print("[CapMatrix] Reset: \(items.count) items")
1241
+ items.forEach { item in
1242
+ if let event = serializeTimelineItem(item, roomId: roomId) {
1243
+ print("[CapMatrix] Reset item: \(event["eventId"] ?? "nil") type=\(event["type"] ?? "nil")")
1244
+ onMessage(event)
1245
+ }
1246
+ }
1247
+ onRoomUpdate(roomId, ["roomId": roomId])
1248
+ case .append:
1249
+ let items = d.append() ?? []
1250
+ print("[CapMatrix] Append: \(items.count) items")
1251
+ items.forEach { item in
1252
+ if let event = serializeTimelineItem(item, roomId: roomId) {
1253
+ print("[CapMatrix] Append item: \(event["eventId"] ?? "nil") type=\(event["type"] ?? "nil")")
1254
+ onMessage(event)
1255
+ }
1256
+ }
1257
+ onRoomUpdate(roomId, ["roomId": roomId])
1258
+ case .pushBack:
1259
+ if let item = d.pushBack() {
1260
+ let isLocalEcho = item.asEvent()?.eventId() == nil && item.asEvent()?.transactionId() != nil
1261
+ print("[CapMatrix] PushBack: localEcho=\(isLocalEcho)")
1262
+ if !isLocalEcho, let event = serializeTimelineItem(item, roomId: roomId) {
1263
+ print("[CapMatrix] PushBack item: \(event["eventId"] ?? "nil") type=\(event["type"] ?? "nil")")
1264
+ onMessage(event)
1265
+ }
1266
+ onRoomUpdate(roomId, ["roomId": roomId])
1267
+ }
1268
+ case .pushFront:
1269
+ if let item = d.pushFront() {
1270
+ if let event = serializeTimelineItem(item, roomId: roomId) {
1271
+ print("[CapMatrix] PushFront item: \(event["eventId"] ?? "nil")")
1272
+ onMessage(event)
1273
+ }
1274
+ onRoomUpdate(roomId, ["roomId": roomId])
1275
+ }
1276
+ case .set:
1277
+ if let data = d.set() {
1278
+ if let event = serializeTimelineItem(data.item, roomId: roomId) {
1279
+ print("[CapMatrix] Set item: \(event["eventId"] ?? "nil") type=\(event["type"] ?? "nil") status=\(event["status"] ?? "nil") readBy=\(event["readBy"] ?? "nil")")
1280
+ onMessage(event)
1281
+ // If this event has readBy data, trigger roomUpdated
1282
+ // so the app can refresh receipt status for all messages
1283
+ if let rb = event["readBy"] as? [String], !rb.isEmpty {
1284
+ onRoomUpdate(roomId, ["roomId": roomId])
1285
+ }
1286
+ }
1287
+ }
1288
+ case .insert:
1289
+ if let data = d.insert() {
1290
+ if let event = serializeTimelineItem(data.item, roomId: roomId) {
1291
+ print("[CapMatrix] Insert item: \(event["eventId"] ?? "nil")")
1292
+ onMessage(event)
1293
+ }
1294
+ onRoomUpdate(roomId, ["roomId": roomId])
1295
+ }
1296
+ case .remove:
1297
+ break // Index-based removal, handled by JS layer
1298
+ default:
1299
+ break
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ // MARK: - Timeline Item Collector (for pagination/one-shot reads)
1306
+
1307
+ /// Mirrors the SDK's full timeline (including virtual/nil items) so that
1308
+ /// index-based diffs (Insert, Remove, Set) stay correct. The public `events`
1309
+ /// property filters out nils to return only real event items.
1310
+ class TimelineItemCollector: TimelineListener {
1311
+ private let lock = NSLock()
1312
+ // Full mirror of the SDK timeline — nil entries represent virtual items
1313
+ // (day separators, read markers, etc.) that serializeTimelineItem skips.
1314
+ private var _items: [[String: Any]?] = []
1315
+ private var _uniqueIdMap: [String: String] = [:] // eventId -> uniqueId
1316
+ private let roomId: String
1317
+ private var _updateContinuation: CheckedContinuation<Bool, Never>?
1318
+ private var _updateCount = 0
1319
+ private var _lastWaitedCount = 0
1320
+
1321
+ init(roomId: String) {
1322
+ self.roomId = roomId
1323
+ }
1324
+
1325
+ /// Waits for the listener to receive at least one update since the last call (or since creation).
1326
+ /// Returns true if an update was received, false if the timeout was hit.
1327
+ @discardableResult
1328
+ func waitForUpdate(timeoutNanos: UInt64 = 0) async -> Bool {
1329
+ lock.lock()
1330
+ let countBefore = _lastWaitedCount
1331
+ if _updateCount > countBefore {
1332
+ _lastWaitedCount = _updateCount
1333
+ lock.unlock()
1334
+ return true
1335
+ }
1336
+ lock.unlock()
1337
+
1338
+ // Race between the update arriving and an optional timeout
1339
+ return await withTaskGroup(of: Bool.self) { group in
1340
+ group.addTask {
1341
+ await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
1342
+ self.lock.lock()
1343
+ if self._updateCount > countBefore {
1344
+ self._lastWaitedCount = self._updateCount
1345
+ self.lock.unlock()
1346
+ cont.resume(returning: true)
1347
+ } else {
1348
+ self._updateContinuation = cont
1349
+ self.lock.unlock()
1350
+ }
1351
+ }
1352
+ }
1353
+ if timeoutNanos > 0 {
1354
+ group.addTask {
1355
+ try? await Task.sleep(nanoseconds: timeoutNanos)
1356
+ return false
1357
+ }
1358
+ }
1359
+ let result = await group.next() ?? false
1360
+ if !result {
1361
+ // Timeout won — clear and resume the pending continuation
1362
+ self.lock.lock()
1363
+ let pending = self._updateContinuation
1364
+ self._updateContinuation = nil
1365
+ self.lock.unlock()
1366
+ pending?.resume(returning: false)
1367
+ }
1368
+ group.cancelAll()
1369
+ self.lock.lock()
1370
+ self._lastWaitedCount = self._updateCount
1371
+ self.lock.unlock()
1372
+ return result
1373
+ }
1374
+ }
1375
+
1376
+ /// Returns only the non-nil (real event) items, in timeline order.
1377
+ var events: [[String: Any]] {
1378
+ lock.lock()
1379
+ defer { lock.unlock() }
1380
+ return _items.compactMap { $0 }
1381
+ }
1382
+
1383
+ func uniqueIdForEvent(_ eventId: String) -> String? {
1384
+ lock.lock()
1385
+ defer { lock.unlock() }
1386
+ return _uniqueIdMap[eventId]
1387
+ }
1388
+
1389
+ func onUpdate(diff: [TimelineDiff]) {
1390
+ var continuation: CheckedContinuation<Bool, Never>?
1391
+ lock.lock()
1392
+ for d in diff {
1393
+ switch d.change() {
1394
+ case .reset:
1395
+ _items.removeAll()
1396
+ _uniqueIdMap.removeAll()
1397
+ d.reset()?.forEach { item in
1398
+ trackUniqueId(item)
1399
+ _items.append(serializeTimelineItem(item, roomId: roomId))
1400
+ }
1401
+ case .append:
1402
+ d.append()?.forEach { item in
1403
+ trackUniqueId(item)
1404
+ _items.append(serializeTimelineItem(item, roomId: roomId))
1405
+ }
1406
+ case .pushBack:
1407
+ if let item = d.pushBack() {
1408
+ trackUniqueId(item)
1409
+ _items.append(serializeTimelineItem(item, roomId: roomId))
1410
+ }
1411
+ case .pushFront:
1412
+ if let item = d.pushFront() {
1413
+ trackUniqueId(item)
1414
+ _items.insert(serializeTimelineItem(item, roomId: roomId), at: 0)
1415
+ }
1416
+ case .set:
1417
+ if let data = d.set() {
1418
+ trackUniqueId(data.item)
1419
+ let idx = Int(data.index)
1420
+ if idx >= 0 && idx < _items.count {
1421
+ _items[idx] = serializeTimelineItem(data.item, roomId: roomId)
1422
+ }
1423
+ }
1424
+ case .insert:
1425
+ if let data = d.insert() {
1426
+ trackUniqueId(data.item)
1427
+ let idx = min(Int(data.index), _items.count)
1428
+ _items.insert(serializeTimelineItem(data.item, roomId: roomId), at: idx)
1429
+ }
1430
+ case .clear:
1431
+ _items.removeAll()
1432
+ _uniqueIdMap.removeAll()
1433
+ default:
1434
+ break
1435
+ }
1436
+ }
1437
+ _updateCount += 1
1438
+ continuation = _updateContinuation
1439
+ _updateContinuation = nil
1440
+ lock.unlock()
1441
+ continuation?.resume(returning: true)
1442
+ }
1443
+
1444
+ private func trackUniqueId(_ item: TimelineItem) {
1445
+ guard let eventItem = item.asEvent() else { return }
1446
+ let uniqueId = item.uniqueId()
1447
+ if let eid = eventItem.eventId() {
1448
+ _uniqueIdMap[eid] = uniqueId
1449
+ }
1450
+ if let tid = eventItem.transactionId() {
1451
+ _uniqueIdMap[tid] = uniqueId
1452
+ }
1453
+ }
1454
+ }
1455
+
1456
+ // MARK: - Errors
1457
+
1458
+ enum MatrixBridgeError: LocalizedError {
1459
+ case notLoggedIn
1460
+ case roomNotFound(String)
1461
+ case notSupported(String)
1462
+
1463
+ var errorDescription: String? {
1464
+ switch self {
1465
+ case .notLoggedIn:
1466
+ return "Not logged in. Call login() or loginWithToken() first."
1467
+ case .roomNotFound(let roomId):
1468
+ return "Room \(roomId) not found"
1469
+ case .notSupported(let method):
1470
+ return "\(method) is not supported in this version of the Matrix SDK"
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // MARK: - Sync Observer Proxy
1476
+
1477
+ class SyncStateObserverProxy: SyncServiceStateObserver {
1478
+ private let onUpdateHandler: (SyncServiceState) -> Void
1479
+
1480
+ init(onUpdate: @escaping (SyncServiceState) -> Void) {
1481
+ self.onUpdateHandler = onUpdate
1482
+ }
1483
+
1484
+ func onUpdate(state: SyncServiceState) {
1485
+ onUpdateHandler(state)
1486
+ }
1487
+ }
1488
+
1489
+ // MARK: - Enable Recovery Progress Listener (no-op)
1490
+
1491
+ class NoopEnableRecoveryProgressListener: EnableRecoveryProgressListener {
1492
+ func onUpdate(status: EnableRecoveryProgress) {
1493
+ // No-op
1494
+ }
1495
+ }
1496
+
1497
+ // MARK: - Keychain Store
1498
+
1499
+ class MatrixKeychainStore {
1500
+ private let service = "de.tremaze.matrix"
1501
+
1502
+ func save(session: MatrixSessionInfo) {
1503
+ let data: [String: String] = session.toDictionary()
1504
+ guard let jsonData = try? JSONEncoder().encode(data) else { return }
1505
+
1506
+ let query: [String: Any] = [
1507
+ kSecClass as String: kSecClassGenericPassword,
1508
+ kSecAttrService as String: service,
1509
+ kSecAttrAccount as String: "session",
1510
+ kSecValueData as String: jsonData
1511
+ ]
1512
+
1513
+ SecItemDelete(query as CFDictionary)
1514
+ SecItemAdd(query as CFDictionary, nil)
1515
+ }
1516
+
1517
+ func load() -> MatrixSessionInfo? {
1518
+ let query: [String: Any] = [
1519
+ kSecClass as String: kSecClassGenericPassword,
1520
+ kSecAttrService as String: service,
1521
+ kSecAttrAccount as String: "session",
1522
+ kSecReturnData as String: true,
1523
+ kSecMatchLimit as String: kSecMatchLimitOne
1524
+ ]
1525
+
1526
+ var result: AnyObject?
1527
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
1528
+ guard status == errSecSuccess, let data = result as? Data else { return nil }
1529
+ guard let dict = try? JSONDecoder().decode([String: String].self, from: data) else { return nil }
1530
+
1531
+ guard let accessToken = dict["accessToken"],
1532
+ let userId = dict["userId"],
1533
+ let deviceId = dict["deviceId"],
1534
+ let homeserverUrl = dict["homeserverUrl"] else { return nil }
1535
+
1536
+ return MatrixSessionInfo(
1537
+ accessToken: accessToken,
1538
+ userId: userId,
1539
+ deviceId: deviceId,
1540
+ homeserverUrl: homeserverUrl
1541
+ )
1542
+ }
1543
+
1544
+ func clear() {
1545
+ let query: [String: Any] = [
1546
+ kSecClass as String: kSecClassGenericPassword,
1547
+ kSecAttrService as String: service,
1548
+ kSecAttrAccount as String: "session"
1549
+ ]
1550
+ SecItemDelete(query as CFDictionary)
1551
+ }
1552
+ }