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

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 (100) hide show
  1. package/README.md +66 -3
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidDiagnostics.kt +53 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogs.kt +16 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +144 -14
  6. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +150 -29
  7. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKEncryptedUploadV2Materials.kt +27 -0
  8. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +68 -3
  9. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidLogsTest.kt +33 -0
  10. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +135 -6
  11. package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingsTest.kt +236 -10
  12. package/generated/codegen-contract.json +495 -8
  13. package/ios/BotaDeviceSDK.mm +102 -0
  14. package/ios/BotaDeviceSDKAppleBridge.swift +126 -4
  15. package/ios/BotaDeviceSDKAppleDiagnostics.swift +40 -0
  16. package/ios/BotaDeviceSDKAppleLogs.swift +18 -0
  17. package/ios/BotaDeviceSDKAppleRecordingUploads.swift +138 -6
  18. package/ios/BotaDeviceSDKAppleRecordings.swift +113 -5
  19. package/ios/BotaDeviceSDKEncryptedUploadV2Materials.swift +40 -0
  20. package/lib/commonjs/BotaClient.js +13 -2
  21. package/lib/commonjs/BotaClient.js.map +1 -1
  22. package/lib/commonjs/ble/constants.js +5 -1
  23. package/lib/commonjs/ble/constants.js.map +1 -1
  24. package/lib/commonjs/ble/deviceDiagnostics.js +240 -0
  25. package/lib/commonjs/ble/deviceDiagnostics.js.map +1 -0
  26. package/lib/commonjs/client.js +163 -28
  27. package/lib/commonjs/client.js.map +1 -1
  28. package/lib/commonjs/index.js +15 -0
  29. package/lib/commonjs/index.js.map +1 -1
  30. package/lib/commonjs/managers/DeviceManager.js +8 -0
  31. package/lib/commonjs/managers/DeviceManager.js.map +1 -1
  32. package/lib/commonjs/managers/NativeUploadQueue.js +386 -0
  33. package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -0
  34. package/lib/commonjs/managers/RecordingManager.js +83 -102
  35. package/lib/commonjs/managers/RecordingManager.js.map +1 -1
  36. package/lib/commonjs/managers/uploadRecoveryMetadata.js +83 -0
  37. package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -0
  38. package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
  39. package/lib/module/BotaClient.js +13 -2
  40. package/lib/module/BotaClient.js.map +1 -1
  41. package/lib/module/ble/constants.js +4 -0
  42. package/lib/module/ble/constants.js.map +1 -1
  43. package/lib/module/ble/deviceDiagnostics.js +234 -0
  44. package/lib/module/ble/deviceDiagnostics.js.map +1 -0
  45. package/lib/module/client.js +163 -28
  46. package/lib/module/client.js.map +1 -1
  47. package/lib/module/index.js +2 -0
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/managers/DeviceManager.js +8 -0
  50. package/lib/module/managers/DeviceManager.js.map +1 -1
  51. package/lib/module/managers/NativeUploadQueue.js +380 -0
  52. package/lib/module/managers/NativeUploadQueue.js.map +1 -0
  53. package/lib/module/managers/RecordingManager.js +83 -102
  54. package/lib/module/managers/RecordingManager.js.map +1 -1
  55. package/lib/module/managers/uploadRecoveryMetadata.js +77 -0
  56. package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -0
  57. package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
  58. package/lib/typescript/BotaClient.d.ts.map +1 -1
  59. package/lib/typescript/ble/constants.d.ts +4 -0
  60. package/lib/typescript/ble/constants.d.ts.map +1 -1
  61. package/lib/typescript/ble/deviceDiagnostics.d.ts +10 -0
  62. package/lib/typescript/ble/deviceDiagnostics.d.ts.map +1 -0
  63. package/lib/typescript/client.d.ts +18 -2
  64. package/lib/typescript/client.d.ts.map +1 -1
  65. package/lib/typescript/index.d.ts +6 -4
  66. package/lib/typescript/index.d.ts.map +1 -1
  67. package/lib/typescript/managers/DeviceManager.d.ts +3 -1
  68. package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
  69. package/lib/typescript/managers/NativeUploadQueue.d.ts +41 -0
  70. package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -0
  71. package/lib/typescript/managers/RecordingManager.d.ts +3 -8
  72. package/lib/typescript/managers/RecordingManager.d.ts.map +1 -1
  73. package/lib/typescript/managers/types.d.ts +6 -1
  74. package/lib/typescript/managers/types.d.ts.map +1 -1
  75. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts +6 -0
  76. package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -0
  77. package/lib/typescript/models/Device.d.ts +42 -0
  78. package/lib/typescript/models/Device.d.ts.map +1 -1
  79. package/lib/typescript/models/Recording.d.ts +47 -0
  80. package/lib/typescript/models/Recording.d.ts.map +1 -1
  81. package/lib/typescript/models/Status.d.ts +5 -1
  82. package/lib/typescript/models/Status.d.ts.map +1 -1
  83. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +61 -0
  84. package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/scripts/bota_device_sdk_spm_workaround.rb +26 -15
  87. package/src/BotaClient.ts +13 -3
  88. package/src/ble/constants.ts +5 -0
  89. package/src/ble/deviceDiagnostics.ts +238 -0
  90. package/src/client.ts +166 -18
  91. package/src/index.ts +12 -0
  92. package/src/managers/DeviceManager.ts +11 -0
  93. package/src/managers/NativeUploadQueue.ts +330 -0
  94. package/src/managers/RecordingManager.ts +83 -116
  95. package/src/managers/types.ts +7 -1
  96. package/src/managers/uploadRecoveryMetadata.ts +59 -0
  97. package/src/models/Device.ts +43 -0
  98. package/src/models/Recording.ts +52 -0
  99. package/src/models/Status.ts +5 -1
  100. package/src/specs/NativeBotaDeviceSDK.ts +56 -0
