@hot-updater/react-native 1.0.5 → 1.0.7

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 (73) hide show
  1. package/HotUpdater.podspec +24 -3
  2. package/Package.swift +100 -0
  3. package/android/build.gradle +3 -4
  4. package/android/hot-updater-builtin-assets.gradle +67 -0
  5. package/android/hot-updater-min-bundle-id.gradle +69 -0
  6. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +85 -0
  7. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +116 -11
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +117 -12
  9. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +5 -52
  10. package/android/src/newarch/HotUpdaterModule.kt +129 -34
  11. package/android/src/oldarch/HotUpdaterModule.kt +130 -34
  12. package/android/src/oldarch/HotUpdaterSpec.kt +5 -0
  13. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +69 -0
  14. package/ios/HotUpdater/Interface/HotUpdaterInterface.m +3 -0
  15. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +87 -0
  16. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +88 -13
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +90 -90
  18. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +154 -52
  19. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +26 -0
  20. package/ios/HotUpdater/Internal/HotUpdaterMinBundleId.m +10 -0
  21. package/ios/HotUpdater/Package.swift +88 -0
  22. package/ios/Plugins/HotUpdaterMinBundleIdPlugin/HotUpdaterMinBundleIdPlugin.swift +28 -0
  23. package/ios/scripts/generate-min-bundle-id.zsh +49 -0
  24. package/lib/commonjs/checkForUpdate.js +17 -2
  25. package/lib/commonjs/checkForUpdate.js.map +1 -1
  26. package/lib/commonjs/fetchUpdateResponse.js +5 -2
  27. package/lib/commonjs/fetchUpdateResponse.js.map +1 -1
  28. package/lib/commonjs/httpClient.js +58 -5
  29. package/lib/commonjs/httpClient.js.map +1 -1
  30. package/lib/commonjs/native.js +42 -7
  31. package/lib/commonjs/native.js.map +1 -1
  32. package/lib/commonjs/sdkVersion.js +1 -1
  33. package/lib/commonjs/specs/NativeHotUpdater.js +5 -0
  34. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  35. package/lib/module/checkForUpdate.js +18 -3
  36. package/lib/module/checkForUpdate.js.map +1 -1
  37. package/lib/module/fetchUpdateResponse.js +4 -1
  38. package/lib/module/fetchUpdateResponse.js.map +1 -1
  39. package/lib/module/httpClient.js +61 -7
  40. package/lib/module/httpClient.js.map +1 -1
  41. package/lib/module/native.js +41 -7
  42. package/lib/module/native.js.map +1 -1
  43. package/lib/module/sdkVersion.js +1 -1
  44. package/lib/module/specs/NativeHotUpdater.js +7 -0
  45. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  46. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
  48. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/httpClient.d.ts +26 -2
  50. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/native.d.ts +17 -3
  52. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  54. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +37 -1
  55. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  56. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  57. package/lib/typescript/module/fetchUpdateResponse.d.ts +2 -0
  58. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -1
  59. package/lib/typescript/module/httpClient.d.ts +26 -2
  60. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  61. package/lib/typescript/module/native.d.ts +17 -3
  62. package/lib/typescript/module/native.d.ts.map +1 -1
  63. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  64. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +37 -1
  65. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  66. package/package.json +5 -4
  67. package/react-native.config.js +7 -0
  68. package/src/checkForUpdate.ts +19 -3
  69. package/src/fetchUpdateResponse.ts +4 -1
  70. package/src/httpClient.ts +88 -9
  71. package/src/native.ts +53 -9
  72. package/src/sdkVersion.ts +1 -1
  73. package/src/specs/NativeHotUpdater.ts +39 -1
@@ -124,6 +124,7 @@ interface BundleStorageService {
124
124
  manifestFileHash: String,
125
125
  assets: Map<String, ChangedAssetDescriptor>,
126
126
  archiveUrl: String? = null,
127
+ fileResolver: ArtifactFileResolver? = null,
127
128
  progressCallback: (UpdateProgressPayload) -> Unit,
128
129
  ): UpdateBundleResult
129
130
 
