@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,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
|
+
}
|