@@ -569,6 +569,46 @@ RCT_EXPORT_MODULE(BotaDeviceSDK)
569
569
  }];
570
570
  }
571
571
 
572
+ - (void)listPendingRecordings:(JS::NativeBotaDeviceSDK::NativeConnectedDevice &)device
573
+ resolve:(RCTPromiseResolveBlock)resolve
574
+ reject:(RCTPromiseRejectBlock)reject
575
+ {
576
+ [[BotaDeviceSDKAppleBridge shared]
577
+ listPendingRecordingsWithID:device.id_()
578
+ serialNumber:device.serialNumber()
579
+ deviceType:device.deviceType()
580
+ firmwareVersion:device.firmwareVersion()
581
+ hardwareRevision:device.hardwareRevision()
582
+ isProvisioned:device.isProvisioned()
583
+ connectionState:device.connectionState()
584
+ mtu:device.mtu()
585
+ completion:^(NSArray<NSDictionary *> *_Nullable values, NSError *_Nullable error) {
586
+ if (error != nil) { BotaRejectEncryptedUploadV2Error(reject); return; }
587
+ resolve(values);
588
+ }];
589
+ }
590
+
591
+ - (void)cancelEncryptedRecordingV2:(NSString *)operationId
592
+ resolve:(RCTPromiseResolveBlock)resolve
593
+ reject:(RCTPromiseRejectBlock)reject
594
+ {
595
+ [[BotaDeviceSDKAppleBridge shared]
596
+ cancelEncryptedRecordingV2WithOperationID:operationId
597
+ completion:^(NSError *_Nullable error) {
598
+ if (error != nil) { BotaRejectEncryptedUploadV2Error(reject); return; }
599
+ resolve(nil);
600
+ }];
601
+ }
602
+
603
+ - (void)releaseEncryptedUploadV2Material:(NSString *)materialRegistrationId
604
+ resolve:(RCTPromiseResolveBlock)resolve
605
+ reject:(RCTPromiseRejectBlock)reject
606
+ {
607
+ [[BotaDeviceSDKAppleBridge shared]
608
+ releaseEncryptedUploadV2MaterialWithRegistrationID:materialRegistrationId
609
+ completion:^{ resolve(nil); }];
610
+ }
611
+
572
612
  - (void)syncRecording:(JS::NativeBotaDeviceSDK::NativeConnectedDevice &)device
