@hot-updater/react-native 1.0.7 → 1.1.0

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 (144) hide show
  1. package/HotUpdater.podspec +14 -2
  2. package/Package.swift +24 -3
  3. package/android/build.gradle +2 -6
  4. package/android/hot-updater-fingerprint.gradle +135 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
  6. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
  7. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
  9. package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
  10. package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
  12. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
  13. package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
  14. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
  15. package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
  16. package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
  17. package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
  18. package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
  19. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  143. package/android/src/newarch/ReactIntegrationManager.kt +0 -185
  144. package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
@@ -0,0 +1,405 @@
1
+ package com.hotupdater
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+ import java.net.MalformedURLException
6
+ import java.net.URL
7
+ import java.util.Locale
8
+
9
+ /**
10
+ * How native downloads a Bundle, as httpClient.ts resolves it, its assets
11
+ * parsed like the TurboModule's `updateBundle`. In artifact protocol v1,
12
+ * [assets] holds a URL for every file. In v2, it holds only the Hermes patch,
13
+ * and the staging asks [filesUrl] for the URLs of the files it downloads.
14
+ */
15
+ internal class ResolvedArtifact(
16
+ val manifestUrl: String,
17
+ val manifestFileHash: String,
18
+ val archiveUrl: String?,
19
+ val assets: Map<String, ChangedAssetDescriptor>,
20
+ val filesUrl: String? = null,
21
+ )
22
+
23
+ /**
24
+ * The catalog and artifact requests of httpClient.ts, with the catalog's ETag
25
+ * cache of releaseCatalogCache.ts kept in the native release catalog cache.
26
+ */
27
+ internal class ReleaseCatalogClient(
28
+ private val state: UpdateCheckState,
29
+ private val transport: UpdateCheckTransport,
30
+ ) {
31
+ companion object {
32
+ private const val CACHE_FORMAT_VERSION = "1"
33
+ private const val MAX_ETAG_BYTES = 1024
34
+ private const val MAX_CACHE_ENTRY_BYTES = ReleaseCatalogs.MAX_RELEASE_CATALOG_WIRE_BYTES + MAX_ETAG_BYTES + 3
35
+ private const val ARTIFACT_PROTOCOL_VERSION = 1.0
36
+ private const val ARTIFACT_FILES_PROTOCOL_VERSION = 2.0
37
+ private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
38
+ private val HTTP_URL = Regex("^https?://.*", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
39
+ }
40
+
41
+ private class CachedCatalog(
42
+ val catalog: ReleaseCatalog,
43
+ val etag: String,
44
+ )
45
+
46
+ /**
47
+ * Fetches a scope's catalog, or null when the server answers that the scope
48
+ * has none (a 404 with `x-hot-updater-catalog: none`).
49
+ */
50
+ suspend fun fetchReleaseCatalog(
51
+ baseURL: String,
52
+ url: String,
53
+ expectedScope: ReleaseCatalogScope,
54
+ requestHeaders: Map<String, String>,
55
+ requestTimeoutMillis: Long,
56
+ ): ReleaseCatalog? {
57
+ val partition = cachePartition(baseURL, url, requestHeaders)
58
+ val cached = readValidatedCache(partition, expectedScope)
59
+ val response = fetchCatalogResponse(url, requestHeaders, requestTimeoutMillis, cached?.etag)
60
+
61
+ if (response.status == 304) {
62
+ if (cached != null) return cached.catalog
63
+
64
+ val repairResponse = fetchCatalogResponse(url, requestHeaders, requestTimeoutMillis, null)
65
+ return consumeSuccessfulResponse(repairResponse, expectedScope, partition)
66
+ }
67
+
68
+ return consumeSuccessfulResponse(response, expectedScope, partition)
69
+ }
70
+
71
+ suspend fun resolveArtifact(
72
+ baseURL: String,
73
+ currentBundleId: String,
74
+ targetBundleId: String,
75
+ requestHeaders: Map<String, String>,
76
+ requestTimeoutMillis: Long,
77
+ ): ResolvedArtifact {
78
+ val headers = LinkedHashMap(effectiveHeaders(requestHeaders))
79
+ if (!headers.containsKey("accept")) headers["accept"] = "application/json"
80
+ val target = encodeURIComponent(targetBundleId)
81
+ val route = "$target/from/${encodeURIComponent(currentBundleId)}"
82
+ val response =
83
+ transport.get(
84
+ UpdateCheckRequest(
85
+ url = "$baseURL/artifacts/v2/$route",
86
+ headers = headers,
87
+ timeoutMillis = requestTimeoutMillis,
88
+ resource = "artifact",
89
+ ),
90
+ )
91
+ // Legacy: artifact protocol v1: a server older than v2 has no such
92
+ // route. Remove this fallback once no supported server predates v2.
93
+ if (response.status == 404) {
94
+ return resolveArtifactV1(baseURL, route, headers, requestTimeoutMillis)
95
+ }
96
+ if (response.status != 200) {
97
+ throw UpdateHttpException(response.status, response.statusText)
98
+ }
99
+ return parseArtifactV2(
100
+ response.body ?: throw InvalidUpdateResponseException("Received an empty artifact response"),
101
+ filesUrl = "$baseURL/artifacts/v2/$target/files",
102
+ )
103
+ }
104
+
105
+ // Legacy: artifact protocol v1: reads a server that predates v2; remove with the fallback.
106
+ private suspend fun resolveArtifactV1(
107
+ baseURL: String,
108
+ route: String,
109
+ headers: Map<String, String>,
110
+ requestTimeoutMillis: Long,
111
+ ): ResolvedArtifact {
112
+ val response =
113
+ transport.get(
114
+ UpdateCheckRequest(
115
+ url = "$baseURL/artifacts/v1/$route",
116
+ headers = headers,
117
+ timeoutMillis = requestTimeoutMillis,
118
+ resource = "artifact",
119
+ ),
120
+ )
121
+ if (response.status != 200) {
122
+ throw UpdateHttpException(response.status, response.statusText)
123
+ }
124
+ return parseArtifact(response.body ?: throw InvalidUpdateResponseException("Received an empty artifact response"))
125
+ }
126
+
127
+ /** `requireArtifactProtocolV2` of httpClient.ts; the patch becomes its asset's only entry. */
128
+ private fun parseArtifactV2(
129
+ body: String,
130
+ filesUrl: String,
131
+ ): ResolvedArtifact {
132
+ // As JSON.parse reads it, which org.json's parser does not.
133
+ val info =
134
+ StrictJson.parse(body) as? JSONObject
135
+ ?: throw InvalidUpdateResponseException("Received an invalid artifact response")
136
+ val protocolVersion = info.opt("artifactProtocolVersion")
137
+ val manifestUrl = info.opt("manifestUrl") as? String
138
+ val manifestFileHash = info.opt("manifestFileHash") as? String
139
+ val archiveUrl = info.opt("archiveUrl")
140
+ val patch = info.opt("patch")
141
+ val patchObject = patch as? JSONObject
142
+ if (
143
+ protocolVersion !is Number ||
144
+ protocolVersion.toDouble() != ARTIFACT_FILES_PROTOCOL_VERSION ||
145
+ manifestUrl.isNullOrEmpty() ||
146
+ manifestFileHash.isNullOrEmpty() ||
147
+ (archiveUrl != null && archiveUrl !is String) ||
148
+ (
149
+ patch != null &&
150
+ (
151
+ (patchObject?.opt("assetPath") as? String).isNullOrEmpty() ||
152
+ (patchObject?.opt("patchUrl") as? String).isNullOrEmpty()
153
+ )
154
+ )
155
+ ) {
156
+ throw InvalidUpdateResponseException("Server does not support artifact protocol 2.")
157
+ }
158
+ requireArtifactUrl(manifestUrl)
159
+ val archive = (archiveUrl as? String)?.takeIf { it.isNotEmpty() }
160
+ archive?.let(::requireArtifactUrl)
161
+ val assets = linkedMapOf<String, ChangedAssetDescriptor>()
162
+ if (patchObject != null) {
163
+ // An incomplete patch is skipped, as the TurboModule's asset parsing skips it.
164
+ parsePatch(patchObject)?.let {
165
+ assets[patchObject.getString("assetPath")] = ChangedAssetDescriptor(fileUrl = null, fileHash = "", patch = it)
166
+ }
167
+ }
168
+ return ResolvedArtifact(
169
+ manifestUrl = manifestUrl,
170
+ manifestFileHash = manifestFileHash,
171
+ archiveUrl = archive,
172
+ assets = assets,
173
+ filesUrl = filesUrl,
174
+ )
175
+ }
176
+
177
+ /** Any stable string per server, route, and headers; it need not match JavaScript's partition. */
178
+ private fun cachePartition(
179
+ baseURL: String,
180
+ url: String,
181
+ requestHeaders: Map<String, String>,
182
+ ): String {
183
+ val headers = JSONArray()
184
+ effectiveHeaders(requestHeaders)
185
+ .entries
186
+ .sortedBy { it.key }
187
+ .forEach { (name, value) -> headers.put(JSONArray().put(name).put(value)) }
188
+ return "native:" +
189
+ JSONObject()
190
+ .put("baseURL", baseURL)
191
+ .put("url", url)
192
+ .put("headers", headers)
193
+ .toString()
194
+ }
195
+
196
+ private suspend fun fetchCatalogResponse(
197
+ url: String,
198
+ requestHeaders: Map<String, String>,
199
+ requestTimeoutMillis: Long,
200
+ etag: String?,
201
+ ): UpdateCheckResponse {
202
+ val headers = LinkedHashMap<String, String>()
203
+ effectiveHeaders(requestHeaders).forEach { (name, value) ->
204
+ if (name != "accept" && name != "if-none-match") headers[name] = value
205
+ }
206
+ headers["accept"] = "application/json"
207
+ if (etag != null) headers["if-none-match"] = etag
208
+ return transport.get(UpdateCheckRequest(url, headers, requestTimeoutMillis))
209
+ }
210
+
211
+ private fun readValidatedCache(
212
+ partition: String,
213
+ expectedScope: ReleaseCatalogScope,
214
+ ): CachedCatalog? {
215
+ val value = state.getReleaseCatalogCache(partition) ?: return null
216
+ val cached = parseCache(value, expectedScope)
217
+ if (cached == null) {
218
+ state.removeReleaseCatalogCache(partition)
219
+ }
220
+ return cached
221
+ }
222
+
223
+ private fun parseCache(
224
+ value: String,
225
+ expectedScope: ReleaseCatalogScope,
226
+ ): CachedCatalog? {
227
+ val versionEnd = value.indexOf('\n')
228
+ val etagEnd = value.indexOf('\n', versionEnd + 1)
229
+ if (versionEnd == -1 || etagEnd == -1 || value.substring(0, versionEnd) != CACHE_FORMAT_VERSION) {
230
+ return null
231
+ }
232
+ val etag = value.substring(versionEnd + 1, etagEnd)
233
+ if (!isValidETag(etag)) return null
234
+ val catalog = ReleaseCatalogs.parse(value.substring(etagEnd + 1), expectedScope) ?: return null
235
+ return CachedCatalog(catalog, etag)
236
+ }
237
+
238
+ private fun consumeSuccessfulResponse(
239
+ response: UpdateCheckResponse,
240
+ expectedScope: ReleaseCatalogScope,
241
+ partition: String,
242
+ ): ReleaseCatalog? {
243
+ // The server marks the 404 of a scope that has no catalog yet: no update,
244
+ // not a failure. Any other 404 is a wrong baseURL or route.
245
+ if (
246
+ response.status == 404 &&
247
+ response.header("x-hot-updater-catalog")?.trimJsWhitespace()?.lowercase(Locale.ROOT) == "none"
248
+ ) {
249
+ return null
250
+ }
251
+ if (response.status != 200) {
252
+ throw UpdateHttpException(response.status, response.statusText)
253
+ }
254
+
255
+ val body = response.body
256
+ val catalog = body?.let { ReleaseCatalogs.parse(it, expectedScope) }
257
+ if (body == null || catalog == null) {
258
+ state.removeReleaseCatalogCache(partition)
259
+ throw InvalidUpdateResponseException("Received an invalid Release catalog")
260
+ }
261
+
262
+ val etag = response.header("etag")
263
+ if (isValidETag(etag)) {
264
+ val serialized = "$CACHE_FORMAT_VERSION\n$etag\n$body"
265
+ if (ReleaseCatalogs.utf8ByteLength(serialized) > MAX_CACHE_ENTRY_BYTES) {
266
+ state.removeReleaseCatalogCache(partition)
267
+ return catalog
268
+ }
269
+ state.setReleaseCatalogCache(partition, serialized)
270
+ } else {
271
+ state.removeReleaseCatalogCache(partition)
272
+ }
273
+ return catalog
274
+ }
275
+
276
+ private fun isValidETag(value: String?): Boolean =
277
+ value != null &&
278
+ value.isNotEmpty() &&
279
+ !value.contains('\r') &&
280
+ !value.contains('\n') &&
281
+ ReleaseCatalogs.utf8ByteLength(value) <= MAX_ETAG_BYTES
282
+
283
+ /** `requireArtifactProtocolV1` and `requireArtifactUrls` of httpClient.ts, then the TurboModule's asset parsing. */
284
+ private fun parseArtifact(body: String): ResolvedArtifact {
285
+ // As JSON.parse reads it, which org.json's parser does not.
286
+ val info =
287
+ StrictJson.parse(body) as? JSONObject
288
+ ?: throw InvalidUpdateResponseException("Received an invalid artifact response")
289
+ val protocolVersion = info.opt("artifactProtocolVersion")
290
+ val assets = info.opt("assets") as? JSONObject
291
+ val manifestUrl = info.opt("manifestUrl") as? String
292
+ val manifestFileHash = info.opt("manifestFileHash") as? String
293
+ val archiveUrl = info.opt("archiveUrl")
294
+ if (
295
+ protocolVersion !is Number ||
296
+ protocolVersion.toDouble() != ARTIFACT_PROTOCOL_VERSION ||
297
+ assets == null ||
298
+ manifestUrl.isNullOrEmpty() ||
299
+ manifestFileHash.isNullOrEmpty() ||
300
+ (archiveUrl != null && archiveUrl !is String)
301
+ ) {
302
+ throw InvalidUpdateResponseException("Server does not support artifact protocol 1.")
303
+ }
304
+ val assetObjects =
305
+ assets
306
+ .keys()
307
+ .asSequence()
308
+ .map { it to assets.opt(it) }
309
+ .toList()
310
+ for ((_, asset) in assetObjects) {
311
+ val file = (asset as? JSONObject)?.opt("file") as? JSONObject
312
+ val fileUrl = file?.opt("url") as? String
313
+ val fileHash = (asset as? JSONObject)?.opt("fileHash") as? String
314
+ if (fileUrl.isNullOrEmpty() || fileHash.isNullOrEmpty()) {
315
+ throw InvalidUpdateResponseException("Artifact protocol 1 requires an original file for every asset.")
316
+ }
317
+ }
318
+
319
+ requireArtifactUrl(manifestUrl)
320
+ val archive = (archiveUrl as? String)?.takeIf { it.isNotEmpty() }
321
+ archive?.let(::requireArtifactUrl)
322
+ val parsedAssets = linkedMapOf<String, ChangedAssetDescriptor>()
323
+ for ((path, value) in assetObjects) {
324
+ val asset = value as JSONObject
325
+ val file = asset.getJSONObject("file")
326
+ val fileUrl = requireArtifactUrl(file.getString("url"))
327
+ val patch =
328
+ when (val patchValue = asset.opt("patch")) {
329
+ null, JSONObject.NULL, false -> null
330
+ is JSONObject -> parsePatch(patchValue)
331
+ else -> throw InvalidUpdateResponseException("Artifact URLs must be absolute HTTP(S) URLs.")
332
+ }
333
+ parsedAssets[path] =
334
+ ChangedAssetDescriptor(
335
+ fileUrl = fileUrl,
336
+ fileHash = asset.getString("fileHash"),
337
+ fileCompression = file.opt("compression") as? String,
338
+ patch = patch,
339
+ )
340
+ }
341
+ return ResolvedArtifact(
342
+ manifestUrl = manifestUrl,
343
+ manifestFileHash = manifestFileHash,
344
+ archiveUrl = archive,
345
+ assets = parsedAssets,
346
+ )
347
+ }
348
+
349
+ private fun parsePatch(patch: JSONObject): BsdiffPatchDescriptor? {
350
+ // A patch's URL is checked even when the rest is incomplete.
351
+ requireArtifactUrl(patch.opt("patchUrl") as? String ?: "")
352
+ val algorithm = patch.opt("algorithm") as? String
353
+ val baseBundleId = patch.opt("baseBundleId") as? String
354
+ val baseFileHash = patch.opt("baseFileHash") as? String
355
+ val patchFileHash = patch.opt("patchFileHash") as? String
356
+ val patchUrl = patch.opt("patchUrl") as? String
357
+ if (algorithm == null || baseBundleId == null || baseFileHash == null || patchFileHash == null || patchUrl == null) {
358
+ return null
359
+ }
360
+ return BsdiffPatchDescriptor(
361
+ algorithm = algorithm,
362
+ baseBundleId = baseBundleId,
363
+ baseFileHash = baseFileHash,
364
+ patchFileHash = patchFileHash,
365
+ patchUrl = patchUrl,
366
+ byteSize = readSafeInteger(patch.opt("byteSize")),
367
+ )
368
+ }
369
+
370
+ private fun readSafeInteger(value: Any?): Long? {
371
+ if (value !is Number) return null
372
+ val number = value.toDouble()
373
+ if (!number.isFinite() || number % 1.0 != 0.0) return null
374
+ return number.toLong().takeIf { it in 0..MAX_SAFE_INTEGER }
375
+ }
376
+
377
+ /** A URL the device downloads from: the server returns each one absolute. */
378
+ private fun requireArtifactUrl(value: String): String {
379
+ if (!HTTP_URL.matches(value)) {
380
+ throw InvalidUpdateResponseException("Artifact URLs must be absolute HTTP(S) URLs.")
381
+ }
382
+ val url =
383
+ try {
384
+ URL(value)
385
+ } catch (error: MalformedURLException) {
386
+ throw InvalidUpdateResponseException("Invalid artifact URL: $value", error)
387
+ }
388
+ // java.net.URL parses a hostless "https://"; a download needs a host.
389
+ if (url.host.isNullOrEmpty()) {
390
+ throw InvalidUpdateResponseException("Invalid artifact URL: $value")
391
+ }
392
+ return value
393
+ }
394
+ }
395
+
396
+ /**
397
+ * The headers a request sends: names are case-insensitive and lowercased, and
398
+ * the last value of a name wins, as OkHttp's `header` replaces it. Request
399
+ * identities use these, so two maps that send the same headers share one.
400
+ */
401
+ internal fun effectiveHeaders(headers: Map<String, String>): Map<String, String> {
402
+ val effective = LinkedHashMap<String, String>()
403
+ headers.forEach { (name, value) -> effective[name.lowercase(Locale.ROOT)] = value }
404
+ return effective
405
+ }