@bota.dev/react-native-app-sdk 2.0.0-beta.10 → 2.0.0-beta.12

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 (41) hide show
  1. package/README.md +18 -8
  2. package/android/src/main/java/dev/bota/sdk/reactnative/BotaDeviceSDKPackage.kt +9 -0
  3. package/android/src/main/java/dev/bota/sdk/reactnative/upload/BotaUploadV2BackendModule.kt +66 -0
  4. package/android/src/main/java/dev/bota/sdk/reactnative/upload/NativeUpload.kt +400 -0
  5. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadHttp.kt +158 -0
  6. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadJournal.kt +112 -0
  7. package/android/src/main/java/dev/bota/sdk/reactnative/upload/UploadModels.kt +171 -0
  8. package/generated/codegen-contract.json +167 -8
  9. package/ios/BotaUploadV2Backend.mm +49 -0
  10. package/ios/UploadBackend/BotaUploadV2BackendBridge.swift +98 -0
  11. package/ios/UploadBackend/UploadCoordinator.swift +84 -0
  12. package/ios/UploadBackend/UploadHTTP.swift +82 -0
  13. package/ios/UploadBackend/UploadJournal.swift +187 -0
  14. package/ios/UploadBackend/UploadModels.swift +273 -0
  15. package/ios/UploadBackend/UploadOperation.swift +391 -0
  16. package/lib/commonjs/encryptedUploadV2Backend.js +200 -0
  17. package/lib/commonjs/encryptedUploadV2Backend.js.map +1 -0
  18. package/lib/commonjs/index.js +5 -0
  19. package/lib/commonjs/index.js.map +1 -1
  20. package/lib/commonjs/sdkIdentity.js +1 -1
  21. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js +11 -0
  22. package/lib/commonjs/specs/NativeBotaUploadV2Backend.js.map +1 -0
  23. package/lib/module/encryptedUploadV2Backend.js +196 -0
  24. package/lib/module/encryptedUploadV2Backend.js.map +1 -0
  25. package/lib/module/index.js +3 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/sdkIdentity.js +1 -1
  28. package/lib/module/specs/NativeBotaUploadV2Backend.js +9 -0
  29. package/lib/module/specs/NativeBotaUploadV2Backend.js.map +1 -0
  30. package/lib/typescript/encryptedUploadV2Backend.d.ts +41 -0
  31. package/lib/typescript/encryptedUploadV2Backend.d.ts.map +1 -0
  32. package/lib/typescript/index.d.ts +3 -0
  33. package/lib/typescript/index.d.ts.map +1 -1
  34. package/lib/typescript/sdkIdentity.d.ts +1 -1
  35. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts +18 -0
  36. package/lib/typescript/specs/NativeBotaUploadV2Backend.d.ts.map +1 -0
  37. package/package.json +1 -1
  38. package/src/encryptedUploadV2Backend.ts +171 -0
  39. package/src/index.ts +6 -0
  40. package/src/sdkIdentity.ts +1 -1
  41. package/src/specs/NativeBotaUploadV2Backend.ts +19 -0