573
613
  recording:(JS::NativeBotaDeviceSDK::NativeDeviceRecording &)recording
574
614
  sinkId:(NSString *)sinkId
@@ -626,6 +666,10 @@ RCT_EXPORT_MODULE(BotaDeviceSDK)
626
666
  generation:recording.generation()
627
667
  ciphertextLength:recording.ciphertextLength()
628
668
  ciphertextSHA256:recording.ciphertextSha256()
669
+ startedAtMs:recording.startedAtMs()
670
+ durationMs:recording.durationMs()
671
+ plaintextLength:recording.plaintextLength()
672
+ storageFormat:recording.storageFormat().has_value() ? @(recording.storageFormat().value()) : nil
629
673
  operationID:operationId
630
674
  onProfileRequest:^(NSDictionary *request) {
631
675
  [weakSelf emitOnEncryptedUploadV2ProfileRequested:request];
@@ -807,6 +851,7 @@ RCT_EXPORT_MODULE(BotaDeviceSDK)
807
851
  uploadRecordingFileWithTaskID:request.taskId()
808
852
  recordingID:request.recordingId()
809
853
  localPath:request.localPath()
854
+ fileSizeBytes:request.fileSizeBytes().has_value() ? @(request.fileSizeBytes().value()) : nil
810
855
  uploadURL:request.uploadUrl()
811
856
  uploadToken:request.uploadToken()
812
857
  completeURL:request.completeUrl()
@@ -826,6 +871,23 @@ RCT_EXPORT_MODULE(BotaDeviceSDK)
826
871
  }];
827
872
  }
828
873
 
874
+ - (void)releaseRecordingFile:(NSString *)taskId
875
+ localPath:(NSString *)localPath
876
+ resolve:(RCTPromiseResolveBlock)resolve
877
+ reject:(RCTPromiseRejectBlock)reject
878
+ {
879
+ [[BotaDeviceSDKAppleBridge shared]
880
+ releaseRecordingFileWithTaskID:taskId
881
+ localPath:localPath
882
+ completion:^(NSError *error) {
883
+ if (error != nil) {
884
+ BotaRejectAppleError(error, reject);
885
+ return;
886
+ }
887
+ resolve(nil);
888
+ }];
889
+ }
890
+
829
891
  - (void)cancelRecordingUpload:(NSString *)taskId
830
892
  resolve:(RCTPromiseResolveBlock)resolve
831
893
  reject:(__unused RCTPromiseRejectBlock)reject
@@ -966,6 +1028,46 @@ RCT_EXPORT_MODULE(BotaDeviceSDK)
966
1028
  }];
967
1029
  }
968
1030
 
1031
+ - (void)readDiagnosticEvents:(JS::NativeBotaDeviceSDK::NativeConnectedDevice &)device
1032
+ resolve:(RCTPromiseResolveBlock)resolve
1033
+ reject:(RCTPromiseRejectBlock)reject
1034
+ {
1035
+ [[BotaDeviceSDKAppleBridge shared]
1036
+ readDiagnosticEventsWithID:device.id_()
1037
+ serialNumber:device.serialNumber()
1038
+ deviceType:device.deviceType()
1039
+ firmwareVersion:device.firmwareVersion()
1040
+ hardwareRevision:device.hardwareRevision()
1041
+ isProvisioned:device.isProvisioned()
1042
+ connectionState:device.connectionState()
1043
+ mtu:device.mtu()
1044
+ completion:^(NSDictionary *_Nullable batch, NSError *_Nullable error) {
1045
+ if (error != nil) { BotaRejectAppleError(error, reject); return; }
1046
+ resolve(batch);
1047
+ }];
1048
+ }
1049
+
1050
+ - (void)acknowledgeDiagnosticEvents:(JS::NativeBotaDeviceSDK::NativeConnectedDevice &)device
1051
+ acceptedEventIds:(NSArray *)acceptedEventIds
1052
+ resolve:(RCTPromiseResolveBlock)resolve
1053
+ reject:(RCTPromiseRejectBlock)reject
1054
+ {
1055
+ [[BotaDeviceSDKAppleBridge shared]
1056
+ acknowledgeDiagnosticEventsWithID:device.id_()
1057
+ serialNumber:device.serialNumber()
1058
+ deviceType:device.deviceType()
1059
+ firmwareVersion:device.firmwareVersion()
1060
+ hardwareRevision:device.hardwareRevision()
1061
+ isProvisioned:device.isProvisioned()
1062
+ connectionState:device.connectionState()
1063
+ mtu:device.mtu()
1064
+ acceptedEventIds:acceptedEventIds
1065
+ completion:^(NSError *_Nullable error) {
1066
+ if (error != nil) { BotaRejectAppleError(error, reject); return; }
1067
+ resolve(nil);
1068
+ }];
1069
+ }
1070
+
969
1071
  - (void)stopDeviceLogs:(RCTPromiseResolveBlock)resolve