@@ -239,6 +240,16 @@ class BundleFileStorageService(
239
240
  private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
240
241
  private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
241
242
 
243
+ /** Files downloaded at once when the archive is not used. */
244
+ private const val PARALLEL_FILE_DOWNLOADS = 16
245
+
246
+ /**
247
+ * What one request costs beyond the first [PARALLEL_FILE_DOWNLOADS], in
248
+ * bytes: each waits a round trip for a free slot, about 100 ms, which
249
+ * at 5 MB/s over 16 slots is the time of 32 KiB.
250
+ */
251
+ private const val REQUEST_OVERHEAD_BYTES = 32L * 1024
252
+
242
253
  /** A patch produced a file that does not match the target, or none at all. */
243
254
  private val PATCH_FAILURE =
244
255
  UpdateFailure(UpdateFailureStage.INSTALL, UpdateFailureReason.PATCH, resource = UpdateFailureResource.PATCH)
@@ -718,10 +729,35 @@ class BundleFileStorageService(
718
729
  if (resolveBundleFile(bundleDir, bundleId) == null) return false
719
730
  return manifest.assets.all { (assetPath, asset) ->
720
731
  val file = RelativePathResolver.resolveInside(bundleDir, assetPath)
721
- file?.isFile == true && runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess
732
+ file?.isFile == true &&
733
+ (
734
+ runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess ||
735
+ isBuiltInEquivalent(assetPath, file, asset)
736
+ )
722
737
  }
723
738
  }
724
739
 
740
+ /**
741
+ * Whether [file] holds the packaged bytes of the built-in asset the native
742
+ * build recorded with the manifest's source hash, as AAPT2 re-encodes a
743
+ * PNG. Both come from the APK, never from app storage. The manifest's
744
+ * signature still covers that source hash.
745
+ */
746
+ private fun isBuiltInEquivalent(
747
+ assetPath: String,
748
+ file: File,
749
+ asset: ParsedManifestAsset,
750
+ ): Boolean {
751
+ val packagedHash = builtInAssetResolver.packagedHashIfSourceMatches(assetPath, asset.fileHash) ?: return false
752
+ return HashUtils.calculateSHA256(file).equals(packagedHash, ignoreCase = true) &&
753
+ runCatching {
754
+ if (SignatureVerifier.isSigningEnabled(context)) {
755
+ val signature = asset.signature ?: throw SignatureVerificationException.InvalidSignatureFormat()
756
+ SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
757
+ }
758
+ }.isSuccess
759
+ }
760
+
725
761
  private fun recoverInterruptedPromotions(): Boolean {
726
762
  val bundleStoreDir = getBundleStoreDir()
727
763
  val backups =
@@ -1610,6 +1646,7 @@ class BundleFileStorageService(
1610
1646
  manifestFileHash: String,
1611
1647
  assets: Map<String, ChangedAssetDescriptor>,
1612
1648
  archiveUrl: String?,
1649
+ fileResolver: ArtifactFileResolver?,
1613
1650
  progressCallback: (UpdateProgressPayload) -> Unit,
1614
1651
  ): UpdateBundleResult =
1615
1652
  updateMutex.withLock {
@@ -1619,6 +1656,7 @@ class BundleFileStorageService(
1619
1656
  manifestFileHash = manifestFileHash,
1620
1657
  changedAssets = assets,
1621
1658
  archiveUrl = archiveUrl,
1659
+ fileResolver = fileResolver,
1622
1660
  progressCallback = progressCallback,
1623
1661
  )
1624
1662
  }
@@ -1629,6 +1667,7 @@ class BundleFileStorageService(
1629
1667
  manifestFileHash: String,
1630
1668
  changedAssets: Map<String, ChangedAssetDescriptor>,
1631
1669
  archiveUrl: String?,
1670
+ fileResolver: ArtifactFileResolver?,
1632
1671
  progressCallback: (UpdateProgressPayload) -> Unit,
1633
1672
  ): UpdateBundleResult {
1634
1673
  Log.d(
@@ -1660,6 +1699,7 @@ class BundleFileStorageService(
1660
1699
  manifestFileHash = manifestFileHash,
1661
1700
  changedAssets = changedAssets,
1662
1701
  archiveUrl = archiveUrl,
1702
+ fileResolver = fileResolver,
1663
1703
  bundleStoreDir = bundleStoreDir,
1664
1704
  finalBundleDir = finalBundleDir,
1665
1705
  progressCallback = progressCallback,
@@ -1710,6 +1750,35 @@ class BundleFileStorageService(
1710
1750
  }
1711
1751
  }
1712
1752
 
1753
+ /**
1754
+ * Asks [fileResolver] for the URLs of [downloads], in requests of at most
1755
+ * [ArtifactFileResolver.ARTIFACT_FILES_REQUEST_LIMIT] paths.
1756
+ */
1757
+ private suspend fun resolveFileUrls(
1758
+ fileResolver: ArtifactFileResolver,
1759
+ downloads: MutableMap<String, ChangedAssetDescriptor>,
1760
+ ) {
1761
+ for (paths in downloads.keys.sorted().chunked(ArtifactFileResolver.ARTIFACT_FILES_REQUEST_LIMIT)) {
1762
+ val files =
1763
+ try {
1764
+ fileResolver.resolve(paths)
1765
+ } catch (error: CancellationException) {
1766
+ throw error
1767
+ } catch (error: Exception) {
1768
+ throw downloadError(error)
1769
+ }
1770
+ for (path in paths) {
1771
+ val file = files[path] ?: throw HotUpdaterException.invalidBundle()
1772
+ try {
1773
+ URL(file.url)
1774
+ } catch (error: java.net.MalformedURLException) {
1775
+ throw HotUpdaterException.invalidFileUrl(path, file.url, UpdateFailureResource.FILE)
1776
+ }
1777
+ downloads[path] = downloads.getValue(path).copy(fileUrl = file.url, fileCompression = file.compression)
1778
+ }
1779
+ }
1780
+ }
1781
+
1713
1782
  /** The rejection for a download that returned [error]. */
1714
1783
  private fun downloadError(error: Exception): HotUpdaterException =
1715
1784
  if (error is IncompleteDownloadException) {
@@ -1945,10 +2014,15 @@ class BundleFileStorageService(
1945
2014
  }
1946
2015
  if (archive.tarByteSize < logicalBytes) return null
1947
2016
  val tarFramingBytes = archive.tarByteSize - logicalBytes
1948
- if (individualBytes > MAX_SAFE_INTEGER - tarFramingBytes) return null
1949
- val framedByteLimit = individualBytes + tarFramingBytes
1950
-
1951
- if (archive.downloadByteSize <= individualBytes) return archive
2017
+ // Many small files cost more in requests than in bytes.
2018
+ val requestOverheadBytes =
2019
+ (downloads.size - PARALLEL_FILE_DOWNLOADS).coerceAtLeast(0) * REQUEST_OVERHEAD_BYTES
2020
+ if (individualBytes > MAX_SAFE_INTEGER - requestOverheadBytes) return null
2021
+ val individualCost = individualBytes + requestOverheadBytes
2022
+ if (individualCost > MAX_SAFE_INTEGER - tarFramingBytes) return null
2023
+ val framedByteLimit = individualCost + tarFramingBytes
2024
+
2025
+ if (archive.downloadByteSize <= individualCost) return archive
1952
2026
  if (downloads.size != manifest.assets.size || downloads.values.any { it.patch != null }) return null
1953
2027
  return archive.takeIf { it.downloadByteSize <= framedByteLimit }
1954
2028
  }
@@ -2074,6 +2148,7 @@ class BundleFileStorageService(
2074
2148
  manifestFileHash: String,
2075
2149
  changedAssets: Map<String, ChangedAssetDescriptor>,
2076
2150
  archiveUrl: String?,
2151
+ fileResolver: ArtifactFileResolver?,
2077
2152
  bundleStoreDir: File,
2078
2153
  finalBundleDir: File,
2079
2154
  progressCallback: (UpdateProgressPayload) -> Unit,
@@ -2135,14 +2210,20 @@ class BundleFileStorageService(
2135
2210
  if (manifest.bundleId != bundleId) {
2136
2211
  throw HotUpdaterException.invalidBundle()
2137
2212
  }
2138
- // Reuse never bypasses the complete artifact contract.
2139
- if (manifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
2213
+ if (fileResolver == null) {
2214
+ // Legacy: artifact protocol v1, where every file comes with its URL.
2215
+ // Reuse never bypasses the complete artifact contract.
2216
+ if (manifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
2217
+ } else if (!manifest.assets.keys.containsAll(changedAssets.keys)) {
2218
+ // In v2, `assets` holds only the Hermes patch.
2219
+ throw HotUpdaterException.invalidBundle()
2220
+ }
2140
2221
  manifest.assets.forEach { (path, asset) ->
2141
- val descriptor = changedAssets.getValue(path)
2222
+ val descriptor = changedAssets[path]
2142
2223
  if (RelativePathResolver.normalizeRelativePath(path) != path ||
2143
2224
  path == "manifest.json" ||
2144
- descriptor.fileUrl.isNullOrBlank() ||
2145
- !descriptor.fileHash.equals(asset.fileHash, ignoreCase = true) ||
2225
+ (fileResolver == null && descriptor?.fileUrl.isNullOrBlank()) ||
2226
+ (fileResolver == null && !descriptor!!.fileHash.equals(asset.fileHash, ignoreCase = true)) ||
2146
2227
  RelativePathResolver.resolveInside(tmpDir, path) == null
2147
2228
  ) {
2148
2229
  throw HotUpdaterException.invalidBundle()
@@ -2150,6 +2231,20 @@ class BundleFileStorageService(
2150
2231
  }
2151
2232
  manifest
2152
2233
  }
2234
+ // In v2, the manifest names every file; URLs come once the install
2235
+ // knows which files it downloads.
2236
+ val plannedAssets =
2237
+ if (fileResolver == null) {
2238
+ changedAssets
2239
+ } else {
2240
+ targetManifest.assets.mapValues { (path, asset) ->
2241
+ ChangedAssetDescriptor(
2242
+ fileUrl = null,
2243
+ fileHash = asset.fileHash,
2244
+ patch = changedAssets[path]?.patch,
2245
+ )
2246
+ }
2247
+ }
2153
2248
  updateStep(UpdateFailureStage.INSTALL, resource = null) {
2154
2249
  if (!tmpDir.isDirectory && !tmpDir.mkdirs()) throw HotUpdaterException.directoryCreationFailed()
2155
2250
  pruneStagingDirectory(tmpDir, targetManifest.assets.keys)
@@ -2194,7 +2289,12 @@ class BundleFileStorageService(
2194
2289
  verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2195
2290
  return@forEachIndexed
2196
2291
  }
2197
- downloads[assetPath] = changedAssets.getValue(assetPath)
2292
+ val packagedHash = builtInAssetResolver.copyIfSourceMatches(assetPath, expectedAsset.fileHash, targetFile)
2293
+ if (packagedHash != null && isBuiltInEquivalent(assetPath, targetFile, expectedAsset)) {
2294
+ return@forEachIndexed
2295
+ }
2296
+ if (packagedHash != null) targetFile.delete()
2297
+ downloads[assetPath] = plannedAssets.getValue(assetPath)
2198
2298
  }
2199
2299
  downloads
2200
2300
  }
@@ -2222,11 +2322,16 @@ class BundleFileStorageService(
2222
2322
  diffFiles = archiveProgress
2223
2323
  emptyList()
2224
2324
  } else {
2325
+ if (fileResolver != null) {
2326
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.FILE) {
2327
+ resolveFileUrls(fileResolver, downloads)
2328
+ }
2329
+ }
2225
2330
  diffFiles = createDiffProgressFiles(downloads)
2226
2331
  emitDiffProgress(progressCallback, "downloading", diffFiles)
2227
2332
  val progressLock = Any()
2228
2333
  coroutineScope {
2229
- val permits = Semaphore(4)
2334
+ val permits = Semaphore(PARALLEL_FILE_DOWNLOADS)
2230
2335
  diffFiles
2231
2336
  .toList()
2232
2337
  .map { progressFile ->
@@ -162,65 +162,16 @@ class HotUpdaterImpl {
162
162
  ) ?: DEFAULT_CHANNEL
163
163
 
164
164
  /**
165
- * Get minimum bundle ID string
166
- * @return The minimum bundle ID string
165
+ * The bundle ID of the bundle this build ships (MIN_BUNDLE_ID),
166
+ * written by the generateHotUpdaterMinBundleId build task
167
167
  */
168
168
  fun getMinBundleId(): String {
169
169
  if (BuildConfig.DEBUG) {
170
170
  return "00000000-0000-0000-0000-000000000000"
171
171
  }
172
- return BuildConfig.MIN_BUNDLE_ID.takeIf { it != "null" } ?: generateMinBundleIdFromBuildTimestamp()
172
+ return HotUpdaterMinBundleId.get()
173
173
  }
174
174
 
175
- /**
176
- * Generates a bundle ID based on build timestamp
177
- * @return The generated minimum bundle ID string
178
- */
179
- private fun generateMinBundleIdFromBuildTimestamp(): String =
180
- try {
181
- val buildTimestampMs = BuildConfig.BUILD_TIMESTAMP
182
- val bytes =
183
- ByteArray(16).apply {
184
- this[0] = ((buildTimestampMs shr 40) and 0xFF).toByte()
185
- this[1] = ((buildTimestampMs shr 32) and 0xFF).toByte()
186
- this[2] = ((buildTimestampMs shr 24) and 0xFF).toByte()
187
- this[3] = ((buildTimestampMs shr 16) and 0xFF).toByte()
188
- this[4] = ((buildTimestampMs shr 8) and 0xFF).toByte()
189
- this[5] = (buildTimestampMs and 0xFF).toByte()
190
- this[6] = 0x70.toByte()
191
- this[7] = 0x00.toByte()
192
- this[8] = 0x80.toByte()
193
- this[9] = 0x00.toByte()
194
- this[10] = 0x00.toByte()
195
- this[11] = 0x00.toByte()
196
- this[12] = 0x00.toByte()
197
- this[13] = 0x00.toByte()
198
- this[14] = 0x00.toByte()
199
- this[15] = 0x00.toByte()
200
- }
201
- String.format(
202
- "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
203
- bytes[0].toInt() and 0xFF,
204
- bytes[1].toInt() and 0xFF,
205
- bytes[2].toInt() and 0xFF,
206
- bytes[3].toInt() and 0xFF,
207
- bytes[4].toInt() and 0xFF,
208
- bytes[5].toInt() and 0xFF,
209
- bytes[6].toInt() and 0xFF,
210
- bytes[7].toInt() and 0xFF,
211
- bytes[8].toInt() and 0xFF,
212
- bytes[9].toInt() and 0xFF,
213
- bytes[10].toInt() and 0xFF,
214
- bytes[11].toInt() and 0xFF,
215
- bytes[12].toInt() and 0xFF,
216
- bytes[13].toInt() and 0xFF,
217
- bytes[14].toInt() and 0xFF,
218
- bytes[15].toInt() and 0xFF,
219
- )
220
- } catch (e: Exception) {
221
- "00000000-0000-0000-0000-000000000000"
222
- }
223
-
224
175
  /**
225
176
  * Gets the current fingerprint hash
226
177
  * @param context Application context
@@ -290,6 +241,7 @@ class HotUpdaterImpl {
290
241
  channel: String?,
291
242
  selection: PersistedSelection? = null,
292
243
  archiveUrl: String? = null,
244
+ fileResolver: ArtifactFileResolver? = null,
293
245
  progressCallback: (UpdateProgressPayload) -> Unit,
294
246
  ): UpdateBundleResult {
295
247
  if (selection != null && !bundleStorage.stageReleaseSelection(selection)) {
@@ -302,6 +254,7 @@ class HotUpdaterImpl {
302
254
  manifestFileHash,
303
255
  assets,
304
256
  archiveUrl,
257
+ fileResolver,
305
258
  progressCallback,
306
259
  )
307
260
 
@@ -47,7 +47,14 @@ class HotUpdaterModule internal constructor(
47
47
  */
48
48
  private fun getInstance(): HotUpdaterImpl = HotUpdater.getInstance(mReactApplicationContext)
49
49
 
50
- private fun parseAssets(params: ReadableMap): Map<String, ChangedAssetDescriptor>? {
50
+ /**
51
+ * Every changed file with its hash and URL or Hermes patch; with
52
+ * [requireFileHash] false, also entries that hold only the Hermes patch.
53
+ */
54
+ private fun parseAssets(
55
+ params: ReadableMap,
56
+ requireFileHash: Boolean,
57
+ ): Map<String, ChangedAssetDescriptor>? {
51
58
  if (!params.hasKey("assets") || params.isNull("assets")) {
52
59
  return null
53
60
  }
@@ -59,38 +66,10 @@ class HotUpdaterModule internal constructor(
59
66
  while (iterator.hasNextKey()) {
60
67
  val assetPath = iterator.nextKey()
61
68
  val assetMap = changedAssetsMap.getMap(assetPath) ?: continue
62
- val assetHash = assetMap.getString("fileHash") ?: continue
69
+ val assetHash = if (assetMap.hasKey("fileHash")) assetMap.getString("fileHash") else null
70
+ if (assetHash == null && requireFileHash) continue
63
71
  val patchMap = assetMap.getMap("patch")
64
- val patch =
65
- if (patchMap != null) {
66
- val algorithm = patchMap.getString("algorithm")
67
- val baseBundleId = patchMap.getString("baseBundleId")
68
- val baseFileHash = patchMap.getString("baseFileHash")
69
- val patchFileHash = patchMap.getString("patchFileHash")
70
- val patchUrl = patchMap.getString("patchUrl")
71
- val byteSize = readSafeInteger(patchMap, "byteSize")
72
-
73
- if (
74
- algorithm != null &&
75
- baseBundleId != null &&
76
- baseFileHash != null &&
77
- patchFileHash != null &&
78
- patchUrl != null
79
- ) {
80
- BsdiffPatchDescriptor(
81
- algorithm = algorithm,
82
- baseBundleId = baseBundleId,
83
- baseFileHash = baseFileHash,
84
- patchFileHash = patchFileHash,
85
- patchUrl = patchUrl,
86
- byteSize = byteSize,
87
- )
88
- } else {
89
- null
90
- }
91
- } else {
92
- null
93
- }
72
+ val patch = parsePatch(patchMap)
94
73
  val fileMap =
95
74
  if (assetMap.hasKey("file") && !assetMap.isNull("file")) {
96
75
  assetMap.getMap("file")
@@ -107,10 +86,13 @@ class HotUpdaterModule internal constructor(
107
86
  if (assetUrl == null && patch == null) {
108
87
  continue
109
88
  }
89
+ if (assetHash == null && assetUrl != null) {
90
+ continue
91
+ }
110
92
  parsedAssets[assetPath] =
111
93
  ChangedAssetDescriptor(
112
94
  fileUrl = assetUrl,
113
- fileHash = assetHash,
95
+ fileHash = assetHash ?: "",
114
96
  fileCompression = fileCompression,
115
97
  patch = patch,
116
98
  )
@@ -119,6 +101,94 @@ class HotUpdaterModule internal constructor(
119
101
  return parsedAssets
120
102
  }
121
103
 
104
+ /** The update check's request headers, which the artifact files request repeats. */
105
+ private fun parseRequestHeaders(params: ReadableMap): Map<String, String> {
106
+ if (!params.hasKey("requestHeaders") || params.isNull("requestHeaders")) return emptyMap()
107
+ val headers = params.getMap("requestHeaders") ?: return emptyMap()
108
+ val parsed = linkedMapOf<String, String>()
109
+ val iterator = headers.keySetIterator()
110
+ while (iterator.hasNextKey()) {
111
+ val name = iterator.nextKey()
112
+ if (headers.getType(name) == com.facebook.react.bridge.ReadableType.String) {
113
+ headers.getString(name)?.let { parsed[name] = it }
114
+ }
115
+ }
116
+ return parsed
117
+ }
118
+
119
+ private fun parsePatch(patchMap: ReadableMap?): BsdiffPatchDescriptor? {
120
+ if (patchMap == null) return null
121
+ val algorithm = patchMap.getString("algorithm")
122
+ val baseBundleId = patchMap.getString("baseBundleId")
123
+ val baseFileHash = patchMap.getString("baseFileHash")
124
+ val patchFileHash = patchMap.getString("patchFileHash")
125
+ val patchUrl = patchMap.getString("patchUrl")
126
+ val byteSize = readSafeInteger(patchMap, "byteSize")
127
+
128
+ return if (
129
+ algorithm != null &&
130
+ baseBundleId != null &&
131
+ baseFileHash != null &&
132
+ patchFileHash != null &&
133
+ patchUrl != null
134
+ ) {
135
+ BsdiffPatchDescriptor(
136
+ algorithm = algorithm,
137
+ baseBundleId = baseBundleId,
138
+ baseFileHash = baseFileHash,
139
+ patchFileHash = patchFileHash,
140
+ patchUrl = patchUrl,
141
+ byteSize = byteSize,
142
+ )
143
+ } else {
144
+ null
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The resolver that asks `filesUrl` for the files native downloads one by
150
+ * one; null when `filesUrl` is missing.
151
+ */
152
+ private fun parseFileResolver(params: ReadableMap): ArtifactFileResolver? {
153
+ val filesUrl =
154
+ if (params.hasKey("filesUrl") && !params.isNull("filesUrl")) {
155
+ params.getString("filesUrl")?.takeIf { it.isNotEmpty() }
156
+ } else {
157
+ null
158
+ } ?: return null
159
+ try {
160
+ java.net.URL(filesUrl)
161
+ } catch (e: java.net.MalformedURLException) {
162
+ throw HotUpdaterException.invalidFileUrl("filesUrl", filesUrl, UpdateFailureResource.FILE)
163
+ }
164
+ val requestTimeoutMs =
165
+ if (params.hasKey("requestTimeout") && !params.isNull("requestTimeout")) {
166
+ params.getDouble("requestTimeout").toLong().takeIf { it > 0 }
167
+ } else {
168
+ null
169
+ } ?: HttpArtifactFileResolver.DEFAULT_TIMEOUT_MS
170
+ return HttpArtifactFileResolver(filesUrl, parseRequestHeaders(params), requestTimeoutMs)
171
+ }
172
+
173
+ /**
174
+ * Artifact protocol v2: the Hermes patch as the only planned asset, and
175
+ * the resolver that asks `filesUrl` for the files native downloads one by
176
+ * one; null when `filesUrl` is missing.
177
+ */
178
+ private fun parseArtifactInstall(params: ReadableMap): Pair<Map<String, ChangedAssetDescriptor>, ArtifactFileResolver?>? {
179
+ val fileResolver = parseFileResolver(params) ?: return null
180
+ val patchMap = if (params.hasKey("patch") && !params.isNull("patch")) params.getMap("patch") else null
181
+ val assetPath = patchMap?.getString("assetPath")
182
+ val patch = parsePatch(patchMap)
183
+ val assets =
184
+ if (assetPath != null && patch != null) {
185
+ mapOf(assetPath to ChangedAssetDescriptor(fileUrl = null, fileHash = "", patch = patch))
186
+ } else {
187
+ emptyMap()
188
+ }
189
+ return assets to fileResolver
190
+ }
191
+
122
192
  private fun parseSelection(params: ReadableMap): PersistedSelection? {
123
193
  if (!params.hasKey("selection") || params.isNull("selection")) return null
124
194
  val selection = params.getMap("selection") ?: return null
@@ -169,6 +239,28 @@ class HotUpdaterModule internal constructor(
169
239
  override fun updateBundle(
170
240
  params: ReadableMap,
171
241
  promise: Promise,
242
+ ) {
243
+ // Legacy: artifact protocol v1, where every file comes with its URL.
244
+ // JavaScript from the first protocol v2 SDK also sends `filesUrl`
245
+ // here, with only the Hermes patch in `assets`; its OTA Bundles must
246
+ // still install.
247
+ stage(params, promise) {
248
+ val fileResolver = parseFileResolver(params)
249
+ parseAssets(params, requireFileHash = fileResolver == null)?.let { it to fileResolver }
250
+ }
251
+ }
252
+
253
+ override fun installArtifact(
254
+ params: ReadableMap,
255
+ promise: Promise,
256
+ ) {
257
+ stage(params, promise) { parseArtifactInstall(params) }
258
+ }
259
+
260
+ private fun stage(
261
+ params: ReadableMap,
262
+ promise: Promise,
263
+ plan: () -> Pair<Map<String, ChangedAssetDescriptor>, ArtifactFileResolver?>?,
172
264
  ) {
173
265
  moduleScope.launch {
174
266
  try {
@@ -180,7 +272,8 @@ class HotUpdaterModule internal constructor(
180
272
 
181
273
  val manifestUrl = params.getString("manifestUrl")
182
274
  val manifestFileHash = params.getString("manifestFileHash")
183
- val assets = parseAssets(params)
275
+ val planned = plan()
276
+ val assets = planned?.first
184
277
  if (manifestUrl.isNullOrEmpty() || manifestFileHash.isNullOrEmpty() || assets == null) {
185
278
  promise.rejectUpdateBundle(HotUpdaterException.invalidManifestParams())
186
279
  return@launch
@@ -211,6 +304,7 @@ class HotUpdaterModule internal constructor(
211
304
  }
212
305
  }
213
306
  val selection = parseSelection(params)
307
+ val fileResolver = planned.second
214
308
 
215
309
  val impl = getInstance()
216
310
 
@@ -223,6 +317,7 @@ class HotUpdaterModule internal constructor(
223
317
  channel,
224
318
  selection,
225
319
  archiveUrl,
320
+ fileResolver,
226
321
  ) { progress ->
227
322
  // Post to Main thread for React Native event emission
228
323
  Handler(Looper.getMainLooper()).post {