@bota.dev/react-native-app-sdk 2.0.0-beta.1 → 2.0.0-beta.11

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.
Files changed (152) hide show
  1. package/README.md +115 -4
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevices.kt +22 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
  9. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +86 -3
  10. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  11. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  12. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  13. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  14. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  15. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  16. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDevicesTest.kt +35 -0
  17. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  18. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  19. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  20. package/generated/codegen-contract.json +751 -32
  21. package/ios/BotaDeviceSDK.mm +114 -0
  22. package/ios/BotaDeviceSDKAppleBridge.swift +141 -4
  23. package/ios/BotaDeviceSDKAppleDevices.swift +11 -0
  24. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  25. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  26. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  27. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  28. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  29. package/ios/BotaUploadV2Backend.mm +49 -0
  30. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  31. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  32. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  33. package/ios/UploadBackend/UploadJournal.swift +187 -0
  34. package/ios/UploadBackend/UploadModels.swift +273 -0
  35. package/ios/UploadBackend/UploadOperation.swift +391 -0
  36. package/lib/commonjs/BotaClient.js +16 -2
  37. package/lib/commonjs/BotaClient.js.map +1 -1
  38. package/lib/commonjs/ble/constants.js +5 -1
  39. package/lib/commonjs/ble/constants.js.map +1 -1
  40. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  41. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  42. package/lib/commonjs/client.js +183 -28
  43. package/lib/commonjs/client.js.map +1 -1
  44. package/lib/commonjs/clientPresence.js +26 -0
  45. package/lib/commonjs/clientPresence.js.map +1 -0
  46. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  47. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  48. package/lib/commonjs/index.js +34 -0
  49. package/lib/commonjs/index.js.map +1 -1
  50. package/lib/commonjs/managers/DeviceManager.js +23 -0
  51. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  52. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  53. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  54. package/lib/commonjs/managers/RecordingManager.js +83 -102
  55. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  56. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  57. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  58. package/lib/commonjs/sdkIdentity.js +10 -0
  59. package/lib/commonjs/sdkIdentity.js.map +1 -0
  60. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  61. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  62. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  63. package/lib/commonjs/uploadRecovery.js +68 -0
  64. package/lib/commonjs/uploadRecovery.js.map +1 -0
  65. package/lib/module/BotaClient.js +16 -2
  66. package/lib/module/BotaClient.js.map +1 -1
  67. package/lib/module/ble/constants.js +4 -0
  68. package/lib/module/ble/constants.js.map +1 -1
  69. package/lib/module/ble/deviceDiagnostics.js +234 -0
  70. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  71. package/lib/module/client.js +183 -28
  72. package/lib/module/client.js.map +1 -1
  73. package/lib/module/clientPresence.js +22 -0
  74. package/lib/module/clientPresence.js.map +1 -0
  75. package/lib/module/encryptedUploadV2Backend.js +196 -0
  76. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  77. package/lib/module/index.js +7 -0
  78. package/lib/module/index.js.map +1 -1
  79. package/lib/module/managers/DeviceManager.js +23 -0
  80. package/lib/module/managers/DeviceManager.js.map +1 -1
  81. package/lib/module/managers/NativeUploadQueue.js +380 -0
  82. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  83. package/lib/module/managers/RecordingManager.js +83 -102
  84. package/lib/module/managers/RecordingManager.js.map +1 -1
  85. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  86. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  87. package/lib/module/sdkIdentity.js +6 -0
  88. package/lib/module/sdkIdentity.js.map +1 -0
  89. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  90. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  91. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  92. package/lib/module/uploadRecovery.js +64 -0
  93. package/lib/module/uploadRecovery.js.map +1 -0
  94. package/lib/typescript/BotaClient.d.ts +2 -0
  95. package/lib/typescript/BotaClient.d.ts.map +1 -1
  96. package/lib/typescript/ble/constants.d.ts +4 -0
  97. package/lib/typescript/ble/constants.d.ts.map +1 -1
  98. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  99. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  100. package/lib/typescript/client.d.ts +24 -3
  101. package/lib/typescript/client.d.ts.map +1 -1
  102. package/lib/typescript/clientPresence.d.ts +26 -0
  103. package/lib/typescript/clientPresence.d.ts.map +1 -0
  104. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  105. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  106. package/lib/typescript/index.d.ts +13 -4
  107. package/lib/typescript/index.d.ts.map +1 -1
  108. package/lib/typescript/managers/DeviceManager.d.ts +5 -1
  109. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  110. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  111. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  112. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  113. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  114. package/lib/typescript/managers/types.d.ts +6 -1
  115. package/lib/typescript/managers/types.d.ts.map +1 -1
  116. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  117. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  118. package/lib/typescript/models/Device.d.ts +42 -0
  119. package/lib/typescript/models/Device.d.ts.map +1 -1
  120. package/lib/typescript/models/Recording.d.ts +47 -0
  121. package/lib/typescript/models/Recording.d.ts.map +1 -1
  122. package/lib/typescript/models/Status.d.ts +5 -1
  123. package/lib/typescript/models/Status.d.ts.map +1 -1
  124. package/lib/typescript/sdkIdentity.d.ts +3 -0
  125. package/lib/typescript/sdkIdentity.d.ts.map +1 -0
  126. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +70 -0
  127. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  128. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  129. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  130. package/lib/typescript/uploadRecovery.d.ts +27 -0
  131. package/lib/typescript/uploadRecovery.d.ts.map +1 -0
  132. package/package.json +1 -1
  133. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  134. package/src/BotaClient.ts +17 -3
  135. package/src/ble/constants.ts +5 -0
  136. package/src/ble/deviceDiagnostics.ts +238 -0
  137. package/src/client.ts +189 -18
  138. package/src/clientPresence.ts +33 -0
  139. package/src/encryptedUploadV2Backend.ts +171 -0
  140. package/src/index.ts +24 -0
  141. package/src/managers/DeviceManager.ts +23 -0
  142. package/src/managers/NativeUploadQueue.ts +330 -0
  143. package/src/managers/RecordingManager.ts +83 -116
  144. package/src/managers/types.ts +7 -1
  145. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  146. package/src/models/Device.ts +43 -0
  147. package/src/models/Recording.ts +52 -0
  148. package/src/models/Status.ts +5 -1
  149. package/src/sdkIdentity.ts +3 -0
  150. package/src/specs/NativeBotaDeviceSDK.ts +66 -0
  151. package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
  152. package/src/uploadRecovery.ts +66 -0
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import Darwin
2
3
 