970
1072
  reject:(__unused RCTPromiseRejectBlock)reject
971
1073
  {
@@ -801,6 +801,48 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
801
801
  }
802
802
  }
803
803
 
804
+ @objc(listPendingRecordingsWithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:completion:)
805
+ public func listPendingRecordings(
806
+ id: String, serialNumber: String, deviceType: String, firmwareVersion: String,
807
+ hardwareRevision: String?, isProvisioned: Bool, connectionState: String, mtu: Double,
808
+ completion: @escaping @Sendable ([[String: Any]]?, NSError?) -> Void
809
+ ) {
810
+ Task {
811
+ do {
812
+ let values = try await recordings.listPendingRecordings(Self.connectedDevice(
813
+ id: id, serialNumber: serialNumber, deviceType: deviceType,
814
+ firmwareVersion: firmwareVersion, hardwareRevision: hardwareRevision,
815
+ isProvisioned: isProvisioned, connectionState: connectionState, mtu: mtu
816
+ ))
817
+ completion(values.map { value in
818
+ switch value {
819
+ case let .legacy(recording):
820
+ ["profile": "legacy", "legacy": Self.recording(recording)]
821
+ case let .encryptedV2(recording):
822
+ ["profile": "encrypted_upload_v2", "encrypted": BotaDeviceSDKAppleRecordings.encryptedUploadV2Recording(recording)]
823
+ }
824
+ }, nil)
825
+ } catch { completion(nil, error as NSError) }
826
+ }
827
+ }
828
+
829
+ @objc(cancelEncryptedRecordingV2WithOperationID:completion:)
830
+ public func cancelEncryptedRecordingV2(
831
+ operationID: String, completion: @escaping @Sendable (NSError?) -> Void
832
+ ) {
833
+ Task {
834
+ do { try await recordings.cancelEncryptedRecordingV2(operationID); completion(nil) }
835
+ catch { completion(error as NSError) }
836
+ }
837
+ }
838
+
839
+ @objc(releaseEncryptedUploadV2MaterialWithRegistrationID:completion:)
840
+ public func releaseEncryptedUploadV2Material(
841
+ registrationID: String, completion: @escaping @Sendable () -> Void
842
+ ) {
843
+ Task { await BotaDeviceSDKEncryptedUploadV2Materials.release(registrationID); completion() }
844
+ }
845
+
804
846
  @objc(syncRecordingWithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:recordingUUID:startedAtMilliseconds:durationMilliseconds:fileSize:codec:isEncrypted:sinkID:onProgress:completion:)
805
847
  public func syncRecording(
806
848
  id: String,
@@ -855,6 +897,7 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
855
897
  var value: [String: Any] = [
856
898
  "localPath": result.localPath,
857
899
  "e2eEncrypted": result.isE2EEncrypted,
900
+ "fileSizeBytes": result.fileSizeBytes,
858
901
  ]
859
902
  if let contentSHA256Hex = result.contentSHA256Hex {
860
903
  value["contentSha256"] = contentSHA256Hex
@@ -866,7 +909,7 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
866
909
  }
867
910
  }
868
911
 