@@ -0,0 +1,158 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import java.io.IOException
4
+ import java.util.UUID
5
+ import java.util.concurrent.TimeUnit
6
+ import kotlin.coroutines.resume
7
+ import kotlin.coroutines.resumeWithException
8
+ import kotlinx.coroutines.CompletableDeferred
9
+ import kotlinx.coroutines.NonCancellable
10
+ import kotlinx.coroutines.suspendCancellableCoroutine
11
+ import kotlinx.coroutines.withContext
12
+ import kotlinx.coroutines.withTimeoutOrNull
13
+ import okhttp3.Call
14
+ import okhttp3.Callback
15
+ import okhttp3.MediaType.Companion.toMediaType
16
+ import okhttp3.OkHttpClient
17
+ import okhttp3.Request
18
+ import okhttp3.RequestBody.Companion.toRequestBody
19
+ import okhttp3.Response
20
+ import org.json.JSONObject
21
+
22
+ internal class UploadOperation(val input: UploadInput) {
23
+ private var cancelled = false
24
+ private val cancellations = mutableSetOf<() -> Unit>()
25
+ var registrationId: String? = null
26
+
27
+ @Synchronized fun check() { if (cancelled) fail("CANCELLED") }
28
+ @Synchronized fun <T> active(block: () -> T): T { check(); return block() }
29
+ @Synchronized fun onCancel(callback: () -> Unit): () -> Unit {
30
+ check()
31
+ cancellations.add(callback)
32
+ return { synchronized(this) { cancellations.remove(callback) }; Unit }
33
+ }
34
+ fun cancel() {
35
+ val callbacks = synchronized(this) {
36
+ if (cancelled) return
37
+ cancelled = true
38
+ cancellations.toList().also { cancellations.clear() }
39
+ }
40
+ callbacks.forEach { it() }
41
+ }
42
+ }
43
+
44
+ internal class CredentialBroker(private val emit: (String, String) -> Unit) {
45
+ private data class Pending(val operation: UploadOperation, val result: CompletableDeferred<String>)
46
+ private val pending = mutableMapOf<String, Pending>()
47
+
48
+ suspend fun request(operation: UploadOperation): String {
49
+ operation.check()
50
+ val requestId = UUID.randomUUID().toString()
51
+ val result = CompletableDeferred<String>()
52
+ val unregister = operation.onCancel { reject(requestId) }
53
+ try {
54
+ operation.active {
55
+ synchronized(this) { pending[requestId] = Pending(operation, result) }
56
+ emit(requestId, operation.input.operationId)
57
+ }
58
+ val token = withTimeoutOrNull(30_000) { result.await() } ?: fail("CREDENTIALS")
59
+ operation.check()
60
+ return token
61
+ } catch (error: Exception) {
62
+ operation.check()
63
+ throw error
64
+ } finally {
65
+ unregister()
66
+ synchronized(this) { pending.remove(requestId) }
67
+ }
68
+ }
69
+
70
+ fun resolve(requestId: String, token: String) {
71
+ val request = synchronized(this) { pending.remove(requestId) } ?: return
72
+ try {
73
+ request.operation.active {
74
+ demand(token.length in 1..32768 && token.all { it.code in 33..126 }, "CREDENTIALS")
75
+ request.result.complete(token)
76
+ }
77
+ } catch (error: UploadFailure) { request.result.completeExceptionally(error) }
78
+ }
79
+
80
+ fun reject(requestId: String) {
81
+ val request = synchronized(this) { pending.remove(requestId) } ?: return
82
+ request.result.completeExceptionally(UploadFailure("BOTA_UPLOAD_CREDENTIALS"))
83
+ }
84
+ }
85
+
86
+ internal class UploadRequest(
87
+ val origin: String, val path: String, val method: String, val headers: Map<String, String>, val body: JSONObject?,
88
+ ) {
89
+ override fun toString(): String = "UploadRequest(<redacted>)"
90
+ }
91
+
92
+ internal interface UploadHttp {
93
+ suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
94
+ beforeDispatch: () -> Unit = {}): JSONObject
95
+ }
96
+
97
+ /** Dedicated client: no app network recorder, auth interceptor, redirects, or automatic POST retry. */
98
+ internal class BackendUploadHttp(private val client: OkHttpClient = OkHttpClient.Builder()
99
+ .followRedirects(false).followSslRedirects(false).retryOnConnectionFailure(false)
100
+ .connectTimeout(20, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).callTimeout(45, TimeUnit.SECONDS).build()) : UploadHttp {
101
+ override suspend fun execute(request: UploadRequest, operation: UploadOperation, retain: Boolean,
102
+ beforeDispatch: () -> Unit): JSONObject = withContext(if (retain) NonCancellable else kotlin.coroutines.EmptyCoroutineContext) {
103
+ try {
104
+ val nativeRequest = Request.Builder().url(request.origin + request.path).apply {
105
+ request.headers.forEach { (key, value) -> header(key, value) }
106
+ method(request.method, request.body?.toString()?.toRequestBody("application/json".toMediaType()))
107
+ }.build()
108
+ val call = client.newCall(nativeRequest)
109
+ suspendCancellableCoroutine { continuation ->
110
+ var unregister: (() -> Unit)? = null
111
+ continuation.invokeOnCancellation { call.cancel() }
112
+ try {
113
+ operation.active {
114
+ if (!retain) unregister = operation.onCancel { call.cancel() }
115
+ beforeDispatch()
116
+ call.enqueue(object : Callback {
117
+ override fun onFailure(call: Call, e: IOException) {
118
+ unregister?.invoke()
119
+ if (continuation.isActive) continuation.resumeWithException(UploadFailure("BOTA_UPLOAD_TRANSPORT"))
120
+ }
121
+ override fun onResponse(call: Call, response: Response) {
122
+ val value = runCatching {
123
+ response.use {
124
+ if (!response.isSuccessful && response.code != 401 && response.code != 403 && response.code != 409) fail("HTTP_${response.code}")
125
+ val body = response.body ?: fail("INVALID_DOCUMENT")
126
+ demand(body.contentLength() <= 65536, "INVALID_DOCUMENT")
127
+ // Read a bounded body here, before completing the retained request.
128
+ val source = body.source()
129
+ source.request(65537)
130
+ demand(source.buffer.size <= 65536, "INVALID_DOCUMENT")
131
+ val text = source.readUtf8()
132
+ if (response.code == 409 && runCatching {
133
+ JSONObject(text).optJSONObject("error")?.optString("code") == "encrypted_upload_v2_staging_missing"
134
+ }.getOrDefault(false)) fail("STAGING_MISSING")
135
+ if (!response.isSuccessful) fail("HTTP_${response.code}")
136
+ if (text.isEmpty()) JSONObject() else sanitized("INVALID_DOCUMENT") { JSONObject(text) }
137
+ }
138
+ }
139
+ unregister?.invoke()
140
+ if (continuation.isActive) value.fold({ continuation.resume(it) }, {
141
+ continuation.resumeWithException(if (it is UploadFailure) it else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
142
+ })
143
+ }
144
+ })
145
+ }
146
+ } catch (error: Exception) {
147
+ unregister?.invoke()
148
+ if (continuation.isActive) continuation.resumeWithException(
149
+ if (error is UploadFailure) error else UploadFailure("BOTA_UPLOAD_TRANSPORT"))
150
+ }
151
+ }
152
+ } catch (error: Exception) {
153
+ if (!retain) operation.check()
154
+ if (error is UploadFailure) throw error
155
+ fail("TRANSPORT")
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,112 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.nio.channels.FileChannel
6
+ import java.nio.file.Files
7
+ import java.nio.file.LinkOption.NOFOLLOW_LINKS
8
+ import java.nio.file.StandardCopyOption.ATOMIC_MOVE
9
+ import java.nio.file.StandardCopyOption.REPLACE_EXISTING
10
+ import java.nio.file.StandardOpenOption.READ
11
+ import java.util.UUID
12
+ import org.json.JSONObject
13
+
14
+ internal data class JournalEntry(val identitySha256: String, val pointer: UploadPointer?, val phase: String = "ready",
15
+ val importedPriorSha256: String? = null, val authorizationNonceSha256: String? = null,
16
+ val recoveryNonceBase64: String? = null) {
17
+ fun json(): JSONObject = (pointer?.json() ?: JSONObject()).put("version", 2)
18
+ .put("identitySha256", identitySha256).put("phase", phase).apply {
19
+ if (importedPriorSha256 != null) put("importedPriorSha256", importedPriorSha256)
20
+ if (authorizationNonceSha256 != null) put("authorizationNonceSha256", authorizationNonceSha256)
21
+ if (recoveryNonceBase64 != null) put("recoveryNonceBase64", recoveryNonceBase64)
22
+ }
23
+ }
24
+
25
+ internal interface JournalDurability {
26
+ fun syncFile(stream: FileOutputStream) { stream.fd.sync() }
27
+ fun replace(source: File, target: File) { Files.move(source.toPath(), target.toPath(), ATOMIC_MOVE, REPLACE_EXISTING) }
28
+ fun syncDirectory(directory: File) { FileChannel.open(directory.toPath(), READ).use { it.force(true) } }
29
+ }
30
+
31
+ internal class UploadJournal(private val root: File, private val durability: JournalDurability = object : JournalDurability {}) {
32
+ private fun directory() {
33
+ val path = root.toPath()
34
+ if (!Files.exists(path, NOFOLLOW_LINKS)) {
35
+ demand(root.mkdir(), "JOURNAL")
36
+ durability.syncDirectory(root.parentFile!!)
37
+ }
38
+ demand(Files.isDirectory(path, NOFOLLOW_LINKS) && !Files.isSymbolicLink(path), "JOURNAL")
39
+ }
40
+ private fun file(key: String): File = File(root, "${hashString(key)}.json")
41
+
42
+ @Synchronized fun load(input: UploadInput): JournalEntry? = sanitized("JOURNAL") {
43
+ directory()
44
+ val file = file(input.journalKey)
45
+ if (!Files.exists(file.toPath(), NOFOLLOW_LINKS)) return@sanitized null
46
+ demand(Files.isRegularFile(file.toPath(), NOFOLLOW_LINKS) && file.length() <= 4096, "JOURNAL")
47
+ val json = JSONObject(file.readText(Charsets.UTF_8))
48
+ val allowed = setOf("version", "identitySha256", "phase", "recordingId", "sessionId", "ownerRevision", "importedPriorSha256", "authorizationNonceSha256", "recoveryNonceBase64")
49
+ demand(json.keys().asSequence().all { it in allowed }, "JOURNAL")
50
+ val version = json.integer("version", 1, 2)
51
+ val identity = hashString(json.string("identitySha256"))
52
+ demand(identity == (if (version == 1L) input.legacyIdentitySha256 else input.identitySha256), "IDENTITY_CONFLICT")
53
+ val phase = json.string("phase")
54
+ demand(phase in setOf("ready", "recording_create_unknown", "session_create_unknown"), "JOURNAL")
55
+ val pointer = if (json.has("recordingId")) UploadPointer.parse(json) else null
56
+ val imported = if (json.has("importedPriorSha256")) hashString(json.string("importedPriorSha256")) else null
57
+ val nonceHash = if (json.has("authorizationNonceSha256")) hashString(json.string("authorizationNonceSha256")) else null
58
+ demand(nonceHash == null || pointer?.sessionId != null, "JOURNAL")
59
+ val recoveryNonce = if (json.has("recoveryNonceBase64")) json.string("recoveryNonceBase64").also { opaque(it, 16) } else null
60
+ demand(recoveryNonce == null || (phase == "ready" && pointer?.sessionId != null), "JOURNAL")
61
+ demand(if (phase == "recording_create_unknown") pointer == null else pointer != null, "JOURNAL")
62
+ demand(phase != "session_create_unknown" || pointer?.sessionId == null, "JOURNAL")
63
+ if (input.prior != null && pointer != null) {
64
+ demand(input.prior.recordingId == pointer.recordingId &&
65
+ (input.prior.sessionId == null || input.prior.fingerprint() == imported ||
66
+ (input.prior.sessionId == pointer.sessionId && input.prior.ownerRevision == pointer.ownerRevision)),
67
+ "IDENTITY_CONFLICT")
68
+ }
69
+ JournalEntry(input.identitySha256, pointer, phase, imported, nonceHash, recoveryNonce).also {
70
+ if (version == 1L) save(input, it)
71
+ }
72
+ }
73
+
74
+ @Synchronized fun save(input: UploadInput, entry: JournalEntry) = sanitized("JOURNAL") {
75
+ directory()
76
+ demand(entry.identitySha256 == input.identitySha256, "IDENTITY_CONFLICT")
77
+ val target = file(input.journalKey)
78
+ demand(!Files.isSymbolicLink(target.toPath()), "JOURNAL")
79
+ val temporary = File(root, ".${input.journalKey}.${UUID.randomUUID()}.tmp")
80
+ try {
81
+ demand(temporary.createNewFile(), "JOURNAL")
82
+ FileOutputStream(temporary).use { stream ->
83
+ stream.write(entry.json().toString().toByteArray(Charsets.UTF_8))
84
+ durability.syncFile(stream)
85
+ }
86
+ durability.replace(temporary, target)
87
+ durability.syncDirectory(root)
88
+ } finally { if (temporary.exists()) temporary.delete() }
89
+ }
90
+
91
+ @Synchronized fun remove(input: UploadInput, expected: UploadPointer) = sanitized("JOURNAL") {
92
+ val current = load(input) ?: fail("IDENTITY_CONFLICT")
93
+ demand(current.phase == "ready" && current.pointer == expected, "IDENTITY_CONFLICT")
94
+ Files.delete(file(input.journalKey).toPath())
95
+ durability.syncDirectory(root)
96
+ }
97
+
98
+ @Synchronized fun rollbackRejectedCreate(input: UploadInput, marker: JournalEntry, previous: JournalEntry?) = sanitized("JOURNAL") {
99
+ demand(load(input) == marker, "IDENTITY_CONFLICT")
100
+ when (marker.phase) {
101
+ "recording_create_unknown" -> demand(previous == null && marker.pointer == null, "IDENTITY_CONFLICT")
102
+ "session_create_unknown" -> demand(previous?.phase == "ready" && previous.copy(phase = marker.phase) == marker, "IDENTITY_CONFLICT")
103
+ else -> fail("IDENTITY_CONFLICT")
104
+ }
105
+ if (previous == null) {
106
+ Files.delete(file(input.journalKey).toPath())
107
+ durability.syncDirectory(root)
108
+ } else {
109
+ save(input, previous)
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,171 @@
1
+ package dev.bota.sdk.reactnative.upload
2
+
3
+ import java.net.URI
4
+ import java.security.MessageDigest
5
+ import java.time.Instant
6
+ import java.util.Base64
7
+ import org.json.JSONArray
8
+ import org.json.JSONObject
9
+
10
+ internal class UploadFailure(val code: String) : Exception(code)
11
+ internal fun fail(code: String): Nothing = throw UploadFailure("BOTA_UPLOAD_$code")
12
+ internal fun demand(value: Boolean, code: String = "INVALID_INPUT") { if (!value) fail(code) }
13
+ internal inline fun <T> sanitized(code: String, block: () -> T): T = try { block() }
14
+ catch (error: UploadFailure) { throw error }
15
+ catch (_: Exception) { fail(code) }
16
+ internal fun digest(value: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(value)
17
+ .joinToString("") { "%02x".format(it.toInt() and 255) }
18
+ internal fun hex(value: String): ByteArray {
19
+ demand(value.length % 2 == 0 && value.matches(Regex("[0-9a-fA-F]*")))
20
+ return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
21
+ }
22
+ internal fun encoded(value: ByteArray): String = Base64.getEncoder().encodeToString(value)
23
+ internal fun opaque(value: String, length: Int, hash: String? = null): ByteArray = sanitized("INVALID_DOCUMENT") {
24
+ val bytes = Base64.getDecoder().decode(value)
25
+ demand(bytes.size == length && encoded(bytes) == value && (hash == null || digest(bytes) == hash), "INVALID_DOCUMENT")
26
+ bytes
27
+ }
28
+ internal fun JSONObject.string(key: String): String = (opt(key) as? String)?.also {
29
+ demand(it.isNotEmpty() && it.length <= 8192)
30
+ } ?: fail("INVALID_INPUT")
31
+ internal fun JSONObject.integer(key: String, minimum: Long = 0, maximum: Long = 9007199254740991L): Long {
32
+ val raw = opt(key) as? Number ?: fail("INVALID_INPUT")
33
+ val text = raw.toString()
34
+ demand(text.matches(Regex("0|[1-9][0-9]*")))
35
+ val number = text.toLongOrNull() ?: fail("INVALID_INPUT")
36
+ demand(number in minimum..maximum)
37
+ return number
38
+ }
39
+ internal fun JSONObject.decimal(key: String, minimum: Long = 0): Long {
40
+ val value = string(key)
41
+ demand(value.matches(Regex("0|[1-9][0-9]*")))
42
+ return (value.toLongOrNull() ?: fail("INVALID_INPUT")).also { demand(it in minimum..9007199254740991L) }
43
+ }
44
+ internal fun uuid(value: String): String = value.also {
45
+ demand(it.matches(Regex("[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")))
46
+ }
47
+ internal fun hashString(value: String): String = value.also { demand(it.matches(Regex("[0-9a-f]{64}"))) }
48
+ internal fun JSONObject.optionalObject(key: String): JSONObject? {
49
+ if (!has(key) || isNull(key)) return null
50
+ return opt(key) as? JSONObject ?: fail("INVALID_INPUT")
51
+ }
52
+ internal fun timestamp(value: String): Long = sanitized("INVALID_DOCUMENT") { Instant.parse(value).toEpochMilli() }
53
+
54
+ internal data class UploadScope(
55
+ val apiOrigin: String, val accountId: String, val projectId: String, val organizationId: String,
56
+ val environment: String, val endUserId: String, val deviceId: String,
57
+ val bindingGeneration: Long, val serialNumber: String, val nativeDeviceId: String, val scopeKey: String,
58
+ val apiBasePath: String = "/v1",
59
+ ) {
60
+ fun identity(includeTransport: Boolean = false): List<Any> = listOf(apiOrigin, accountId, projectId, organizationId, environment, endUserId,
61
+ deviceId, bindingGeneration, serialNumber, if (includeTransport) nativeDeviceId else "", scopeKey, apiBasePath)
62
+
63
+ companion object {
64
+ fun parse(json: JSONObject): UploadScope {
65
+ val origin = json.string("apiOrigin")
66
+ val url = URI(origin)
67
+ demand(url.scheme == "https" && !url.host.isNullOrEmpty() && url.rawUserInfo == null &&
68
+ url.rawQuery == null && url.rawFragment == null && (url.rawPath.isNullOrEmpty() || url.rawPath == "/"))
69
+ fun identifier(key: String) = json.string(key).also { demand(it.matches(Regex("[A-Za-z0-9_-]{1,256}"))) }
70
+ return UploadScope(origin.trimEnd('/'), identifier("accountId"), identifier("projectId"), json.optString("organizationId", "").also { demand(it.isEmpty() || it.matches(Regex("[A-Za-z0-9_-]{1,256}"))) },
71
+ identifier("environment"), identifier("endUserId"), identifier("deviceId"), json.integer("bindingGeneration"),
72
+ json.string("serialNumber"), json.string("nativeDeviceId"), json.string("scopeKey"),
73
+ json.optString("apiBasePath", "/v1").also { demand(it.matches(Regex("(/[A-Za-z0-9_-]+)+"))) })
74
+ }
75
+ }
76
+ }
77
+
78
+ internal data class UploadRecording(
79
+ val uuid: String, val generation: Long, val ciphertextLength: Long, val ciphertextSha256: String,
80
+ val startedAtMs: Long, val durationMs: Long, val plaintextLength: Long, val storageFormat: Long,
81
+ ) {
82
+ fun identity(): List<Any> = listOf(uuid, generation, ciphertextLength.toString(), ciphertextSha256,
83
+ startedAtMs.toString(), durationMs.toString(), plaintextLength.toString(), storageFormat)
84
+
85
+ companion object {
86
+ fun parse(json: JSONObject): UploadRecording = UploadRecording(uuid(json.string("uuid")),
87
+ json.integer("generation", 1, 4294967295), json.decimal("ciphertextLength", 1),
88
+ hashString(json.string("ciphertextSha256")), json.decimal("startedAtMs"), json.decimal("durationMs"),
89
+ json.decimal("plaintextLength"), json.integer("storageFormat", 3, 3)).also {
90
+ demand(it.startedAtMs + it.durationMs <= 8640000000000000L)
91
+ }
92
+ }
93
+ }
94
+
95
+ internal data class UploadPointer(val recordingId: String, val sessionId: String? = null, val ownerRevision: Long? = null) {
96
+ fun fingerprint(): String = digest(JSONArray(listOf(recordingId, sessionId ?: JSONObject.NULL,
97
+ ownerRevision ?: JSONObject.NULL)).toString().toByteArray(Charsets.UTF_8))
98
+ fun json(): JSONObject = JSONObject().put("recordingId", recordingId).apply {
99
+ if (sessionId != null) put("sessionId", sessionId).put("ownerRevision", ownerRevision)
100
+ }
101
+ companion object {
102
+ fun parse(json: JSONObject): UploadPointer {
103
+ val id = json.string("recordingId")
104
+ demand(id.matches(Regex("rec_[A-Za-z0-9_-]+")))
105
+ val hasSession = json.has("sessionId") && !json.isNull("sessionId")
106
+ val hasRevision = json.has("ownerRevision") && !json.isNull("ownerRevision")
107
+ demand(hasSession == hasRevision)
108
+ return UploadPointer(id, if (hasSession) uuid(json.string("sessionId")) else null,
109
+ if (hasRevision) json.integer("ownerRevision", 1, Int.MAX_VALUE.toLong()) else null)
110
+ }
111
+ }
112
+ }
113
+
114
+ internal data class UploadInput(
115
+ val operationId: String, val scope: UploadScope, val recording: UploadRecording,
116
+ val capability: ByteArray, val flags: Long, val journalKey: String, val identitySha256: String,
117
+ val prior: UploadPointer?, val checkpoint: JSONObject?,
118
+ ) {
119
+ val legacyIdentitySha256: String get() = digest(JSONArray(scope.identity(includeTransport = true) +
120
+ recording.identity()).toString().toByteArray(Charsets.UTF_8))
121
+
122
+ fun validateCheckpoint(pointer: UploadPointer?) {
123
+ val cp = checkpoint ?: return
124
+ sanitized("IDENTITY_CONFLICT") {
125
+ demand(pointer?.sessionId != null && pointer.ownerRevision != null, "IDENTITY_CONFLICT")
126
+ val session = uuid(cp.string("uploadSessionId"))
127
+ cp.integer("version", 1, 1)
128
+ val revision = cp.integer("ownerRevision", 1, Int.MAX_VALUE.toLong())
129
+ demand(revision <= pointer!!.ownerRevision!! &&
130
+ ((revision == pointer.ownerRevision) == (session == pointer.sessionId)), "IDENTITY_CONFLICT")
131
+ demand(cp.decimal("nextCiphertextOffset") <= recording.ciphertextLength, "IDENTITY_CONFLICT")
132
+ hashString(cp.string("prefixSha256"))
133
+ cp.integer("revision", 1, 4294967295)
134
+ val transport = cp.string("transportSessionId")
135
+ demand(transport.matches(Regex("[1-9][0-9]*")) && transport.toULongOrNull() != null)
136
+ cp.string("sinkRegistrationId")
137
+ cp.integer("windowPackets", 1, 65535)
138
+ cp.integer("dataPayloadBytes", 1, 65535)
139
+ if (cp.has("highestContiguousSequence") && !cp.isNull("highestContiguousSequence"))
140
+ cp.integer("highestContiguousSequence", 0, 4294967295)
141
+ // Legacy identity fields, if supplied during migration, cannot contradict this recording.
142
+ if (cp.has("recordingUuid")) demand(cp.string("recordingUuid") == recording.uuid, "IDENTITY_CONFLICT")
143
+ if (cp.has("recordingGeneration")) demand(cp.integer("recordingGeneration") == recording.generation, "IDENTITY_CONFLICT")
144
+ if (cp.has("ciphertextLength")) demand(cp.decimal("ciphertextLength") == recording.ciphertextLength, "IDENTITY_CONFLICT")
145
+ if (cp.has("ciphertextSha256")) demand(cp.string("ciphertextSha256") == recording.ciphertextSha256, "IDENTITY_CONFLICT")
146
+ }
147
+ }
148
+
149
+ companion object {
150
+ fun parse(text: String): UploadInput = sanitized("INVALID_INPUT") {
151
+ demand(text.length <= 32768)
152
+ val json = JSONObject(text)
153
+ val operation = json.string("operationId").also { demand(it.length <= 256) }
154
+ val scope = UploadScope.parse(json.getJSONObject("scope"))
155
+ val recording = UploadRecording.parse(json.getJSONObject("recording"))
156
+ val capability = json.getJSONObject("capability")
157
+ val bytes = hex(capability.string("rawValueHex"))
158
+ demand(bytes.size == 24)
159
+
160
+ val keyFields = listOf(scope.scopeKey, scope.deviceId, scope.bindingGeneration, recording.uuid,
161
+ recording.generation, recording.ciphertextLength.toString(), recording.ciphertextSha256)
162
+ // Android org.json escapes slashes; the existing JS journal key uses JSON.stringify.
163
+ val keyJson = JSONArray(keyFields).toString().replace("\\/", "/")
164
+ val key = digest(keyJson.toByteArray(Charsets.UTF_8))
165
+ if (json.has("journalKey")) demand(hashString(json.string("journalKey")) == key, "IDENTITY_CONFLICT")
166
+ val identity = digest(JSONArray(scope.identity() + recording.identity()).toString().toByteArray(Charsets.UTF_8))
167
+ UploadInput(operation, scope, recording, bytes, capability.integer("flags", 0, 4294967295), key, identity,
168
+ json.optionalObject("priorJournalEntry")?.let(UploadPointer::parse), json.optionalObject("checkpoint"))
169
+ }
170
+ }
171
+ }
@@ -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.10",
4
+ "packageVersion": "2.0.0-beta.12",
5
5
  "reactNativeVersion": "0.86.3",
6
6
  "codegenLibrary": "BotaDeviceSDKSpec",
7
7
  "nativeModule": "BotaDeviceSDK",
@@ -4108,6 +4108,161 @@
4108
4108
  ]
4109
4109
  },
4110
4110
  "moduleName": "BotaDeviceSDK"
4111
+ },
4112
+ "NativeBotaUploadV2Backend": {
4113
+ "type": "NativeModule",
4114
+ "aliasMap": {},
4115
+ "enumMap": {},
4116
+ "spec": {
4117
+ "eventEmitters": [
4118
+ {
4119
+ "name": "onCredentialsRequested",
4120
+ "optional": false,
4121
+ "typeAnnotation": {
4122
+ "type": "EventEmitterTypeAnnotation",
4123
+ "typeAnnotation": {
4124
+ "type": "ObjectTypeAnnotation",
4125
+ "properties": [
4126
+ {
4127
+ "name": "requestId",
4128
+ "optional": false,
4129
+ "typeAnnotation": {
4130
+ "type": "StringTypeAnnotation"
4131
+ }
4132
+ },
4133
+ {
4134
+ "name": "operationId",
4135
+ "optional": false,
4136
+ "typeAnnotation": {
4137
+ "type": "StringTypeAnnotation"
4138
+ }
4139
+ }
4140
+ ]
4141
+ }
4142
+ }
4143
+ }
4144
+ ],
4145
+ "methods": [
4146
+ {
4147
+ "name": "prepare",
4148
+ "optional": false,
4149
+ "typeAnnotation": {
4150
+ "type": "FunctionTypeAnnotation",
4151
+ "returnTypeAnnotation": {
4152
+ "type": "PromiseTypeAnnotation",
4153
+ "elementType": {
4154
+ "type": "StringTypeAnnotation"
4155
+ }
4156
+ },
4157
+ "params": [
4158
+ {
4159
+ "name": "inputJSON",
4160
+ "optional": false,
4161
+ "typeAnnotation": {
4162
+ "type": "StringTypeAnnotation"
4163
+ }
4164
+ }
4165
+ ]
4166
+ }
4167
+ },
4168
+ {
4169
+ "name": "cancel",
4170
+ "optional": false,
4171
+ "typeAnnotation": {
4172
+ "type": "FunctionTypeAnnotation",
4173
+ "returnTypeAnnotation": {
4174
+ "type": "PromiseTypeAnnotation",
4175
+ "elementType": {
4176
+ "type": "VoidTypeAnnotation"
4177
+ }
4178
+ },
4179
+ "params": [
4180
+ {
4181
+ "name": "operationId",
4182
+ "optional": false,
4183
+ "typeAnnotation": {
4184
+ "type": "StringTypeAnnotation"
4185
+ }
4186
+ }
4187
+ ]
4188
+ }
4189
+ },
4190
+ {
4191
+ "name": "complete",
4192
+ "optional": false,
4193
+ "typeAnnotation": {
4194
+ "type": "FunctionTypeAnnotation",
4195
+ "returnTypeAnnotation": {
4196
+ "type": "PromiseTypeAnnotation",
4197
+ "elementType": {
4198
+ "type": "VoidTypeAnnotation"
4199
+ }
4200
+ },
4201
+ "params": [
4202
+ {
4203
+ "name": "operationId",
4204
+ "optional": false,
4205
+ "typeAnnotation": {
4206
+ "type": "StringTypeAnnotation"
4207
+ }
4208
+ }
4209
+ ]
4210
+ }
4211
+ },
4212
+ {
4213
+ "name": "resolveCredentials",
4214
+ "optional": false,
4215
+ "typeAnnotation": {
4216
+ "type": "FunctionTypeAnnotation",
4217
+ "returnTypeAnnotation": {
4218
+ "type": "PromiseTypeAnnotation",
4219
+ "elementType": {
4220
+ "type": "VoidTypeAnnotation"
4221
+ }
4222
+ },
4223
+ "params": [
4224
+ {
4225
+ "name": "requestId",
4226
+ "optional": false,
4227
+ "typeAnnotation": {
4228
+ "type": "StringTypeAnnotation"
4229
+ }
4230
+ },
4231
+ {
4232
+ "name": "token",
4233
+ "optional": false,
4234
+ "typeAnnotation": {
4235
+ "type": "StringTypeAnnotation"
4236
+ }
4237
+ }
4238
+ ]
4239
+ }
4240
+ },
4241
+ {
4242
+ "name": "rejectCredentials",
4243
+ "optional": false,
4244
+ "typeAnnotation": {
4245
+ "type": "FunctionTypeAnnotation",
4246
+ "returnTypeAnnotation": {
4247
+ "type": "PromiseTypeAnnotation",
4248
+ "elementType": {
4249
+ "type": "VoidTypeAnnotation"
4250
+ }
4251
+ },
4252
+ "params": [
4253
+ {
4254
+ "name": "requestId",
4255
+ "optional": false,
4256
+ "typeAnnotation": {
4257
+ "type": "StringTypeAnnotation"
4258
+ }
4259
+ }
4260
+ ]
4261
+ }
4262
+ }
4263
+ ]
4264
+ },
4265
+ "moduleName": "BotaUploadV2Backend"
4111
4266
  }
4112
4267
  }
4113
4268
  },
@@ -4117,13 +4272,17 @@
4117
4272
  "path": "java/dev/bota/sdk/reactnative/NativeBotaDeviceSDKSpec.java",
4118
4273
  "sha256": "3f1acef6907956745c7db721b5ec768a7d099da4a8e85bc50ae960451c261e2d"
4119
4274
  },
4275
+ {
4276
+ "path": "java/dev/bota/sdk/reactnative/NativeBotaUploadV2BackendSpec.java",
4277
+ "sha256": "50844eb0e1ed98848de2819d2a88f00ccc4a550d4bcfdc5db21039a1eb17127f"
4278
+ },
4120
4279
  {
4121
4280
  "path": "jni/BotaDeviceSDKSpec-generated.cpp",
4122
- "sha256": "544e060e90cf43640d48192c784a354c26c96ea47950a58967a70bbbfd010e38"
4281
+ "sha256": "f1a82f3cf03616e6f08655204af85317087cd2b0f633c052f2939eb49448c6b8"
4123
4282
  },
4124
4283
  {
4125
4284
  "path": "jni/BotaDeviceSDKSpec.h",
4126
- "sha256": "e82597a93c10fd0b8cded44c655abc919235f347879dae756545616925ab2c00"
4285
+ "sha256": "a4809b54c65c499c2ad727df1461d2044cfcb6de1eb3bbb05582e160426effce"
4127
4286
  },
4128
4287
  {
4129
4288
  "path": "jni/CMakeLists.txt",
@@ -4131,23 +4290,23 @@
4131
4290
  },
4132
4291
  {
4133
4292
  "path": "jni/react/renderer/components/BotaDeviceSDKSpec/BotaDeviceSDKSpecJSI.h",
4134
- "sha256": "23446d334170a7dab1df1d655e942e052b857ba286a7d18e1729301182ed27f3"
4293
+ "sha256": "6c5309bc7b6b6b4b2e62e5bed85a7fab8a9da8c43af9e3904692e50756de9e0f"
4135
4294
  }
4136
4295
  ],
4137
4296
  "ios": [
4138
4297
  {
4139
4298
  "path": "BotaDeviceSDKSpec/BotaDeviceSDKSpec-generated.mm",
4140
- "sha256": "26fe0dc191f982056702506a2159a7ee813241333434f0c90e7306b9a9c70a00"
4299
+ "sha256": "8bee25e75f2e73f0b989b65e1c083fb851ba77ca3d4384aecaabb1bcb252fe7a"
4141
4300
  },
4142
4301
  {
4143
4302
  "path": "BotaDeviceSDKSpec/BotaDeviceSDKSpec.h",
4144
- "sha256": "5a680da391ab9fabbe0422bf692be4023ea26575c86231657a17518d22ef3260"
4303
+ "sha256": "6bef81e2a87bb83ddbc3021e69ddbd280dac23a5049af675b738925454b1c22c"
4145
4304
  },
4146
4305
  {
4147
4306
  "path": "BotaDeviceSDKSpecJSI.h",
4148
- "sha256": "23446d334170a7dab1df1d655e942e052b857ba286a7d18e1729301182ed27f3"
4307
+ "sha256": "6c5309bc7b6b6b4b2e62e5bed85a7fab8a9da8c43af9e3904692e50756de9e0f"
4149
4308
  }
4150
4309
  ]
4151
4310
  },
4152
- "contractDigest": "058deff0ead08281d386027a52b699bca6b42bd9549d55c1e7a659eccefa8aab"
4311
+ "contractDigest": "0378337ad43dd8f3d04c6df7713d4c7805779f15befb830951437c6f60d057fe"
4153
4312
  }