3
4
  struct BotaDeviceSDKAppleRecordingUploadRequest: Sendable {
4
5
  let taskID: String
@@ -11,6 +12,7 @@ struct BotaDeviceSDKAppleRecordingUploadRequest: Sendable {
11
12
  let contentSHA256: String?
12
13
  let relayURL: String?
13
14
  let relayBearerToken: String?
15
+ var fileSizeBytes: Int64? = nil
14
16
  }
15
17
 
16
18
  struct BotaDeviceSDKAppleRecordingUploadProgress: Equatable, Sendable {
@@ -25,6 +27,9 @@ actor BotaDeviceSDKAppleRecordingUploads {
25
27
  case invalidResponse
26
28
  case missingFile(String)
27
29
  case requestFailed(Int)
30
+ case invalidJournal
31
+ case notCompleted
32
+ case fileSizeChanged
28
33
 
29
34
  var errorDescription: String? {
30
35
  switch self {
@@ -36,24 +41,42 @@ actor BotaDeviceSDKAppleRecordingUploads {
36
41
  "recording file does not exist: \(path)"
37
42
  case let .requestFailed(statusCode):
38
43
  "recording upload failed with HTTP \(statusCode)"
44
+ case .invalidJournal:
45
+ "invalid upload recovery journal"
46
+ case .notCompleted:
47
+ "recording release requires exact durable completion"
48
+ case .fileSizeChanged:
49
+ "stored recording length changed"
39
50
  }
40
51
  }
41
52
  }
42
53
 
43
54
  private var queueFile: URL
55
+ private var recordingDirectory: URL
44
56
  private let session: URLSession
57
+ private let syncDirectory: @Sendable (URL) throws -> Void
58
+ private var journalWriteUncertain = false
45
59
  private var operations: [String: Task<Void, Error>] = [:]
60
+ private var cancelledAttempts = Set<String>()
46
61
 
47
62
  init(
48
63
  queueFile: URL = BotaDeviceSDKAppleRecordingUploads.defaultQueueFile(),
49
- session: URLSession = .shared
64
+ session: URLSession = .shared,
65
+ syncDirectory: @escaping @Sendable (URL) throws -> Void = BotaDeviceSDKAppleRecordingUploads.synchronizeDirectory
50
66
  ) {
51
67
  self.queueFile = queueFile
68
+ recordingDirectory = queueFile.deletingLastPathComponent().appendingPathComponent("Recordings", isDirectory: true)
52
69
  self.session = session
70
+ self.syncDirectory = syncDirectory
53
71
  }
54
72
 
55
73
  func configure(applicationSupportDirectory: URL?) {
56
- guard let applicationSupportDirectory else { return }
74
+ guard let applicationSupportDirectory else {
75
+ queueFile = Self.defaultQueueFile()
76
+ recordingDirectory = queueFile.deletingLastPathComponent().appendingPathComponent("Recordings", isDirectory: true)
77
+ return
78
+ }
79
+ recordingDirectory = applicationSupportDirectory.appendingPathComponent("Recordings", isDirectory: true)
57
80
  queueFile = applicationSupportDirectory
58
81
  .appendingPathComponent("BotaDeviceSDK", isDirectory: true)
59
82
  .appendingPathComponent("compat-upload-queue.json")
@@ -63,6 +86,8 @@ actor BotaDeviceSDKAppleRecordingUploads {
63
86
  _ upload: BotaDeviceSDKAppleRecordingUploadRequest,
64
87
  onProgress: @escaping @Sendable (BotaDeviceSDKAppleRecordingUploadProgress) -> Void
65
88
  ) async throws {
89
+ guard !cancelledAttempts.contains(upload.taskID) else { throw CancellationError() }
90
+ try requireOwnedFile(upload.localPath)
66
91
  guard operations[upload.taskID] == nil else {
67
92
  throw UploadError.alreadyActive(upload.taskID)
68
93
  }
@@ -72,16 +97,21 @@ actor BotaDeviceSDKAppleRecordingUploads {
72
97
  }
73
98
  operations[upload.taskID] = operation
74
99
  defer { operations.removeValue(forKey: upload.taskID) }
75
- try await operation.value
100
+ try await withTaskCancellationHandler {
101
+ try await operation.value
102
+ } onCancel: {
103
+ operation.cancel()
104
+ }
76
105
  }
77
106
 
78
107
  func cancel(taskID: String) {
79
- operations.removeValue(forKey: taskID)?.cancel()
108
+ cancelledAttempts.insert(taskID)
109
+ operations[taskID]?.cancel()
80
110
  }
81
111
 
82
112
  func cancelAll() {
113
+ cancelledAttempts.formUnion(operations.keys)
83
114
  operations.values.forEach { $0.cancel() }
84
- operations.removeAll()
85
115
  }
86
116
 
87
117
  func loadQueue() throws -> String {
@@ -90,11 +120,103 @@ actor BotaDeviceSDKAppleRecordingUploads {
90
120
  }
91
121
 
92
122
  func saveQueue(_ serializedTasks: String) throws {
123
+ try writeQueue(Self.serialize(Self.metadata(serializedTasks)))
124
+ }
125
+
126
+ func release(taskID: String, localPath: String) async throws {
127
+ guard !journalWriteUncertain else { throw UploadError.notCompleted }
128
+ let tasks = try Self.metadata(String(contentsOf: queueFile, encoding: .utf8))
129
+ guard tasks.contains(where: {
130
+ $0["id"] as? String == taskID && $0["localPath"] as? String == localPath &&
131
+ $0["status"] as? String == "completed"
132
+ }) else { throw UploadError.notCompleted }
133
+ try removeCompletedFile(localPath)
134
+ }
135
+
136
+ private func removeCompletedFile(_ path: String) throws {
137
+ try requireOwnedFile(path)
138
+ if FileManager.default.fileExists(atPath: path) {
139
+ let attributes = try FileManager.default.attributesOfItem(atPath: path)
140
+ guard attributes[.type] as? FileAttributeType == .typeRegular else { throw UploadError.notCompleted }
141
+ try FileManager.default.removeItem(atPath: path)
142
+ }
143
+ // A missing file may be an earlier unlink whose directory sync failed.
144
+ try syncDirectory(URL(fileURLWithPath: path).deletingLastPathComponent())
145
+ }
146
+
147
+ private func requireOwnedFile(_ path: String) throws {
148
+ let file = URL(fileURLWithPath: path)
149
+ guard path.hasPrefix("/"), file.pathExtension == "recording",
150
+ UUID(uuidString: file.deletingPathExtension().lastPathComponent) != nil,
151
+ file.resolvingSymlinksInPath().deletingLastPathComponent().standardizedFileURL == recordingDirectory.resolvingSymlinksInPath().standardizedFileURL
152
+ else { throw UploadError.notCompleted }
153
+ if FileManager.default.fileExists(atPath: path) {
154
+ let attributes = try FileManager.default.attributesOfItem(atPath: path)
155
+ guard attributes[.type] as? FileAttributeType == .typeRegular else { throw UploadError.notCompleted }
156
+ }
157
+ }
158
+
159
+ private func writeQueue(_ serializedTasks: String) throws {
160
+ journalWriteUncertain = true
93
161
  try FileManager.default.createDirectory(
94
162
  at: queueFile.deletingLastPathComponent(),
95
163
  withIntermediateDirectories: true
96
164
  )
165
+ try syncDirectory(queueFile.deletingLastPathComponent().deletingLastPathComponent())
97
166
  try Data(serializedTasks.utf8).write(to: queueFile, options: .atomic)
167
+ let handle = try FileHandle(forWritingTo: queueFile)
168
+ defer { try? handle.close() }
169
+ try handle.synchronize()
170
+ try syncDirectory(queueFile.deletingLastPathComponent())
171
+ journalWriteUncertain = false
172
+ }
173
+
174
+ private static func serialize(_ tasks: [[String: Any]]) throws -> String {
175
+ String(decoding: try JSONSerialization.data(withJSONObject: tasks, options: [.sortedKeys]), as: UTF8.self)
176
+ }
177
+
178
+ private static func metadata(_ serialized: String) throws -> [[String: Any]] {
179
+ guard let tasks = try JSONSerialization.jsonObject(with: Data(serialized.utf8)) as? [[String: Any]] else {
180
+ throw UploadError.invalidJournal
181
+ }
182
+ let fields: Set<String> = ["id", "recordingId", "deviceId", "recordingUuid", "localPath", "status", "retryCount", "createdAt", "updatedAt", "recoveryScope", "fileSizeBytes", "nextAttemptAt", "contentType", "contentSha256", "relayUpload"]
183
+ var ids = Set<String>()
184
+ return try tasks.map { task in
185
+ guard let id = task["id"] as? String, !id.isEmpty, ids.insert(id).inserted,
186
+ let recordingID = task["recordingId"] as? String, !recordingID.isEmpty,
187
+ let deviceID = task["deviceId"] as? String, !deviceID.isEmpty,
188
+ task["localPath"] is String, task["createdAt"] is String, task["updatedAt"] is String,
189
+ let status = task["status"] as? String, ["pending", "uploading", "completed", "failed"].contains(status),
190
+ validInteger(task["retryCount"])
191
+ else { throw UploadError.invalidJournal }
192
+ for key in ["recordingUuid", "recoveryScope", "contentType", "contentSha256"] where task[key] != nil {
193
+ guard task[key] is String else { throw UploadError.invalidJournal }
194
+ }
195
+ for key in ["fileSizeBytes", "nextAttemptAt"] where task[key] != nil {
196
+ guard validInteger(task[key]) else { throw UploadError.invalidJournal }
197
+ }
198
+ if let relay = task["relayUpload"] {
199
+ guard let number = relay as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { throw UploadError.invalidJournal }
200
+ }
201
+ let dates = ISO8601DateFormatter()
202
+ let fractionalDates = ISO8601DateFormatter()
203
+ fractionalDates.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
204
+ for key in ["createdAt", "updatedAt"] {
205
+ guard let value = task[key] as? String,
206
+ dates.date(from: value) != nil || fractionalDates.date(from: value) != nil
207
+ else { throw UploadError.invalidJournal }
208
+ }
209
+ var clean = task.filter { fields.contains($0.key) }
210
+ guard clean.values.allSatisfy({ $0 is String || $0 is NSNumber }) else { throw UploadError.invalidJournal }
211
+ clean["relayUpload"] = task["relayUpload"] as? Bool ?? (task["relay"] is [String: Any])
212
+ return clean
213
+ }
214
+ }
215
+
216
+ private static func validInteger(_ value: Any?) -> Bool {
217
+ guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return false }
218
+ let value = number.doubleValue
219
+ return value.isFinite && value >= 0 && value <= 9_007_199_254_740_991 && value.rounded() == value
98
220
  }
99
221
 
100
222
  private static func performUpload(
@@ -108,6 +230,8 @@ actor BotaDeviceSDKAppleRecordingUploads {
108
230
  }
109
231
  let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
110
232
  let totalBytes = (attributes[.size] as? NSNumber)?.int64Value ?? 0
233
+ if let expected = upload.fileSizeBytes, expected != totalBytes { throw UploadError.fileSizeChanged }
234
+ try Task.checkCancellation()
111
235
  onProgress(.init(taskID: upload.taskID, completedBytes: 0, totalBytes: totalBytes))
112
236
 
113
237
  let relay = upload.relayURL != nil
@@ -123,6 +247,7 @@ actor BotaDeviceSDKAppleRecordingUploads {
123
247
  }
124
248
  let (_, response) = try await session.upload(for: request, fromFile: fileURL)
125
249
  try Self.requireSuccess(response)
250
+ try Task.checkCancellation()
126
251
 
127
252
  if !relay,
128
253
  let completeURL = upload.completeURL,
@@ -144,12 +269,12 @@ actor BotaDeviceSDKAppleRecordingUploads {
144
269
  try requireSuccess(completeResponse)
145
270
  }
146
271
 
272
+ try Task.checkCancellation()
147
273
  onProgress(.init(
148
274
  taskID: upload.taskID,
149
275
  completedBytes: totalBytes,
150
276
  totalBytes: totalBytes
151
277
  ))
152
- try FileManager.default.removeItem(at: fileURL)
153
278
  }
154
279
 
155
280
  private static func requiredURL(_ value: String) throws -> URL {
@@ -175,4 +300,11 @@ actor BotaDeviceSDKAppleRecordingUploads {
175
300
  .appendingPathComponent("BotaDeviceSDK", isDirectory: true)
176
301
  .appendingPathComponent("compat-upload-queue.json")
177
302
  }
303
+
304
+ private static func synchronizeDirectory(_ directory: URL) throws {
305
+ let descriptor = Darwin.open(directory.path, O_RDONLY)
306
+ guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
307
+ defer { Darwin.close(descriptor) }
308
+ guard Darwin.fsync(descriptor) == 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
309
+ }
178
310
  }
@@ -5,10 +5,12 @@ struct BotaDeviceSDKAppleRecordingFile: Equatable, Sendable {
5
5
  let localPath: String
6
6
  let isE2EEncrypted: Bool
7
7
  let contentSHA256Hex: String?
8
+ let fileSizeBytes: UInt64
8
9
  }
9
10
 
10
11
  protocol BotaDeviceSDKAppleRecordingClient: Sendable {
11
12
  func listRecordings(_ device: ConnectedDevice) async throws -> [DeviceRecording]
13
+ func listPendingRecordings(_ device: ConnectedDevice) async throws -> [PendingRecording]
12
14
  func syncRecording(
13
15
  _ device: ConnectedDevice,
14
16
  recording: DeviceRecording,
@@ -18,8 +20,10 @@ protocol BotaDeviceSDKAppleRecordingClient: Sendable {
18
20
  func syncEncryptedRecordingV2(
19
21
  _ device: ConnectedDevice,
20
22
  recording: EncryptedUploadV2Recording,
23
+ operationID: UUID,
21
24
  provider: @escaping EncryptedUploadV2ProfileProvider
22
25
  ) async throws
26
+ func cancelEncryptedUploadV2Operation(_ operationID: UUID) async throws
23
27
  func confirmRecording(_ device: ConnectedDevice, recordingUUID: String) async throws
24
28
  func observeUploadOwnership(
25
29
  _ device: ConnectedDevice,
@@ -50,6 +54,10 @@ struct BotaDeviceSDKSharedAppleRecordingClient: BotaDeviceSDKAppleRecordingClien
50
54
  try await recordings.listRecordings(device)
51
55
  }
52
56
 
57
+ func listPendingRecordings(_ device: ConnectedDevice) async throws -> [PendingRecording] {
58
+ try await recordings.listPendingRecordings(device)
59
+ }
60
+
53
61
  func syncRecording(
54
62
  _ device: ConnectedDevice,
55
63
  recording: DeviceRecording,
@@ -70,15 +78,21 @@ struct BotaDeviceSDKSharedAppleRecordingClient: BotaDeviceSDKAppleRecordingClien
70
78
  func syncEncryptedRecordingV2(
71
79
  _ device: ConnectedDevice,
72
80
  recording: EncryptedUploadV2Recording,
81
+ operationID: UUID,
73
82
  provider: @escaping EncryptedUploadV2ProfileProvider
74
83
  ) async throws {
75
84
  try await recordings.syncEncryptedRecordingV2(
76
85
  device,
77
86
  recording: recording,
87
+ operationID: operationID,
78
88
  provider: provider
79
89
  )
80
90
  }
81
91
 
92
+ func cancelEncryptedUploadV2Operation(_ operationID: UUID) async throws {
93
+ try await recordings.cancelEncryptedUploadV2Operation(operationID)
94
+ }
95
+
82
96
  func confirmRecording(_ device: ConnectedDevice, recordingUUID: String) async throws {
83
97
  try await recordings.confirmRecording(device, recordingUUID: recordingUUID)
84
98
  }
@@ -153,11 +167,16 @@ actor BotaDeviceSDKAppleRecordings {
153
167
  }
154
168
 
155
169
  private let client: any BotaDeviceSDKAppleRecordingClient
170
+ private let fileSize: @Sendable (String) throws -> UInt64
156
171
  private struct EncryptedUploadV2Request {
172
+ let operationID: String
157
173
  let recording: EncryptedUploadV2Recording
158
174
  let continuation: CheckedContinuation<EncryptedUploadV2Material, Error>
159
175
  }
160
176
  private var encryptedUploadV2Requests: [String: EncryptedUploadV2Request] = [:]
177
+ private var encryptedUploadV2Operations: [String: UUID] = [:]
178
+ private var encryptedUploadV2Tasks: [String: Task<Void, Error>] = [:]
179
+ private var cancelledEncryptedUploadV2Operations: Set<String> = []
161
180
  private var destinationRequests: [
162
181
  String: CheckedContinuation<StreamingUploadDestination, Error>
163
182
  ] = [:]
@@ -166,15 +185,28 @@ actor BotaDeviceSDKAppleRecordings {
166
185
 
167
186
  init(
168
187
  client: any BotaDeviceSDKAppleRecordingClient =
169
- BotaDeviceSDKSharedAppleRecordingClient()
188
+ BotaDeviceSDKSharedAppleRecordingClient(),
189
+ fileSize: @escaping @Sendable (String) throws -> UInt64 = { path in
190
+ let attributes = try FileManager.default.attributesOfItem(atPath: path)
191
+ guard attributes[.type] as? FileAttributeType == .typeRegular,
192
+ let size = attributes[.size] as? NSNumber else {
193
+ throw CocoaError(.fileReadCorruptFile)
194
+ }
195
+ return size.uint64Value
196
+ }
170
197
  ) {
171
198
  self.client = client
199
+ self.fileSize = fileSize
172
200
  }
173
201
 
174
202
  func listRecordings(_ device: ConnectedDevice) async throws -> [DeviceRecording] {
175
203
  try await client.listRecordings(device)
176
204
  }
177
205
 
206
+ func listPendingRecordings(_ device: ConnectedDevice) async throws -> [PendingRecording] {
207
+ try await client.listPendingRecordings(device)
208
+ }
209
+
178
210
  func syncRecording(
179
211
  _ device: ConnectedDevice,
180
212
  recording: DeviceRecording,
@@ -200,7 +232,8 @@ actor BotaDeviceSDKAppleRecordings {
200
232
  return BotaDeviceSDKAppleRecordingFile(
201
233
  localPath: path,
202
234
  isE2EEncrypted: metadata?.isE2EEncrypted ?? false,
203
- contentSHA256Hex: metadata?.contentSHA256Hex
235
+ contentSHA256Hex: metadata?.contentSHA256Hex,
236
+ fileSizeBytes: try fileSize(path)
204
237
  )
205
238
  }
206
239
 
@@ -210,11 +243,51 @@ actor BotaDeviceSDKAppleRecordings {
210
243
  operationID: String,
211
244
  onProfileRequest: @escaping @Sendable ([String: Any]) -> Void,
212
245
  onProgress: @escaping @Sendable ([String: Any]) -> Void
246
+ ) async throws {
247
+ guard encryptedUploadV2Operations[operationID] == nil else {
248
+ throw RecordingError.invalidEncryptedUploadV2Selection
249
+ }
250
+ guard !cancelledEncryptedUploadV2Operations.contains(operationID) else { throw CancellationError() }
251
+ let nativeID = UUID()
252
+ let task = Task {
253
+ try Task.checkCancellation()
254
+ try await self.runEncryptedRecordingV2(
255
+ device,
256
+ recording: recording,
257
+ operationID: operationID,
258
+ nativeID: nativeID,
259
+ onProfileRequest: onProfileRequest,
260
+ onProgress: onProgress
261
+ )
262
+ }
263
+ encryptedUploadV2Operations[operationID] = nativeID
264
+ encryptedUploadV2Tasks[operationID] = task
265
+ defer {
266
+ encryptedUploadV2Operations.removeValue(forKey: operationID)
267
+ encryptedUploadV2Tasks.removeValue(forKey: operationID)
268
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(operationID)
269
+ }
270
+ try await withTaskCancellationHandler {
271
+ try await task.value
272
+ } onCancel: {
273
+ task.cancel()
274
+ Task { try? await self.cancelEncryptedRecordingV2(operationID) }
275
+ }
276
+ }
277
+
278
+ private func runEncryptedRecordingV2(
279
+ _ device: ConnectedDevice,
280
+ recording: EncryptedUploadV2Recording,
281
+ operationID: String,
282
+ nativeID: UUID,
283
+ onProfileRequest: @escaping @Sendable ([String: Any]) -> Void,
284
+ onProgress: @escaping @Sendable ([String: Any]) -> Void
213
285
  ) async throws {
214
286
  do {
215
287
  try await client.syncEncryptedRecordingV2(
216
288
  device,
217
- recording: recording
289
+ recording: recording,
290
+ operationID: nativeID
218
291
  ) { context in
219
292
  let completedBytes = String(context.checkpoint?.nextCiphertextOffset ?? 0)
220
293
  let checkpointRevision = context.checkpoint?.revision
@@ -264,6 +337,25 @@ actor BotaDeviceSDKAppleRecordings {
264
337
  }
265
338
  }
266
339
 
340
+ func cancelEncryptedRecordingV2(_ operationID: String) async throws {
341
+ cancelledEncryptedUploadV2Operations.insert(operationID)
342
+ let task = encryptedUploadV2Tasks[operationID]
343
+ task?.cancel()
344
+ let pending = encryptedUploadV2Requests.filter { $0.value.operationID == operationID }
345
+ for (id, request) in pending {
346
+ encryptedUploadV2Requests.removeValue(forKey: id)
347
+ request.continuation.resume(throwing: CancellationError())
348
+ }
349
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(operationID)
350
+ var cancellationError: Error?
351
+ if let nativeID = encryptedUploadV2Operations[operationID] {
352
+ do { try await client.cancelEncryptedUploadV2Operation(nativeID) }
353
+ catch { cancellationError = error }
354
+ }
355
+ _ = await task?.result
356
+ if let cancellationError { throw cancellationError }
357
+ }
358
+
267
359
  func resolveEncryptedUploadV2Profile(
268
360
  requestID: String,
269
361
  profile: String,
@@ -275,6 +367,7 @@ actor BotaDeviceSDKAppleRecordings {
275
367
  guard let request = encryptedUploadV2Requests.removeValue(forKey: requestID) else {
276
368
  throw RecordingError.missingEncryptedUploadV2Request
277
369
  }
370
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(request.operationID)
278
371
  do {
279
372
  guard profile == "encrypted_upload_v2",
280
373
  let sessionID = UUID(uuidString: uploadSessionID)
@@ -299,6 +392,7 @@ actor BotaDeviceSDKAppleRecordings {
299
392
  guard let request = encryptedUploadV2Requests.removeValue(forKey: requestID) else {
300
393
  throw RecordingError.missingEncryptedUploadV2Request
301
394
  }
395
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext(request.operationID)
302
396
  guard errorCode == "application_material_rejected" else {
303
397
  request.continuation.resume(throwing: RecordingError.invalidEncryptedUploadV2Selection)
304
398
  throw RecordingError.invalidEncryptedUploadV2Selection
@@ -453,6 +547,9 @@ actor BotaDeviceSDKAppleRecordings {
453
547
  }
454
548
 
455
549
  func cancelAll() async {
550
+ let operationIDs = Array(encryptedUploadV2Tasks.keys)
551
+ for id in operationIDs { encryptedUploadV2Tasks[id]?.cancel() }
552
+ for id in operationIDs { try? await cancelEncryptedRecordingV2(id) }
456
553
  rejectPendingRequests(message: "recording operations were cancelled")
457
554
  try? await client.cancelCurrentOperation()
458
555
  }
@@ -462,9 +559,13 @@ actor BotaDeviceSDKAppleRecordings {
462
559
  context: EncryptedUploadV2ProviderContext,
463
560
  onRequest: @escaping @Sendable ([String: Any]) -> Void
464
561
  ) async throws -> EncryptedUploadV2Material {
562
+ guard encryptedUploadV2Operations[operationID] != nil,
563
+ !cancelledEncryptedUploadV2Operations.contains(operationID) else { throw CancellationError() }
465
564
  let requestID = UUID().uuidString.lowercased()
565
+ try BotaDeviceSDKEncryptedUploadV2Materials.registerContext(operationID, context: context)
466
566
  return try await withCheckedThrowingContinuation { continuation in
467
567
  encryptedUploadV2Requests[requestID] = .init(
568
+ operationID: operationID,
468
569
  recording: context.recording,
469
570
  continuation: continuation
470
571
  )
@@ -525,12 +626,15 @@ actor BotaDeviceSDKAppleRecordings {
525
626
  destinationRequests.removeAll()
526
627
  finalizeRequests.removeAll()
527
628
  encryptedUploadV2Requests.removeAll()
528
- encryptedUploadV2.forEach { $0.continuation.resume(throwing: error) }
629
+ encryptedUploadV2.forEach {
630
+ BotaDeviceSDKEncryptedUploadV2Materials.removeContext($0.operationID)
631
+ $0.continuation.resume(throwing: error)
632
+ }
529
633
  destinations.forEach { $0.resume(throwing: error) }
530
634
  finalizations.forEach { $0.resume(throwing: error) }
531
635
  }
532
636
 
533
- private static func encryptedUploadV2Recording(
637
+ static func encryptedUploadV2Recording(
534
638
  _ recording: EncryptedUploadV2Recording
535
639
  ) -> [String: Any] {
536
640
  [
@@ -538,6 +642,10 @@ actor BotaDeviceSDKAppleRecordings {
538
642
  "generation": recording.generation,
539
643
  "ciphertextLength": String(recording.ciphertextLength),
540
644
  "ciphertextSha256": recording.ciphertextSHA256.hexString,
645
+ "startedAtMs": String(recording.startedAtMs),
646
+ "durationMs": String(recording.durationMs),
647
+ "plaintextLength": String(recording.plaintextLength),
648
+ "storageFormat": Int(recording.storageFormat),
541
649
  ]
542
650
  }
543
651
 
@@ -15,6 +15,25 @@ public enum BotaDeviceSDKEncryptedUploadV2Materials {
15
15
  storage.remove(registrationID)
16
16
  }
17
17
 
18
+ public static func readAuthNonce(_ operationID: String) async throws -> Data {
19
+ let entry = try storage.context(operationID)
20
+ let nonce = try await entry.value.readAuthNonce()
21
+ guard try storage.context(operationID).lease == entry.lease else {
22
+ throw CancellationError()
23
+ }
24
+ return nonce
25
+ }
26
+
27
+ public static func release(_ registrationID: String) async {
28
+ if let material = storage.remove(registrationID) { await material.cancelPreparation() }
29
+ }
30
+
31
+ static func registerContext(_ operationID: String, context: EncryptedUploadV2ProviderContext) throws {
32
+ try storage.registerContext(operationID, value: context)
33
+ }
34
+
35
+ static func removeContext(_ operationID: String) { storage.removeContext(operationID) }
36
+
18
37
  static func consume(
19
38
  _ registrationID: String,
20
39
  recording: EncryptedUploadV2Recording,
@@ -33,6 +52,10 @@ public enum BotaDeviceSDKEncryptedUploadV2Materials {
33
52
  }
34
53
 
35
54
  private final class BotaDeviceSDKEncryptedUploadV2MaterialStorage: @unchecked Sendable {
55
+ struct Context: Sendable {
56
+ let lease: UUID
57
+ let value: EncryptedUploadV2ProviderContext
58
+ }
36
59
  private enum RegistrationError: LocalizedError {
37
60
  case invalidSelection
38
61
  case unknownRegistration
@@ -49,6 +72,23 @@ private final class BotaDeviceSDKEncryptedUploadV2MaterialStorage: @unchecked Se
49
72
 
50
73
  private let lock = NSLock()
51
74
  private var materials: [String: EncryptedUploadV2Material] = [:]
75
+ private var contexts: [String: Context] = [:]
76
+
77
+ func registerContext(_ operationID: String, value: EncryptedUploadV2ProviderContext) throws {
78
+ try lock.withLock {
79
+ guard contexts[operationID] == nil else { throw RegistrationError.invalidSelection }
80
+ contexts[operationID] = Context(lease: UUID(), value: value)
81
+ }
82
+ }
83
+
84
+ func context(_ operationID: String) throws -> Context {
85
+ try lock.withLock {
86
+ guard let context = contexts[operationID] else { throw RegistrationError.unknownRegistration }
87
+ return context
88
+ }
89
+ }
90
+
91
+ func removeContext(_ operationID: String) { _ = lock.withLock { contexts.removeValue(forKey: operationID) } }
52
92
 
53
93
  func register(_ material: EncryptedUploadV2Material) -> String {
54
94
  let registrationID = UUID().uuidString.lowercased()
@@ -0,0 +1,49 @@
1
+ #import <BotaDeviceSDKSpec/BotaDeviceSDKSpec.h>
2
+ #if __has_include(<BotaDeviceSDK/BotaDeviceSDK-Swift.h>)
3
+ #import <BotaDeviceSDK/BotaDeviceSDK-Swift.h>
4
+ #else
5
+ #import "BotaDeviceSDK-Swift.h"
6
+ #endif
7
+
8
+ @interface BotaUploadV2Backend : NativeBotaUploadV2BackendSpecBase <NativeBotaUploadV2BackendSpec>
9
+ @property(nonatomic, strong) BotaUploadV2BackendBridge *backend;
10
+ @end
11
+
12
+ @implementation BotaUploadV2Backend
13
+ RCT_EXPORT_MODULE(BotaUploadV2Backend)
14
+ + (BOOL)requiresMainQueueSetup { return NO; }
15
+ - (instancetype)init {
16
+ if ((self = [super init])) { _backend = [BotaUploadV2BackendBridge new]; }
17
+ return self;
18
+ }
19
+ - (void)prepare:(NSString *)inputJSON resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
20
+ __weak BotaUploadV2Backend *weakSelf = self;
21
+ [_backend prepare:inputJSON credentialsRequested:^(NSString *requestId, NSString *operationId) {
22
+ [weakSelf emitOnCredentialsRequested:@{@"requestId": requestId, @"operationId": operationId}];
23
+ } completion:^(NSString *result, NSError *error) {
24
+ if (error) { reject(@"encrypted_upload_v2_backend_failed", error.localizedDescription, nil); }
25
+ else { resolve(result); }
26
+ }];
27
+ }
28
+ - (void)cancel:(NSString *)operationId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
29
+ [_backend cancel:operationId completion:^{ resolve(nil); }];
30
+ }
31
+ - (void)complete:(NSString *)operationId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
32
+ [_backend complete:operationId completion:^(NSError *error) {
33
+ if (error) { reject(@"encrypted_upload_v2_backend_failed", error.localizedDescription, nil); }
34
+ else { resolve(nil); }
35
+ }];
36
+ }
37
+ - (void)resolveCredentials:(NSString *)requestId token:(NSString *)token resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
38
+ [_backend resolveCredentials:requestId token:token completion:^{ resolve(nil); }];
39
+ }
40
+ - (void)rejectCredentials:(NSString *)requestId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
41
+ [_backend rejectCredentials:requestId completion:^{ resolve(nil); }];
42
+ }
43
+ - (void)invalidate { [_backend invalidate]; }
44
+ #if RCT_NEW_ARCH_ENABLED
45
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
46
+ return std::make_shared<facebook::react::NativeBotaUploadV2BackendSpecJSI>(params);
47
+ }
48
+ #endif
49
+ @end