869
- @objc(syncEncryptedRecordingV2WithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:recordingUUID:generation:ciphertextLength:ciphertextSHA256:operationID:onProfileRequest:onProgress:completion:)
912
+ @objc(syncEncryptedRecordingV2WithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:recordingUUID:generation:ciphertextLength:ciphertextSHA256:startedAtMs:durationMs:plaintextLength:storageFormat:operationID:onProfileRequest:onProgress:completion:)
870
913
  public func syncEncryptedRecordingV2(
871
914
  id: String,
872
915
  serialNumber: String,
@@ -880,6 +923,10 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
880
923
  generation: Double,
881
924
  ciphertextLength: String,
882
925
  ciphertextSHA256: String,
926
+ startedAtMs: String?,
927
+ durationMs: String?,
928
+ plaintextLength: String?,
929
+ storageFormat: NSNumber?,
883
930
  operationID: String,
884
931
  onProfileRequest: @escaping @Sendable ([String: Any]) -> Void,
885
932
  onProgress: @escaping @Sendable ([String: Any]) -> Void,
@@ -895,6 +942,9 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
895
942
  throw BotaDeviceSDKAppleBridgeInputError.invalidUnsignedInteger
896
943
  }
897
944
  let digest = try Self.sha256Data(ciphertextSHA256)
945
+ guard let format = UInt8(exactly: try Self.unsignedInteger(storageFormat?.doubleValue ?? 3)) else {
946
+ throw BotaDeviceSDKAppleBridgeInputError.invalidUnsignedInteger
947
+ }
898
948
  try await recordings.syncEncryptedRecordingV2(
899
949
  Self.connectedDevice(
900
950
  id: id,
@@ -910,7 +960,11 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
910
960
  uuid: recordingUUID,
911
961
  generation: generationValue,
912
962
  ciphertextLength: length,
913
- ciphertextSHA256: digest
963
+ ciphertextSHA256: digest,
964
+ startedAtMs: try Self.catalogDecimal(startedAtMs),
965
+ durationMs: try Self.catalogDecimal(durationMs),
966
+ plaintextLength: try Self.catalogDecimal(plaintextLength),
967
+ storageFormat: format
914
968
  ),
915
969
  operationID: operationID,
916
970
  onProfileRequest: onProfileRequest,
@@ -923,6 +977,14 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
923
977
  }
924
978
  }
925
979
 
980
+ private static func catalogDecimal(_ text: String?) throws -> UInt64 {
981
+ guard let text else { return 0 }
982
+ guard let value = UInt64(text), String(value) == text else {
983
+ throw BotaDeviceSDKAppleBridgeInputError.invalidUnsignedInteger
984
+ }
985
+ return value
986
+ }
987
+
926
988
  @objc(resolveEncryptedUploadV2ProfileWithRequestID:profile:uploadSessionID:ownerRevision:securityPolicy:materialRegistrationID:completion:)
927
989
  public func resolveEncryptedUploadV2Profile(
928
990
  requestID: String,
@@ -1126,11 +1188,12 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
1126
1188
  }
1127
1189
  }
1128
1190
 
