@bota.dev/react-native-app-sdk 2.0.0-beta.13 → 2.0.0-beta.15
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/README.md +49 -7
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploads.kt +2 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordings.kt +3 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKModule.kt +1 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +37 -3
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/ByoBridgeDriver.kt +79 -0
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +30 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +5 -2
- package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadStreamingStatus.kt +67 -0
- package/android/src/test/java/dev/bota/sdk/reactnative/BotaDeviceSDKAndroidRecordingUploadsTest.kt +23 -0
- package/generated/codegen-contract.json +110 -8
- package/ios/BotaDeviceSDK.mm +1 -0
- package/ios/BotaDeviceSDKAppleBridge.swift +4 -2
- package/ios/BotaDeviceSDKAppleRecordingUploads.swift +3 -3
- package/ios/BotaDeviceSDKAppleRecordings.swift +1 -0
- package/ios/BotaUploadV2Backend.mm +21 -0
- package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +52 -2
- package/ios/UploadBackend/ByoBridgeDriver.swift +94 -0
- package/ios/UploadBackend/UploadModels.swift +6 -1
- package/ios/UploadBackend/UploadOperation.swift +41 -3
- package/ios/UploadBackend/UploadStreamingStatus.swift +43 -0
- package/lib/commonjs/byoStorageBackend.js +144 -0
- package/lib/commonjs/byoStorageBackend.js.map +1 -0
- package/lib/commonjs/client.js +2 -1
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/encryptedUploadV2Backend.js +4 -1
- package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -1
- package/lib/commonjs/index.js +7 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/managers/DeviceManager.js +6 -0
- package/lib/commonjs/managers/DeviceManager.js.map +1 -1
- package/lib/commonjs/managers/NativeUploadQueue.js +22 -8
- package/lib/commonjs/managers/NativeUploadQueue.js.map +1 -1
- package/lib/commonjs/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/commonjs/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/commonjs/managers/uploadVerificationPending.js +20 -0
- package/lib/commonjs/managers/uploadVerificationPending.js.map +1 -0
- package/lib/commonjs/protectedStreamingStatus.js +120 -0
- package/lib/commonjs/protectedStreamingStatus.js.map +1 -0
- package/lib/commonjs/sdkIdentity.js +1 -1
- package/lib/commonjs/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -1
- package/lib/commonjs/uploadRecovery.js +50 -7
- package/lib/commonjs/uploadRecovery.js.map +1 -1
- package/lib/module/byoStorageBackend.js +140 -0
- package/lib/module/byoStorageBackend.js.map +1 -0
- package/lib/module/client.js +2 -1
- package/lib/module/client.js.map +1 -1
- package/lib/module/encryptedUploadV2Backend.js +4 -1
- package/lib/module/encryptedUploadV2Backend.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/managers/DeviceManager.js +6 -0
- package/lib/module/managers/DeviceManager.js.map +1 -1
- package/lib/module/managers/NativeUploadQueue.js +22 -8
- package/lib/module/managers/NativeUploadQueue.js.map +1 -1
- package/lib/module/managers/uploadRecoveryMetadata.js +4 -0
- package/lib/module/managers/uploadRecoveryMetadata.js.map +1 -1
- package/lib/module/managers/uploadVerificationPending.js +14 -0
- package/lib/module/managers/uploadVerificationPending.js.map +1 -0
- package/lib/module/protectedStreamingStatus.js +116 -0
- package/lib/module/protectedStreamingStatus.js.map +1 -0
- package/lib/module/sdkIdentity.js +1 -1
- package/lib/module/specs/NativeBotaDeviceSDK.js.map +1 -1
- package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -1
- package/lib/module/uploadRecovery.js +50 -7
- package/lib/module/uploadRecovery.js.map +1 -1
- package/lib/typescript/byoStorageBackend.d.ts +36 -0
- package/lib/typescript/byoStorageBackend.d.ts.map +1 -0
- package/lib/typescript/client.d.ts.map +1 -1
- package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/managers/DeviceManager.d.ts +2 -0
- package/lib/typescript/managers/DeviceManager.d.ts.map +1 -1
- package/lib/typescript/managers/NativeUploadQueue.d.ts.map +1 -1
- package/lib/typescript/managers/uploadRecoveryMetadata.d.ts.map +1 -1
- package/lib/typescript/managers/uploadVerificationPending.d.ts +6 -0
- package/lib/typescript/managers/uploadVerificationPending.d.ts.map +1 -0
- package/lib/typescript/models/Recording.d.ts +6 -0
- package/lib/typescript/models/Recording.d.ts.map +1 -1
- package/lib/typescript/protectedStreamingStatus.d.ts +37 -0
- package/lib/typescript/protectedStreamingStatus.d.ts.map +1 -0
- package/lib/typescript/sdkIdentity.d.ts +1 -1
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts +1 -0
- package/lib/typescript/specs/NativeBotaDeviceSDK.d.ts.map +1 -1
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +4 -0
- package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -1
- package/lib/typescript/uploadRecovery.d.ts +1 -1
- package/lib/typescript/uploadRecovery.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/byoStorageBackend.ts +104 -0
- package/src/client.ts +2 -0
- package/src/encryptedUploadV2Backend.ts +2 -1
- package/src/index.ts +11 -0
- package/src/managers/DeviceManager.ts +6 -0
- package/src/managers/NativeUploadQueue.ts +18 -9
- package/src/managers/uploadRecoveryMetadata.ts +2 -0
- package/src/managers/uploadVerificationPending.ts +10 -0
- package/src/models/Recording.ts +6 -0
- package/src/protectedStreamingStatus.ts +137 -0
- package/src/sdkIdentity.ts +1 -1
- package/src/specs/NativeBotaDeviceSDK.ts +1 -0
- package/src/specs/NativeBotaUploadV2Backend.ts +4 -0
- package/src/uploadRecovery.ts +42 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@bota.dev/react-native-app-sdk",
|
|
4
|
-
"packageVersion": "2.0.0-beta.
|
|
4
|
+
"packageVersion": "2.0.0-beta.15",
|
|
5
5
|
"reactNativeVersion": "0.86.3",
|
|
6
6
|
"codegenLibrary": "BotaDeviceSDKSpec",
|
|
7
7
|
"nativeModule": "BotaDeviceSDK",
|
|
@@ -1216,6 +1216,13 @@
|
|
|
1216
1216
|
"typeAnnotation": {
|
|
1217
1217
|
"type": "NumberTypeAnnotation"
|
|
1218
1218
|
}
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
"name": "markersRequired",
|
|
1222
|
+
"optional": true,
|
|
1223
|
+
"typeAnnotation": {
|
|
1224
|
+
"type": "BooleanTypeAnnotation"
|
|
1225
|
+
}
|
|
1219
1226
|
}
|
|
1220
1227
|
]
|
|
1221
1228
|
},
|
|
@@ -4143,6 +4150,101 @@
|
|
|
4143
4150
|
}
|
|
4144
4151
|
],
|
|
4145
4152
|
"methods": [
|
|
4153
|
+
{
|
|
4154
|
+
"name": "syncByo",
|
|
4155
|
+
"optional": false,
|
|
4156
|
+
"typeAnnotation": {
|
|
4157
|
+
"type": "FunctionTypeAnnotation",
|
|
4158
|
+
"returnTypeAnnotation": {
|
|
4159
|
+
"type": "PromiseTypeAnnotation",
|
|
4160
|
+
"elementType": {
|
|
4161
|
+
"type": "StringTypeAnnotation"
|
|
4162
|
+
}
|
|
4163
|
+
},
|
|
4164
|
+
"params": [
|
|
4165
|
+
{
|
|
4166
|
+
"name": "inputJSON",
|
|
4167
|
+
"optional": false,
|
|
4168
|
+
"typeAnnotation": {
|
|
4169
|
+
"type": "StringTypeAnnotation"
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
4172
|
+
]
|
|
4173
|
+
}
|
|
4174
|
+
},
|
|
4175
|
+
{
|
|
4176
|
+
"name": "cancelByo",
|
|
4177
|
+
"optional": false,
|
|
4178
|
+
"typeAnnotation": {
|
|
4179
|
+
"type": "FunctionTypeAnnotation",
|
|
4180
|
+
"returnTypeAnnotation": {
|
|
4181
|
+
"type": "PromiseTypeAnnotation",
|
|
4182
|
+
"elementType": {
|
|
4183
|
+
"type": "VoidTypeAnnotation"
|
|
4184
|
+
}
|
|
4185
|
+
},
|
|
4186
|
+
"params": [
|
|
4187
|
+
{
|
|
4188
|
+
"name": "operationId",
|
|
4189
|
+
"optional": false,
|
|
4190
|
+
"typeAnnotation": {
|
|
4191
|
+
"type": "StringTypeAnnotation"
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
]
|
|
4195
|
+
}
|
|
4196
|
+
},
|
|
4197
|
+
{
|
|
4198
|
+
"name": "readProtectedStreamingStatus",
|
|
4199
|
+
"optional": false,
|
|
4200
|
+
"typeAnnotation": {
|
|
4201
|
+
"type": "FunctionTypeAnnotation",
|
|
4202
|
+
"returnTypeAnnotation": {
|
|
4203
|
+
"type": "PromiseTypeAnnotation",
|
|
4204
|
+
"elementType": {
|
|
4205
|
+
"type": "StringTypeAnnotation"
|
|
4206
|
+
}
|
|
4207
|
+
},
|
|
4208
|
+
"params": [
|
|
4209
|
+
{
|
|
4210
|
+
"name": "requestId",
|
|
4211
|
+
"optional": false,
|
|
4212
|
+
"typeAnnotation": {
|
|
4213
|
+
"type": "StringTypeAnnotation"
|
|
4214
|
+
}
|
|
4215
|
+
},
|
|
4216
|
+
{
|
|
4217
|
+
"name": "inputJSON",
|
|
4218
|
+
"optional": false,
|
|
4219
|
+
"typeAnnotation": {
|
|
4220
|
+
"type": "StringTypeAnnotation"
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
]
|
|
4224
|
+
}
|
|
4225
|
+
},
|
|
4226
|
+
{
|
|
4227
|
+
"name": "cancelProtectedStreamingStatus",
|
|
4228
|
+
"optional": false,
|
|
4229
|
+
"typeAnnotation": {
|
|
4230
|
+
"type": "FunctionTypeAnnotation",
|
|
4231
|
+
"returnTypeAnnotation": {
|
|
4232
|
+
"type": "PromiseTypeAnnotation",
|
|
4233
|
+
"elementType": {
|
|
4234
|
+
"type": "VoidTypeAnnotation"
|
|
4235
|
+
}
|
|
4236
|
+
},
|
|
4237
|
+
"params": [
|
|
4238
|
+
{
|
|
4239
|
+
"name": "requestId",
|
|
4240
|
+
"optional": false,
|
|
4241
|
+
"typeAnnotation": {
|
|
4242
|
+
"type": "StringTypeAnnotation"
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
]
|
|
4246
|
+
}
|
|
4247
|
+
},
|
|
4146
4248
|
{
|
|
4147
4249
|
"name": "prepare",
|
|
4148
4250
|
"optional": false,
|
|
@@ -4274,11 +4376,11 @@
|
|
|
4274
4376
|
},
|
|
4275
4377
|
{
|
|
4276
4378
|
"path": "java/dev/bota/sdk/reactnative/NativeBotaUploadV2BackendSpec.java",
|
|
4277
|
-
"sha256": "
|
|
4379
|
+
"sha256": "a6b48b64cb3531d04b204172456115c041025d18db612d836174ced2617d50fe"
|
|
4278
4380
|
},
|
|
4279
4381
|
{
|
|
4280
4382
|
"path": "jni/BotaDeviceSDKSpec-generated.cpp",
|
|
4281
|
-
"sha256": "
|
|
4383
|
+
"sha256": "c98c66a2a320b56be0f7106e35a867d4664e6c8029836c153443cea4e1dd38ce"
|
|
4282
4384
|
},
|
|
4283
4385
|
{
|
|
4284
4386
|
"path": "jni/BotaDeviceSDKSpec.h",
|
|
@@ -4290,23 +4392,23 @@
|
|
|
4290
4392
|
},
|
|
4291
4393
|
{
|
|
4292
4394
|
"path": "jni/react/renderer/components/BotaDeviceSDKSpec/BotaDeviceSDKSpecJSI.h",
|
|
4293
|
-
"sha256": "
|
|
4395
|
+
"sha256": "6c3ab34b33b2e18b2237e389bbca5f813aa679c3ec66e61441ac8fea4a1904a5"
|
|
4294
4396
|
}
|
|
4295
4397
|
],
|
|
4296
4398
|
"ios": [
|
|
4297
4399
|
{
|
|
4298
4400
|
"path": "BotaDeviceSDKSpec/BotaDeviceSDKSpec-generated.mm",
|
|
4299
|
-
"sha256": "
|
|
4401
|
+
"sha256": "23bd8488e1e6a59795ec1845d7d6287731e7924e3211655899a58992efa3f6e1"
|
|
4300
4402
|
},
|
|
4301
4403
|
{
|
|
4302
4404
|
"path": "BotaDeviceSDKSpec/BotaDeviceSDKSpec.h",
|
|
4303
|
-
"sha256": "
|
|
4405
|
+
"sha256": "221da1ba25df2b6c8bac02a90fccdb701dc3644c0bfd9df31c75c117a2f9b47c"
|
|
4304
4406
|
},
|
|
4305
4407
|
{
|
|
4306
4408
|
"path": "BotaDeviceSDKSpecJSI.h",
|
|
4307
|
-
"sha256": "
|
|
4409
|
+
"sha256": "6c3ab34b33b2e18b2237e389bbca5f813aa679c3ec66e61441ac8fea4a1904a5"
|
|
4308
4410
|
}
|
|
4309
4411
|
]
|
|
4310
4412
|
},
|
|
4311
|
-
"contractDigest": "
|
|
4413
|
+
"contractDigest": "7b58a71c032bb3c46157b5c52ffc41a655af87186e5a9cd812e8330a93fed844"
|
|
4312
4414
|
}
|
package/ios/BotaDeviceSDK.mm
CHANGED
|
@@ -670,6 +670,7 @@ RCT_EXPORT_MODULE(BotaDeviceSDK)
|
|
|
670
670
|
durationMs:recording.durationMs()
|
|
671
671
|
plaintextLength:recording.plaintextLength()
|
|
672
672
|
storageFormat:recording.storageFormat().has_value() ? @(recording.storageFormat().value()) : nil
|
|
673
|
+
markersRequired:recording.markersRequired().value_or(false)
|
|
673
674
|
operationID:operationId
|
|
674
675
|
onProfileRequest:^(NSDictionary *request) {
|
|
675
676
|
[weakSelf emitOnEncryptedUploadV2ProfileRequested:request];
|
|
@@ -909,7 +909,7 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
|
|
|
909
909
|
}
|
|
910
910
|
}
|
|
911
911
|
|
|
912
|
-
@objc(syncEncryptedRecordingV2WithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:recordingUUID:generation:ciphertextLength:ciphertextSHA256:startedAtMs:durationMs:plaintextLength:storageFormat:operationID:onProfileRequest:onProgress:completion:)
|
|
912
|
+
@objc(syncEncryptedRecordingV2WithID:serialNumber:deviceType:firmwareVersion:hardwareRevision:isProvisioned:connectionState:mtu:recordingUUID:generation:ciphertextLength:ciphertextSHA256:startedAtMs:durationMs:plaintextLength:storageFormat:markersRequired:operationID:onProfileRequest:onProgress:completion:)
|
|
913
913
|
public func syncEncryptedRecordingV2(
|
|
914
914
|
id: String,
|
|
915
915
|
serialNumber: String,
|
|
@@ -927,6 +927,7 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
|
|
|
927
927
|
durationMs: String?,
|
|
928
928
|
plaintextLength: String?,
|
|
929
929
|
storageFormat: NSNumber?,
|
|
930
|
+
markersRequired: Bool,
|
|
930
931
|
operationID: String,
|
|
931
932
|
onProfileRequest: @escaping @Sendable ([String: Any]) -> Void,
|
|
932
933
|
onProgress: @escaping @Sendable ([String: Any]) -> Void,
|
|
@@ -964,7 +965,8 @@ public final class BotaDeviceSDKAppleBridge: NSObject, @unchecked Sendable {
|
|
|
964
965
|
startedAtMs: try Self.catalogDecimal(startedAtMs),
|
|
965
966
|
durationMs: try Self.catalogDecimal(durationMs),
|
|
966
967
|
plaintextLength: try Self.catalogDecimal(plaintextLength),
|
|
967
|
-
storageFormat: format
|
|
968
|
+
storageFormat: format,
|
|
969
|
+
markersRequired: markersRequired
|
|
968
970
|
),
|
|
969
971
|
operationID: operationID,
|
|
970
972
|
onProfileRequest: onProfileRequest,
|
|
@@ -179,7 +179,7 @@ actor BotaDeviceSDKAppleRecordingUploads {
|
|
|
179
179
|
guard let tasks = try JSONSerialization.jsonObject(with: Data(serialized.utf8)) as? [[String: Any]] else {
|
|
180
180
|
throw UploadError.invalidJournal
|
|
181
181
|
}
|
|
182
|
-
let fields: Set<String> = ["id", "recordingId", "deviceId", "recordingUuid", "localPath", "status", "retryCount", "createdAt", "updatedAt", "recoveryScope", "fileSizeBytes", "nextAttemptAt", "contentType", "contentSha256", "relayUpload"]
|
|
182
|
+
let fields: Set<String> = ["id", "recordingId", "deviceId", "recordingUuid", "localPath", "status", "retryCount", "createdAt", "updatedAt", "recoveryScope", "fileSizeBytes", "nextAttemptAt", "contentType", "contentSha256", "relayUpload", "completionPending"]
|
|
183
183
|
var ids = Set<String>()
|
|
184
184
|
return try tasks.map { task in
|
|
185
185
|
guard let id = task["id"] as? String, !id.isEmpty, ids.insert(id).inserted,
|
|
@@ -195,8 +195,8 @@ actor BotaDeviceSDKAppleRecordingUploads {
|
|
|
195
195
|
for key in ["fileSizeBytes", "nextAttemptAt"] where task[key] != nil {
|
|
196
196
|
guard validInteger(task[key]) else { throw UploadError.invalidJournal }
|
|
197
197
|
}
|
|
198
|
-
|
|
199
|
-
guard let number =
|
|
198
|
+
for key in ["relayUpload", "completionPending"] where task[key] != nil {
|
|
199
|
+
guard let number = task[key] as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() else { throw UploadError.invalidJournal }
|
|
200
200
|
}
|
|
201
201
|
let dates = ISO8601DateFormatter()
|
|
202
202
|
let fractionalDates = ISO8601DateFormatter()
|
|
@@ -646,6 +646,7 @@ actor BotaDeviceSDKAppleRecordings {
|
|
|
646
646
|
"durationMs": String(recording.durationMs),
|
|
647
647
|
"plaintextLength": String(recording.plaintextLength),
|
|
648
648
|
"storageFormat": Int(recording.storageFormat),
|
|
649
|
+
"markersRequired": recording.markersRequired,
|
|
649
650
|
]
|
|
650
651
|
}
|
|
651
652
|
|
|
@@ -16,6 +16,15 @@ RCT_EXPORT_MODULE(BotaUploadV2Backend)
|
|
|
16
16
|
if ((self = [super init])) { _backend = [BotaUploadV2BackendBridge new]; }
|
|
17
17
|
return self;
|
|
18
18
|
}
|
|
19
|
+
- (void)readProtectedStreamingStatus:(NSString *)requestId inputJSON:(NSString *)inputJSON resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
|
20
|
+
[_backend readProtectedStreamingStatus:requestId inputJSON:inputJSON completion:^(NSString *result, NSError *error) {
|
|
21
|
+
if (error) { reject(@"BOTA_STREAM_STATUS_FAILED", @"BOTA_STREAM_STATUS_FAILED", nil); }
|
|
22
|
+
else { resolve(result); }
|
|
23
|
+
}];
|
|
24
|
+
}
|
|
25
|
+
- (void)cancelProtectedStreamingStatus:(NSString *)requestId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
|
|
26
|
+
[_backend cancelProtectedStreamingStatus:requestId completion:^{ resolve(nil); }];
|
|
27
|
+
}
|
|
19
28
|
- (void)prepare:(NSString *)inputJSON resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
|
20
29
|
__weak BotaUploadV2Backend *weakSelf = self;
|
|
21
30
|
[_backend prepare:inputJSON credentialsRequested:^(NSString *requestId, NSString *operationId) {
|
|
@@ -25,6 +34,18 @@ RCT_EXPORT_MODULE(BotaUploadV2Backend)
|
|
|
25
34
|
else { resolve(result); }
|
|
26
35
|
}];
|
|
27
36
|
}
|
|
37
|
+
- (void)syncByo:(NSString *)inputJSON resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
|
38
|
+
__weak BotaUploadV2Backend *weakSelf = self;
|
|
39
|
+
[_backend syncByo:inputJSON credentialsRequested:^(NSString *requestId, NSString *operationId) {
|
|
40
|
+
[weakSelf emitOnCredentialsRequested:@{@"requestId": requestId, @"operationId": operationId}];
|
|
41
|
+
} completion:^(NSString *result, NSError *error) {
|
|
42
|
+
if (error) { reject(error.userInfo[@"byoCode"] ?: @"byo_upload_failed", error.localizedDescription, nil); }
|
|
43
|
+
else { resolve(result); }
|
|
44
|
+
}];
|
|
45
|
+
}
|
|
46
|
+
- (void)cancelByo:(NSString *)operationId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
|
|
47
|
+
[_backend cancelByo:operationId completion:^{ resolve(nil); }];
|
|
48
|
+
}
|
|
28
49
|
- (void)cancel:(NSString *)operationId resolve:(RCTPromiseResolveBlock)resolve reject:(__unused RCTPromiseRejectBlock)reject {
|
|
29
50
|
[_backend cancel:operationId completion:^{ resolve(nil); }];
|
|
30
51
|
}
|
|
@@ -4,9 +4,28 @@ import Foundation
|
|
|
4
4
|
@objc(BotaUploadV2BackendBridge)
|
|
5
5
|
public final class BotaUploadV2BackendBridge: NSObject, @unchecked Sendable {
|
|
6
6
|
@MainActor private var coordinator: UploadCoordinator?
|
|
7
|
+
@MainActor private var byo: ByoBridgeDriver?
|
|
7
8
|
@MainActor private var destroyed = false
|
|
9
|
+
@MainActor private var statusJobs: [String: Task<Void, Never>] = [:]
|
|
8
10
|
@MainActor private var credentialsRequested: (@Sendable (String, String) -> Void)?
|
|
9
11
|
|
|
12
|
+
@objc(readProtectedStreamingStatus:inputJSON:completion:)
|
|
13
|
+
public func readProtectedStreamingStatus(_ id: String, inputJSON: String,
|
|
14
|
+
completion: @escaping @Sendable (String?, NSError?) -> Void) {
|
|
15
|
+
Task { @MainActor in
|
|
16
|
+
guard !destroyed, statusJobs[id] == nil else { completion(nil, UploadFailure.cancelled as NSError); return }
|
|
17
|
+
statusJobs[id] = Task { @MainActor in
|
|
18
|
+
defer { statusJobs[id] = nil }
|
|
19
|
+
do { let result = try await UploadStreamingStatus().read(inputJSON); completion(result, nil) }
|
|
20
|
+
catch { completion(nil, UploadFailure.safe(error) as NSError) }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
@objc(cancelProtectedStreamingStatus:completion:)
|
|
25
|
+
public func cancelProtectedStreamingStatus(_ id: String, completion: @escaping @Sendable () -> Void) {
|
|
26
|
+
Task { @MainActor in statusJobs[id]?.cancel(); completion() }
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
@objc(prepare:credentialsRequested:completion:)
|
|
11
30
|
public func prepare(_ json: String,
|
|
12
31
|
credentialsRequested: @escaping @Sendable (String, String) -> Void,
|
|
@@ -23,6 +42,26 @@ public final class BotaUploadV2BackendBridge: NSObject, @unchecked Sendable {
|
|
|
23
42
|
}
|
|
24
43
|
}
|
|
25
44
|
|
|
45
|
+
@objc(syncByo:credentialsRequested:completion:)
|
|
46
|
+
public func syncByo(_ json: String, credentialsRequested: @escaping @Sendable (String, String) -> Void,
|
|
47
|
+
completion: @escaping @Sendable (String?, NSError?) -> Void) {
|
|
48
|
+
Task { @MainActor in
|
|
49
|
+
do {
|
|
50
|
+
guard !destroyed else { throw UploadFailure.cancelled }
|
|
51
|
+
self.credentialsRequested = credentialsRequested
|
|
52
|
+
completion(try await byoService().sync(json), nil)
|
|
53
|
+
} catch { completion(nil, (error is ByoStorageError ? error : UploadFailure.safe(error)) as NSError) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
@objc(cancelByo:completion:)
|
|
57
|
+
public func cancelByo(_ id: String, completion: @escaping @Sendable () -> Void) {
|
|
58
|
+
Task { @MainActor in await byoService().cancel(id); completion() }
|
|
59
|
+
}
|
|
60
|
+
@MainActor private func byoService() -> ByoBridgeDriver {
|
|
61
|
+
if let byo { return byo }
|
|
62
|
+
let value = ByoBridgeDriver(credentials: service().credentials); byo = value; return value
|
|
63
|
+
}
|
|
64
|
+
|
|
26
65
|
@objc(cancel:completion:)
|
|
27
66
|
public func cancel(_ id: String, completion: @escaping @Sendable () -> Void) {
|
|
28
67
|
Task { @MainActor in coordinator?.cancel(id); completion() }
|
|
@@ -45,7 +84,11 @@ public final class BotaUploadV2BackendBridge: NSObject, @unchecked Sendable {
|
|
|
45
84
|
Task { @MainActor in coordinator?.credentials.reject(requestId: id); completion() }
|
|
46
85
|
}
|
|
47
86
|
@objc public func invalidate() {
|
|
48
|
-
Task { @MainActor in
|
|
87
|
+
Task { @MainActor in
|
|
88
|
+
destroyed = true; coordinator?.cancelAll(); credentialsRequested = nil
|
|
89
|
+
statusJobs.values.forEach { $0.cancel() }; statusJobs.removeAll()
|
|
90
|
+
await byo?.stop()
|
|
91
|
+
}
|
|
49
92
|
}
|
|
50
93
|
|
|
51
94
|
@MainActor private func service() -> UploadCoordinator {
|
|
@@ -69,6 +112,12 @@ public final class BotaUploadV2BackendBridge: NSObject, @unchecked Sendable {
|
|
|
69
112
|
default: throw UploadFailure.invalidDocument
|
|
70
113
|
}
|
|
71
114
|
guard let sessionId = UUID(uuidString: prepared.session.session_id) else { throw UploadFailure.invalidDocument }
|
|
115
|
+
let submitMarkers: EncryptedUploadV2Material.MarkerSubmitter?
|
|
116
|
+
if operation.input.recording.markersRequired == true {
|
|
117
|
+
submitMarkers = { documents, evidence in
|
|
118
|
+
try await operation.submitMarkers(documents, evidence: Self.evidence(evidence))
|
|
119
|
+
}
|
|
120
|
+
} else { submitMarkers = nil }
|
|
72
121
|
let material = EncryptedUploadV2Material(
|
|
73
122
|
materialID: UUID().uuidString.lowercased(), recordingID: operation.input.recording.uuid,
|
|
74
123
|
uploadSessionID: sessionId, ownerRevision: prepared.session.owner_revision, policy: policy,
|
|
@@ -83,7 +132,8 @@ public final class BotaUploadV2BackendBridge: NSObject, @unchecked Sendable {
|
|
|
83
132
|
return EncryptedUploadV2ContextExchange(challenge: context.challenge, exchangeProof: context.exchangeProof)
|
|
84
133
|
},
|
|
85
134
|
shouldUploadCiphertext: { try await operation.shouldUpload(Self.evidence($0)) },
|
|
86
|
-
reconcileStaging: { try await operation.reconcileStaging($0, evidence: Self.evidence($1)) }
|
|
135
|
+
reconcileStaging: { try await operation.reconcileStaging($0, evidence: Self.evidence($1)) },
|
|
136
|
+
submitMarkers: submitMarkers
|
|
87
137
|
)
|
|
88
138
|
return BotaDeviceSDKEncryptedUploadV2Materials.register(material)
|
|
89
139
|
}, unregister: { id in _ = BotaDeviceSDKEncryptedUploadV2Materials.remove(id) })
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import BotaAppSDK
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Metadata-only RN boundary. SDK native code owns documents and recording bytes.
|
|
5
|
+
@MainActor final class ByoBridgeDriver {
|
|
6
|
+
private let credentials: UploadCredentials
|
|
7
|
+
private var tasks: [String: Task<String, Error>] = [:]
|
|
8
|
+
private var cancelled: Set<String> = []
|
|
9
|
+
private var stopped = false
|
|
10
|
+
init(credentials: UploadCredentials) { self.credentials = credentials }
|
|
11
|
+
private struct Input: Decodable {
|
|
12
|
+
let operationId: String
|
|
13
|
+
let device: Device
|
|
14
|
+
let recording: Recording?
|
|
15
|
+
let mode: String
|
|
16
|
+
let scope: Scope
|
|
17
|
+
struct Recording: Decodable { let uuid: String; let generation: UInt32 }
|
|
18
|
+
struct Scope: Decodable {
|
|
19
|
+
let baseUrl, scopeKey, organizationId, projectId, environment, deviceId: String
|
|
20
|
+
let bindingGeneration: UInt32
|
|
21
|
+
}
|
|
22
|
+
struct Device: Decodable {
|
|
23
|
+
let id, serialNumber, deviceType, firmwareVersion, connectionState: String
|
|
24
|
+
let hardwareRevision: String?
|
|
25
|
+
let isProvisioned: Bool
|
|
26
|
+
let mtu: Int
|
|
27
|
+
func native() throws -> ConnectedDevice {
|
|
28
|
+
let type: DeviceType
|
|
29
|
+
switch deviceType {
|
|
30
|
+
case "bota_pin": type = .botaPin
|
|
31
|
+
case "bota_pin_4g": type = .botaPin4G
|
|
32
|
+
case "bota_note": type = .botaNote
|
|
33
|
+
default: throw UploadFailure.invalidInput
|
|
34
|
+
}
|
|
35
|
+
guard connectionState == "connected", (23...517).contains(mtu),
|
|
36
|
+
!id.isEmpty, !serialNumber.isEmpty else { throw UploadFailure.invalidInput }
|
|
37
|
+
return ConnectedDevice(id: id, serialNumber: serialNumber, deviceType: type, firmwareVersion: firmwareVersion,
|
|
38
|
+
hardwareRevision: hardwareRevision, isProvisioned: isProvisioned, connectionState: .connected, mtu: mtu)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
private func check(_ id: String) throws {
|
|
43
|
+
try Task.checkCancellation()
|
|
44
|
+
guard !stopped, !cancelled.contains(id), tasks[id] != nil else { throw UploadFailure.cancelled }
|
|
45
|
+
}
|
|
46
|
+
func sync(_ json: String) async throws -> String {
|
|
47
|
+
guard json.utf8.count <= 8192 else { throw UploadFailure.invalidInput }
|
|
48
|
+
let input = try JSONDecoder().decode(Input.self, from: Data(json.utf8))
|
|
49
|
+
let id = input.operationId
|
|
50
|
+
guard id.hasPrefix("byo-"), id.utf8.count <= 64, !stopped, !cancelled.contains(id), tasks[id] == nil,
|
|
51
|
+
let url = URL(string: input.scope.baseUrl) else { throw UploadFailure.invalidInput }
|
|
52
|
+
let device = try input.device.native()
|
|
53
|
+
let task = Task {
|
|
54
|
+
try self.check(id)
|
|
55
|
+
let options = ByoStorageOptions(baseURL: url, scopeKey: input.scope.scopeKey,
|
|
56
|
+
organizationID: input.scope.organizationId, projectID: input.scope.projectId, deviceID: input.scope.deviceId,
|
|
57
|
+
environment: input.scope.environment, bindingGeneration: input.scope.bindingGeneration,
|
|
58
|
+
stateDirectory: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
59
|
+
.appendingPathComponent("BotaSDKByo", isDirectory: true),
|
|
60
|
+
accessToken: { try await self.credentials.request(operationId: id) },
|
|
61
|
+
validateScope: { try await self.check(id) })
|
|
62
|
+
let bytes: Data
|
|
63
|
+
if input.mode == "list" {
|
|
64
|
+
let recordings = try await BotaDeviceClient.shared.recordings.listByoRecordings(device, options: options)
|
|
65
|
+
try self.check(id)
|
|
66
|
+
bytes = try JSONSerialization.data(withJSONObject: recordings.map { ["uuid": $0.uuid, "generation": $0.generation] as [String: Any] })
|
|
67
|
+
} else {
|
|
68
|
+
guard input.mode == "stream" || (input.mode == "sync" && input.recording != nil) else { throw UploadFailure.invalidInput }
|
|
69
|
+
let selected = input.recording.map { ByoRecording(uuid: $0.uuid, generation: $0.generation) }
|
|
70
|
+
let result: ByoUploadResult
|
|
71
|
+
if input.mode == "stream" { result = try await BotaDeviceClient.shared.recordings.streamByoRecording(device, recording: selected, options: options) }
|
|
72
|
+
else { result = try await BotaDeviceClient.shared.recordings.syncByoRecording(device, recording: selected!, options: options) }
|
|
73
|
+
try self.check(id)
|
|
74
|
+
bytes = try JSONSerialization.data(withJSONObject: ["recordingId": result.recordingID, "uploadSessionId": result.uploadSessionID])
|
|
75
|
+
}
|
|
76
|
+
return String(decoding: bytes, as: UTF8.self)
|
|
77
|
+
}
|
|
78
|
+
tasks[id] = task
|
|
79
|
+
defer { tasks[id] = nil; cancelled.remove(id); credentials.cancel(operationId: id) }
|
|
80
|
+
return try await task.value
|
|
81
|
+
}
|
|
82
|
+
func cancel(_ id: String) async {
|
|
83
|
+
guard id.hasPrefix("byo-"), id.utf8.count <= 64 else { return }
|
|
84
|
+
if cancelled.count >= 128 { stopped = true }
|
|
85
|
+
else { cancelled.insert(id) }
|
|
86
|
+
let task = tasks[id]; task?.cancel(); credentials.cancel(operationId: id); _ = await task?.result
|
|
87
|
+
}
|
|
88
|
+
func stop() async {
|
|
89
|
+
stopped = true
|
|
90
|
+
let current = tasks
|
|
91
|
+
for (id, task) in current { task.cancel(); credentials.cancel(operationId: id) }
|
|
92
|
+
for task in current.values { _ = await task.result }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -101,6 +101,7 @@ struct UploadInput: Codable, Sendable {
|
|
|
101
101
|
var durationMs: String
|
|
102
102
|
var plaintextLength: String
|
|
103
103
|
var storageFormat: UInt8
|
|
104
|
+
var markersRequired: Bool? = nil
|
|
104
105
|
}
|
|
105
106
|
struct Capability: Codable, Sendable { var rawValueHex: String; var flags: UInt32 }
|
|
106
107
|
struct Checkpoint: Codable, Sendable {
|
|
@@ -187,7 +188,9 @@ struct UploadInput: Codable, Sendable {
|
|
|
187
188
|
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
|
188
189
|
var identityScope = scope
|
|
189
190
|
if !includeTransport { identityScope.nativeDeviceId = "" }
|
|
190
|
-
|
|
191
|
+
var identityRecording = recording
|
|
192
|
+
if identityRecording.markersRequired == false { identityRecording.markersRequired = nil }
|
|
193
|
+
return UploadValidation.hash(try encoder.encode(Identity(scope: identityScope, recording: identityRecording)))
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
func validateCheckpoint(_ entry: UploadJournalEntry?) throws {
|
|
@@ -242,6 +245,8 @@ struct UploadSession: Decodable, Sendable {
|
|
|
242
245
|
let plaintext_length: UInt64?
|
|
243
246
|
let completion_receipt_base64: String?
|
|
244
247
|
let completion_receipt_sha256: String?
|
|
248
|
+
let marker_completion_receipt_base64: String?
|
|
249
|
+
let marker_completion_receipt_sha256: String?
|
|
245
250
|
|
|
246
251
|
func validatePointer() throws {
|
|
247
252
|
guard profile == "encrypted_upload_v2", UploadValidation.uuid(session_id), owner_revision > 0,
|
|
@@ -178,7 +178,7 @@ struct UploadContext: Sendable {
|
|
|
178
178
|
} else {
|
|
179
179
|
let nonce = try await nonce()
|
|
180
180
|
let body: [String: Any] = [
|
|
181
|
-
"device_id": input.scope.deviceId, "
|
|
181
|
+
"device_id": input.scope.deviceId, "binding_generation": input.scope.bindingGeneration,
|
|
182
182
|
"recording_uuid": input.recording.uuid, "recording_generation": input.recording.generation,
|
|
183
183
|
"storage_format": "bota_enc_v2", "channel": "ble",
|
|
184
184
|
"capabilities_base64": try UploadValidation.bytes(input.capability.rawValueHex).base64EncodedString(),
|
|
@@ -201,7 +201,28 @@ struct UploadContext: Sendable {
|
|
|
201
201
|
try selected.validatePointer()
|
|
202
202
|
_ = try UploadValidation.date(selected.expires_at)
|
|
203
203
|
guard UploadValidation.hex(selected.authorization_sha256, bytes: 32) else { throw UploadFailure.invalidDocument }
|
|
204
|
-
|
|
204
|
+
var authorization = try UploadValidation.document(selected.authorization_base64, hash: selected.authorization_sha256, size: 408)
|
|
205
|
+
if input.recording.markersRequired == true {
|
|
206
|
+
struct Admission: Decodable {
|
|
207
|
+
let profile: String
|
|
208
|
+
let context_base64: String
|
|
209
|
+
let authorization_base64: String
|
|
210
|
+
let authorization_sha256: String
|
|
211
|
+
}
|
|
212
|
+
let path = try sessionPath() + "/markers/authorization"
|
|
213
|
+
let admitted: Data
|
|
214
|
+
do {
|
|
215
|
+
admitted = try await retry { try await request(path + "?owner_revision=\(selected.owner_revision)") }
|
|
216
|
+
} catch UploadFailure.http(404) where ["", "created", "staging", "staged"].contains(selected.state ?? "") {
|
|
217
|
+
admitted = try await retry { try await request(path, method: "POST", body: [
|
|
218
|
+
"owner_revision": selected.owner_revision, "request_uuid": selected.session_id,
|
|
219
|
+
]) }
|
|
220
|
+
}
|
|
221
|
+
let admission: Admission = try decode(admitted)
|
|
222
|
+
guard admission.profile == "recording-markers/1" else { throw UploadFailure.invalidDocument }
|
|
223
|
+
authorization.append(try UploadValidation.document(admission.context_base64, size: 176))
|
|
224
|
+
authorization.append(try UploadValidation.document(admission.authorization_base64, hash: admission.authorization_sha256, size: 280))
|
|
225
|
+
}
|
|
205
226
|
session = selected
|
|
206
227
|
return UploadPrepared(session: selected, authorization: authorization, recordingId: initial.recordingId)
|
|
207
228
|
}
|
|
@@ -313,6 +334,18 @@ struct UploadContext: Sendable {
|
|
|
313
334
|
needsManifest = false
|
|
314
335
|
}
|
|
315
336
|
|
|
337
|
+
func submitMarkers(_ documents: [Data], evidence: UploadEvidence) async throws {
|
|
338
|
+
try verify(evidence)
|
|
339
|
+
guard input.recording.markersRequired == true, (2...4097).contains(documents.count),
|
|
340
|
+
documents.first?.count == 200, documents.dropFirst().allSatisfy({ (216...402).contains($0.count) }),
|
|
341
|
+
let revision = entry?.ownerRevision else { throw UploadFailure.invalidDocument }
|
|
342
|
+
let path = try sessionPath() + "/markers/batch"
|
|
343
|
+
_ = try await retry { try await request(path, method: "POST", body: [
|
|
344
|
+
"owner_revision": revision, "seal_base64": documents[0].base64EncodedString(),
|
|
345
|
+
"pages_base64": documents.dropFirst().map { $0.base64EncodedString() },
|
|
346
|
+
]) }
|
|
347
|
+
}
|
|
348
|
+
|
|
316
349
|
func finalize(_ evidence: UploadEvidence) async throws {
|
|
317
350
|
try verify(evidence)
|
|
318
351
|
guard let entry else { throw UploadFailure.state }
|
|
@@ -325,7 +358,12 @@ struct UploadContext: Sendable {
|
|
|
325
358
|
let hash = status.completion_receipt_sha256, UploadValidation.hex(hash, bytes: 32) else {
|
|
326
359
|
throw UploadFailure.invalidDocument
|
|
327
360
|
}
|
|
328
|
-
|
|
361
|
+
var completed = try UploadValidation.document(status.completion_receipt_base64, hash: hash, size: 336)
|
|
362
|
+
if input.recording.markersRequired == true {
|
|
363
|
+
guard let markerHash = status.marker_completion_receipt_sha256 else { throw UploadFailure.invalidDocument }
|
|
364
|
+
completed.append(try UploadValidation.document(status.marker_completion_receipt_base64, hash: markerHash, size: 296))
|
|
365
|
+
}
|
|
366
|
+
receipt = completed
|
|
329
367
|
return
|
|
330
368
|
}
|
|
331
369
|
try await wait(2000)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreFoundation
|
|
3
|
+
|
|
4
|
+
/** Scalar-only read boundary; all HTTP runs through the no-redirect native client. */
|
|
5
|
+
@MainActor final class UploadStreamingStatus {
|
|
6
|
+
private let http: any UploadHTTP
|
|
7
|
+
init(http: any UploadHTTP = UploadURLSessionHTTP()) { self.http = http }
|
|
8
|
+
func read(_ input: String) async throws -> String {
|
|
9
|
+
guard let object = try JSONSerialization.jsonObject(with: Data(input.utf8)) as? [String: Any],
|
|
10
|
+
let address = object["url"] as? String, let token = object["token"] as? String,
|
|
11
|
+
let org = object["organizationId"] as? String,
|
|
12
|
+
UploadValidation.matches(address, "https://[A-Za-z0-9.-]+(?::[0-9]+)?(?:/[A-Za-z0-9_-]+)+/recordings/rec_[A-Za-z0-9_-]+/streaming-status\\?session_id=[0-9a-f-]{36}"),
|
|
13
|
+
let url = URL(string: address), (1...16384).contains(token.utf8.count),
|
|
14
|
+
token.utf8.allSatisfy({ (33...126).contains($0) }),
|
|
15
|
+
org.isEmpty || UploadValidation.matches(org, "[A-Za-z0-9_-]{1,128}") else { throw UploadFailure.invalidInput }
|
|
16
|
+
try Task.checkCancellation()
|
|
17
|
+
var request = URLRequest(url: url); request.httpMethod = "GET"
|
|
18
|
+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
19
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
20
|
+
if !org.isEmpty { request.setValue(org, forHTTPHeaderField: "X-Organization-Id") }
|
|
21
|
+
let response = try await http.send(request)
|
|
22
|
+
try Task.checkCancellation()
|
|
23
|
+
guard response.status == 200 else { throw UploadFailure.http(response.status) }
|
|
24
|
+
guard response.body.count <= 16384 else { throw UploadFailure.invalidDocument }
|
|
25
|
+
return try Self.scalarStatus(response.body)
|
|
26
|
+
}
|
|
27
|
+
static func scalarStatus(_ body: Data) throws -> String {
|
|
28
|
+
let strings: Set<String> = ["profile", "recording_id", "session_id", "writer_epoch", "revision", "state"]
|
|
29
|
+
let numbers: Set<String> = ["recording_generation", "received_count", "contiguous_sequence"]
|
|
30
|
+
guard let object = try JSONSerialization.jsonObject(with: body) as? [String: Any],
|
|
31
|
+
Set(object.keys) == strings.union(numbers).union(["expected_count", "authorization_expired"]),
|
|
32
|
+
strings.allSatisfy({ (object[$0] as? String).map { $0.utf8.count <= 160 } == true }),
|
|
33
|
+
numbers.allSatisfy({ isNumber(object[$0]) }),
|
|
34
|
+
object["expected_count"] is NSNull || isNumber(object["expected_count"]),
|
|
35
|
+
let expired = object["authorization_expired"] as? NSNumber,
|
|
36
|
+
CFGetTypeID(expired) == CFBooleanGetTypeID() else { throw UploadFailure.invalidDocument }
|
|
37
|
+
return String(decoding: try JSONSerialization.data(withJSONObject: object), as: UTF8.self)
|
|
38
|
+
}
|
|
39
|
+
private static func isNumber(_ value: Any?) -> Bool {
|
|
40
|
+
guard let number = value as? NSNumber else { return false }
|
|
41
|
+
return CFGetTypeID(number) != CFBooleanGetTypeID()
|
|
42
|
+
}
|
|
43
|
+
}
|