1129
- @objc(uploadRecordingFileWithTaskID:recordingID:localPath:uploadURL:uploadToken:completeURL:contentType:contentSHA256:relayURL:relayBearerToken:onProgress:completion:)
1191
+ @objc(uploadRecordingFileWithTaskID:recordingID:localPath:fileSizeBytes:uploadURL:uploadToken:completeURL:contentType:contentSHA256:relayURL:relayBearerToken:onProgress:completion:)
1130
1192
  public func uploadRecordingFile(
1131
1193
  taskID: String,
1132
1194
  recordingID: String,
1133
1195
  localPath: String,
1196
+ fileSizeBytes: NSNumber?,
1134
1197
  uploadURL: String,
1135
1198
  uploadToken: String?,
1136
1199
  completeURL: String?,
@@ -1143,6 +1206,11 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
1143
1206
  ) {
1144
1207
  Task {
1145
1208
  do {
1209
+ let expectedSize = try fileSizeBytes.map { value -> Int64 in
1210
+ let integer = try Self.unsignedInteger(value.doubleValue)
1211
+ guard integer <= UInt64(Int64.max) else { throw CocoaError(.fileReadCorruptFile) }
1212
+ return Int64(integer)
1213
+ }
1146
1214
  try await recordingUploads.upload(.init(
1147
1215
  taskID: taskID,
1148
1216
  recordingID: recordingID,
@@ -1153,7 +1221,8 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
1153
1221
  contentType: contentType,
1154
1222
  contentSHA256: contentSHA256,
1155
1223
  relayURL: relayURL,
1156
- relayBearerToken: relayBearerToken
1224
+ relayBearerToken: relayBearerToken,
1225
+ fileSizeBytes: expectedSize
1157
1226
  )) { progress in
1158
1227
  onProgress([
1159
1228
  "taskId": progress.taskID,
@@ -1168,6 +1237,22 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
1168
1237
  }
1169
1238
  }
1170
1239
 
1240
+ @objc(releaseRecordingFileWithTaskID:localPath:completion:)
1241
+ public func releaseRecordingFile(
1242
+ taskID: String,
1243
+ localPath: String,
1244
+ completion: @escaping @Sendable (NSError?) -> Void
1245
+ ) {
1246
+ Task {
1247
+ do {
1248
+ try await recordingUploads.release(taskID: taskID, localPath: localPath)
1249
+ completion(nil)
1250
+ } catch {
1251
+ completion(error as NSError)
1252
+ }
1253
+ }
1254
+ }
1255
+
1171
1256
  @objc(cancelRecordingUploadWithTaskID:completion:)
1172
1257
  public func cancelRecordingUpload(
1173
1258
  taskID: String,
@@ -1359,6 +1444,43 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
1359
1444
  }
1360
1445
  }
1361
1446
 
1447
+ @objc(readDiagnosticEventsWithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:completion:)
1448
+ public func readDiagnosticEvents(
1449
+ id: String, serialNumber: String, deviceType: String, firmwareVersion: String,
1450
+ hardwareRevision: String?, isProvisioned: Bool, connectionState: String, mtu: Double,
1451
+ completion: @escaping @Sendable ([String: Any]?, NSError?) -> Void
1452
+ ) {
1453
+ Task {
1454
+ do {
1455
+ let device = try Self.connectedDevice(
1456
+ id: id, serialNumber: serialNumber, deviceType: deviceType,
1457
+ firmwareVersion: firmwareVersion, hardwareRevision: hardwareRevision,
1458
+ isProvisioned: isProvisioned, connectionState: connectionState, mtu: mtu
1459
+ )
1460
+ completion(diagnosticBatchPayload(try await logs.readDiagnosticEvents(device)), nil)
1461
+ } catch { completion(nil, error as NSError) }
1462
+ }
1463
+ }
1464
+
1465
+ @objc(acknowledgeDiagnosticEventsWithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:acceptedEventIds:completion:)
1466
+ public func acknowledgeDiagnosticEvents(
1467
+ id: String, serialNumber: String, deviceType: String, firmwareVersion: String,
1468
+ hardwareRevision: String?, isProvisioned: Bool, connectionState: String, mtu: Double,
1469
+ acceptedEventIds: [String], completion: @escaping @Sendable (NSError?) -> Void
1470
+ ) {
1471
+ Task {
1472
+ do {
1473
+ let device = try Self.connectedDevice(
1474
+ id: id, serialNumber: serialNumber, deviceType: deviceType,
1475
+ firmwareVersion: firmwareVersion, hardwareRevision: hardwareRevision,
1476
+ isProvisioned: isProvisioned, connectionState: connectionState, mtu: mtu
1477
+ )
1478
+ try await logs.acknowledgeDiagnosticEvents(device, acceptedEventIds: acceptedEventIds)
1479
+ completion(nil)
1480
+ } catch { completion(error as NSError) }
1481
+ }
1482
+ }
1483
+
1362
1484
  @objc(stopDeviceLogsWithCompletion:)
1363
1485
  public func stopDeviceLogs(completion: @escaping @Sendable () -> Void) {
1364
1486
  Task {
@@ -0,0 +1,40 @@
1
+ import BotaAppSDK
2
+ import Foundation
3
+
4
+ func diagnosticBatchPayload(_ batch: DeviceDiagnosticsBatch) -> [String: Any] {
5
+ ["schema_version": batch.schemaVersion, "events": batch.events.map(diagnosticEventPayload)]
6
+ }
7
+
8
+ private func diagnosticEventPayload(_ event: DeviceDiagnosticEvent) -> [String: Any] {
9
+ var payload: [String: Any] = [
10
+ "event_id": event.eventId, "event_type": event.eventType,
11
+ "reason_code": event.reasonCode, "uptime_ms": event.uptimeMs,
12
+ "signature": event.signature, "firmware_build_id": event.firmwareBuildId,
13
+ "subsystem": event.subsystem, "state_before_event": event.stateBeforeEvent,
14
+ ]
15
+ if let report = event.report {
16
+ var execution: [String: Any] = ["pc_trace": report.execution.pcTrace]
17
+ execution["task"] = report.execution.task
18
+ execution["reti"] = report.execution.reti
19
+ execution["rets"] = report.execution.rets
20
+ var value: [String: Any] = [
21
+ "fault": [
22
+ "cpu_id": report.fault.cpuId, "cpu_emu": report.fault.cpuEmu,
23
+ "core_emu": report.fault.coreEmu, "hsb_emu": report.fault.hsbEmu,
24
+ "audio_emu": report.fault.audioEmu, "wireless_emu": report.fault.wirelessEmu,
25
+ ],
26
+ "execution": execution,
27
+ ]
28
+ if let runtime = report.runtime {
29
+ var fields: [String: Any] = [:]
30
+ fields["heap_free_bytes"] = runtime.heapFreeBytes
31
+ fields["task_stack_remaining_bytes"] = runtime.taskStackRemainingBytes
32
+ value["runtime"] = fields
33
+ }
34
+ if let breadcrumbs = report.breadcrumbs {
35
+ value["breadcrumbs"] = breadcrumbs.map { ["delta_ms": $0.deltaMs, "code": $0.code, "arg0": $0.arg0] as [String: Any] }
36
+ }
37
+ payload["report"] = value
38
+ }
39
+ return payload
40
+ }
@@ -2,6 +2,8 @@ import BotaAppSDK
2
2
  import Foundation
3
3
 
4
4
  protocol BotaDeviceSDKAppleLogClient: Sendable {
5
+ func readDiagnosticEvents(_ device: ConnectedDevice) async throws -> DeviceDiagnosticsBatch
6
+ func acknowledgeDiagnosticEvents(_ device: ConnectedDevice, acceptedEventIds: [String]) async throws
5
7
  func streamLogs(
6
8
  _ device: ConnectedDevice
7
9
  ) async throws -> AsyncThrowingStream<DeviceLogLine, Error>
@@ -24,6 +26,14 @@ struct BotaDeviceSDKSharedAppleLogClient: BotaDeviceSDKAppleLogClient {
24
26
  func stop() async throws {
25
27
  try await logs.stop()
26
28
  }
29
+
30
+ func readDiagnosticEvents(_ device: ConnectedDevice) async throws -> DeviceDiagnosticsBatch {
31
+ try await logs.readDiagnosticEvents(device)
32
+ }
33
+
34
+ func acknowledgeDiagnosticEvents(_ device: ConnectedDevice, acceptedEventIds: [String]) async throws {
35
+ try await logs.acknowledgeDiagnosticEvents(device, acceptedEventIds: acceptedEventIds)
36
+ }
27
37
  }
28
38
 
29
39
  actor BotaDeviceSDKAppleLogs {
@@ -39,6 +49,14 @@ actor BotaDeviceSDKAppleLogs {
39
49
  self.client = client
40
50
  }
41
51
 
52
+ func readDiagnosticEvents(_ device: ConnectedDevice) async throws -> DeviceDiagnosticsBatch {
53
+ try await client.readDiagnosticEvents(device)
54
+ }
55
+
56
+ func acknowledgeDiagnosticEvents(_ device: ConnectedDevice, acceptedEventIds: [String]) async throws {
57
+ try await client.acknowledgeDiagnosticEvents(device, acceptedEventIds: acceptedEventIds)
58
+ }
59
+
42
60
  func start(
43
61
  _ device: ConnectedDevice,
44
62
  onLine: @escaping @Sendable (DeviceLogLine) -> Void,
@@ -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
  }