@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
@@ -109,14 +109,34 @@ interface BundleStorageService {
109
109
  */
110
110
  fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection
111
111
 
112
+ /**
113
+ * Settles what the previous process left once per process, before the
114
+ * first read of the selection: interrupted promotions, a crash rollback,
115
+ * and a launch that never finished. [prepareLaunch] runs it first; a native
116
+ * update check that runs before React Native starts runs it earlier.
117
+ * [pendingRecovery] is called only when this run settles.
118
+ */
119
+ fun settlePreviousLaunch(pendingRecovery: () -> PendingCrashRecovery?) {}
120
+
121
+ /**
122
+ * The bundle [prepareLaunch] would load, read from the saved metadata
123
+ * without preparing the launch. Null is the built-in bundle. Call
124
+ * [settlePreviousLaunch] first, or a bundle the next launch rolls back
125
+ * is returned.
126
+ */
127
+ fun peekLaunchBundleId(): String? = null
128
+
112
129
  /**
113
130
  * Updates the bundle from the specified URL
114
131
  * @param bundleId ID of the bundle to update
115
132
  * @param manifestUrl URL of the target bundle manifest
116
133
  * @param manifestFileHash Manifest hash or signature
134
+ * @param selection the Release selection this staging commits, checked
135
+ * before the download and again when the staging commits
117
136
  * @param progressCallback Callback for download progress updates
118
137
  * @return how the staged bundle arrived
119
138
  * @throws HotUpdaterException if the update fails
139
+ * @throws StaleReleaseSelectionException if [selection] is no longer current
120
140
  */
121
141
  suspend fun updateBundle(
122
142
  bundleId: String,
@@ -125,11 +145,10 @@ interface BundleStorageService {
125
145
  assets: Map<String, ChangedAssetDescriptor>,
126
146
  archiveUrl: String? = null,
127
147
  fileResolver: ArtifactFileResolver? = null,
148
+ selection: PersistedSelection? = null,
128
149
  progressCallback: (UpdateProgressPayload) -> Unit,
129
150
  ): UpdateBundleResult
130
151
 
131
- fun stageReleaseSelection(selection: PersistedSelection): Boolean = false
132
-
133
152
  fun acceptReleaseCatalog(
134
153
  catalogId: String,
135
154
  scopeKey: String,
@@ -158,6 +177,13 @@ interface BundleStorageService {
158
177
  */
159
178
  fun markLaunchStarted(launchedBundleId: String?)
160
179
 
180
+ /**
181
+ * Undoes [markLaunchStarted] when another instance's crash ends the
182
+ * process: the launch did not fail, so the next process launches its
183
+ * bundle again instead of counting the launch as unfinished.
184
+ */
185
+ fun clearLaunchStarted(launchedBundleId: String?) {}
186
+
161
187
  /**
162
188
  * Marks the current launch as successful after the first content appeared.
163
189
  */
@@ -227,6 +253,10 @@ class BundleFileStorageService(
227
253
  private val preferences: PreferencesService,
228
254
  private val isolationKey: String,
229
255
  private val defaultChannelProvider: () -> String = { HotUpdaterImpl.getChannel(context) },
256
+ private val fingerprintHashProvider: () -> String? = { HotUpdaterImpl.getFingerprintHash(context) },
257
+ // The key bundles must be signed with, or null when signing is off.
258
+ private val publicKeyProvider: () -> String? = { SignatureVerifier.getPublicKeyFromConfig(context) },
259
+ private val fallbackBundleURL: String = "assets://index.android.bundle",
230
260
  builtInAssetResolver: BuiltInAssetResolver? = null,
231
261
  private val metadataWriter: (BundleMetadata, File) -> Boolean = { metadata, file ->
232
262
  metadata.saveToFile(file)
@@ -256,7 +286,6 @@ class BundleFileStorageService(
256
286
  }
257
287
 
258
288
  private val releaseStateLock = Any()
259
- private val pendingInstallSelections = java.util.concurrent.ConcurrentHashMap<String, PersistedSelection>()
260
289
  private val updateMutex = Mutex()
261
290
  private val builtInAssetResolver =
262
291
  builtInAssetResolver ?: AndroidBuiltInAssetResolver(
@@ -608,6 +637,8 @@ class BundleFileStorageService(
608
637
  }
609
638
 
610
639
  private var hasPreparedLaunch = false
640
+ private val settleLock = Any()
641
+ private var hasSettledPreviousLaunch = false
611
642
  private var currentLaunchReport: LaunchReport? = null
612
643
 
613
644
  // The staged bundle this process launched before its first content. Only that
@@ -665,7 +696,7 @@ class BundleFileStorageService(
665
696
 
666
697
  private fun resolveUpdateStrategy(): String =
667
698
  try {
668
- if (HotUpdaterImpl.getFingerprintHash(context).isNullOrEmpty()) {
699
+ if (fingerprintHashProvider().isNullOrEmpty()) {
669
700
  "appVersion"
670
701
  } else {
671
702
  "fingerprint"
@@ -751,9 +782,10 @@ class BundleFileStorageService(
751
782
  val packagedHash = builtInAssetResolver.packagedHashIfSourceMatches(assetPath, asset.fileHash) ?: return false
752
783
  return HashUtils.calculateSHA256(file).equals(packagedHash, ignoreCase = true) &&
753
784
  runCatching {
754
- if (SignatureVerifier.isSigningEnabled(context)) {
785
+ val publicKey = publicKeyProvider()
786
+ if (publicKey != null) {
755
787
  val signature = asset.signature ?: throw SignatureVerificationException.InvalidSignatureFormat()
756
- SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
788
+ SignatureVerifier.verifyHashSignature(publicKey, asset.fileHash, signature)
757
789
  }
758
790
  }.isSuccess
759
791
  }
@@ -1005,15 +1037,13 @@ class BundleFileStorageService(
1005
1037
  throw SignatureVerificationException.FileHashMismatch()
1006
1038
  }
1007
1039
 
1008
- if (!SignatureVerifier.isSigningEnabled(context)) {
1009
- return
1010
- }
1040
+ val publicKey = publicKeyProvider() ?: return
1011
1041
 
1012
1042
  val signature =
1013
1043
  asset.signature
1014
1044
  ?: throw SignatureVerificationException.InvalidSignatureFormat()
1015
1045
 
1016
- SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
1046
+ SignatureVerifier.verifyHashSignature(publicKey, asset.fileHash, signature)
1017
1047
  }
1018
1048
 
1019
1049
  /**
@@ -1108,10 +1138,10 @@ class BundleFileStorageService(
1108
1138
  private fun prepareMetadataForNewStagingBundle(
1109
1139
  metadata: BundleMetadata,
1110
1140
  bundleId: String,
1141
+ incomingSelection: PersistedSelection?,
1111
1142
  ): BundleMetadata {
1112
1143
  val currentVerifiedBundleId =
1113
1144
  getCurrentVerifiedBundleId(metadata)?.takeIf { it != bundleId }
1114
- val incomingSelection = pendingInstallSelections.remove(bundleId)
1115
1145
  if (incomingSelection != null && !isSelectionCurrent(incomingSelection)) {
1116
1146
  throw StaleReleaseSelectionException()
1117
1147
  }
@@ -1141,6 +1171,7 @@ class BundleFileStorageService(
1141
1171
  ),
1142
1172
  verificationPending = true,
1143
1173
  launchInProgress = false,
1174
+ currentSelectionContexts = contextsAfterCommitting(metadata.currentSelectionContexts, incomingSelection),
1144
1175
  updatedAt = System.currentTimeMillis(),
1145
1176
  )
1146
1177
  }
@@ -1259,6 +1290,22 @@ class BundleFileStorageService(
1259
1290
  scopeKey: String,
1260
1291
  ): String = "$catalogId|$scopeKey"
1261
1292
 
1293
+ /**
1294
+ * The selection contexts that stay current once [selection] commits: only
1295
+ * its own scope's, or none for a staging without a Release selection. A
1296
+ * check made before this commit, of another scope such as another channel,
1297
+ * no longer stages over it.
1298
+ */
1299
+ private fun contextsAfterCommitting(
1300
+ contexts: Map<String, String>,
1301
+ selection: PersistedSelection?,
1302
+ ): Map<String, String> {
1303
+ val catalogId = selection?.catalogId ?: return emptyMap()
1304
+ val scopeKey = selection.scopeKey ?: return emptyMap()
1305
+ val stateKey = releaseScopeStateKey(catalogId, scopeKey)
1306
+ return contexts.filterKeys { it == stateKey }
1307
+ }
1308
+
1262
1309
  private fun selectionContextValue(
1263
1310
  channel: String,
1264
1311
  selectionContextHash: String,
@@ -1355,12 +1402,6 @@ class BundleFileStorageService(
1355
1402
  )
1356
1403
  }
1357
1404
 
1358
- override fun stageReleaseSelection(selection: PersistedSelection): Boolean {
1359
- if (!isSelectionCurrent(selection)) return false
1360
- pendingInstallSelections[selection.bundleId] = selection
1361
- return true
1362
- }
1363
-
1364
1405
  override suspend fun commitReleaseSelection(selection: PersistedSelection): Boolean =
1365
1406
  withContext(Dispatchers.IO) {
1366
1407
  synchronized(releaseStateLock) {
@@ -1374,6 +1415,15 @@ class BundleFileStorageService(
1374
1415
  metadata.copy(
1375
1416
  stagingBundleId = selection.bundleId,
1376
1417
  stagingSelection = selection,
1418
+ // The launch of the staged bundle reports this Release as applied.
1419
+ pendingTransition =
1420
+ metadata.pendingTransition?.let { transition ->
1421
+ if (transition.toBundleId == selection.bundleId) {
1422
+ transition.copy(toReleaseId = selection.releaseId)
1423
+ } else {
1424
+ transition
1425
+ }
1426
+ },
1377
1427
  updatedAt = System.currentTimeMillis(),
1378
1428
  )
1379
1429
  }
@@ -1405,7 +1455,11 @@ class BundleFileStorageService(
1405
1455
  updatedAt = System.currentTimeMillis(),
1406
1456
  )
1407
1457
  }
1408
- if (!saveMetadata(nextMetadata)) return@synchronized false
1458
+ val committedMetadata =
1459
+ nextMetadata.copy(
1460
+ currentSelectionContexts = contextsAfterCommitting(nextMetadata.currentSelectionContexts, selection),
1461
+ )
1462
+ if (!saveMetadata(committedMetadata)) return@synchronized false
1409
1463
 
1410
1464
  if (selection.channel == defaultChannelProvider()) {
1411
1465
  preferences.setItem("HotUpdaterChannel", null)
@@ -1517,6 +1571,16 @@ class BundleFileStorageService(
1517
1571
  }
1518
1572
  }
1519
1573
 
1574
+ override fun clearLaunchStarted(launchedBundleId: String?) {
1575
+ synchronized(releaseStateLock) {
1576
+ val metadata = loadMetadataOrNull() ?: return
1577
+ if (!metadata.launchInProgress || metadata.stagingBundleId != launchedBundleId) {
1578
+ return
1579
+ }
1580
+ saveMetadata(metadata.copy(launchInProgress = false))
1581
+ }
1582
+ }
1583
+
1520
1584
  override fun markLaunchCompleted(currentBundleId: String?) {
1521
1585
  readyInterruptedLaunchRetry(currentBundleId)
1522
1586
  val metadata = loadMetadataOrNull() ?: return
@@ -1614,19 +1678,40 @@ class BundleFileStorageService(
1614
1678
  return resolvedBundle?.absolutePath ?: urlString
1615
1679
  }
1616
1680
 
1617
- override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
1681
+ override fun getFallbackBundleURL(): String = fallbackBundleURL
1618
1682
 
1619
- override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
1620
- recoverInterruptedPromotions()
1621
- saveLaunchReport(null)
1622
- applyPendingRecoveryIfNeeded(pendingRecovery)
1623
- // Only a new storage instance consumes an unfinished launch. Repeated bundle
1624
- // lookups or bridge reloads in this process must not reject its own launch.
1625
- if (!hasPreparedLaunch) {
1683
+ override fun settlePreviousLaunch(pendingRecovery: () -> PendingCrashRecovery?) {
1684
+ synchronized(settleLock) {
1685
+ if (hasSettledPreviousLaunch) return
1686
+ hasSettledPreviousLaunch = true
1687
+ recoverInterruptedPromotions()
1688
+ // Cleared before the rollbacks below, so the launch reports their RECOVERED.
1689
+ saveLaunchReport(null)
1690
+ applyPendingRecoveryIfNeeded(pendingRecovery())
1691
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1692
+ // lookups or bridge reloads in this process must not reject its own launch.
1626
1693
  val metadata = loadMetadataOrNull()
1627
1694
  if (metadata?.verificationPending == true && metadata.launchInProgress) {
1628
1695
  metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
1629
1696
  }
1697
+ // A pending bundle whose file is gone rolls back, as selectLaunch does.
1698
+ val settled = loadMetadataOrNull()
1699
+ settled?.stagingBundleId?.let { stagingBundleId ->
1700
+ if (settled.verificationPending && getBundleUrlForId(stagingBundleId) == null) {
1701
+ rollbackPendingBundle(stagingBundleId)
1702
+ }
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
1708
+ if (hasPreparedLaunch) {
1709
+ // A later launch in this process, after a reset or a reload.
1710
+ recoverInterruptedPromotions()
1711
+ saveLaunchReport(null)
1712
+ applyPendingRecoveryIfNeeded(pendingRecovery)
1713
+ } else {
1714
+ settlePreviousLaunch { pendingRecovery }
1630
1715
  }
1631
1716
  hasPreparedLaunch = true
1632
1717
 
@@ -1640,6 +1725,17 @@ class BundleFileStorageService(
1640
1725
  return selection
1641
1726
  }
1642
1727
 
1728
+ override fun peekLaunchBundleId(): String? {
1729
+ val metadata = loadMetadataOrNull() ?: return null
1730
+ metadata.stagingBundleId?.let { stagingBundleId ->
1731
+ if (getBundleUrlForId(stagingBundleId) != null) return stagingBundleId
1732
+ }
1733
+ metadata.stableBundleId?.let { stableBundleId ->
1734
+ if (getBundleUrlForId(stableBundleId) != null) return stableBundleId
1735
+ }
1736
+ return null
1737
+ }
1738
+
1643
1739
  override suspend fun updateBundle(
1644
1740
  bundleId: String,
1645
1741
  manifestUrl: String,
@@ -1647,9 +1743,15 @@ class BundleFileStorageService(
1647
1743
  assets: Map<String, ChangedAssetDescriptor>,
1648
1744
  archiveUrl: String?,
1649
1745
  fileResolver: ArtifactFileResolver?,
1746
+ selection: PersistedSelection?,
1650
1747
  progressCallback: (UpdateProgressPayload) -> Unit,
1651
1748
  ): UpdateBundleResult =
1652
1749
  updateMutex.withLock {
1750
+ // The selection travels with this staging: another staging of the
1751
+ // same bundle carries its own.
1752
+ if (selection != null && !isSelectionCurrent(selection)) {
1753
+ throw StaleReleaseSelectionException()
1754
+ }
1653
1755
  updateBundleSerialized(
1654
1756
  bundleId = bundleId,
1655
1757
  manifestUrl = manifestUrl,
@@ -1657,6 +1759,7 @@ class BundleFileStorageService(
1657
1759
  changedAssets = assets,
1658
1760
  archiveUrl = archiveUrl,
1659
1761
  fileResolver = fileResolver,
1762
+ selection = selection,
1660
1763
  progressCallback = progressCallback,
1661
1764
  )
1662
1765
  }
@@ -1668,6 +1771,7 @@ class BundleFileStorageService(
1668
1771
  changedAssets: Map<String, ChangedAssetDescriptor>,
1669
1772
  archiveUrl: String?,
1670
1773
  fileResolver: ArtifactFileResolver?,
1774
+ selection: PersistedSelection?,
1671
1775
  progressCallback: (UpdateProgressPayload) -> Unit,
1672
1776
  ): UpdateBundleResult {
1673
1777
  Log.d(
@@ -1700,6 +1804,7 @@ class BundleFileStorageService(
1700
1804
  changedAssets = changedAssets,
1701
1805
  archiveUrl = archiveUrl,
1702
1806
  fileResolver = fileResolver,
1807
+ selection = selection,
1703
1808
  bundleStoreDir = bundleStoreDir,
1704
1809
  finalBundleDir = finalBundleDir,
1705
1810
  progressCallback = progressCallback,
@@ -1769,9 +1874,14 @@ class BundleFileStorageService(
1769
1874
  }
1770
1875
  for (path in paths) {
1771
1876
  val file = files[path] ?: throw HotUpdaterException.invalidBundle()
1772
- try {
1773
- URL(file.url)
1774
- } catch (error: java.net.MalformedURLException) {
1877
+ val url =
1878
+ try {
1879
+ URL(file.url)
1880
+ } catch (error: java.net.MalformedURLException) {
1881
+ throw HotUpdaterException.invalidFileUrl(path, file.url, UpdateFailureResource.FILE)
1882
+ }
1883
+ // java.net.URL also takes file: URLs and a hostless "https://"; a download needs neither.
1884
+ if (url.protocol.lowercase() !in setOf("http", "https") || url.host.isNullOrEmpty()) {
1775
1885
  throw HotUpdaterException.invalidFileUrl(path, file.url, UpdateFailureResource.FILE)
1776
1886
  }
1777
1887
  downloads[path] = downloads.getValue(path).copy(fileUrl = file.url, fileCompression = file.compression)
@@ -2149,6 +2259,7 @@ class BundleFileStorageService(
2149
2259
  changedAssets: Map<String, ChangedAssetDescriptor>,
2150
2260
  archiveUrl: String?,
2151
2261
  fileResolver: ArtifactFileResolver?,
2262
+ selection: PersistedSelection?,
2152
2263
  bundleStoreDir: File,
2153
2264
  finalBundleDir: File,
2154
2265
  progressCallback: (UpdateProgressPayload) -> Unit,
@@ -2201,7 +2312,7 @@ class BundleFileStorageService(
2201
2312
  }
2202
2313
 
2203
2314
  try {
2204
- SignatureVerifier.verifyBundle(context, manifestFile, manifestFileHash)
2315
+ SignatureVerifier.verifyBundle(publicKeyProvider(), manifestFile, manifestFileHash)
2205
2316
  } catch (e: SignatureVerificationException) {
2206
2317
  throw HotUpdaterException.signatureVerificationFailed(e)
2207
2318
  }
@@ -2415,7 +2526,7 @@ class BundleFileStorageService(
2415
2526
  finalBundleDir.setLastModified(System.currentTimeMillis())
2416
2527
 
2417
2528
  val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2418
- val nextMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2529
+ val nextMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId, selection)
2419
2530
  if (!setBundleURL(finalIndexFile.absolutePath)) {
2420
2531
  throw HotUpdaterException.storageFailed("Failed to persist bundle URL")
2421
2532
  }
@@ -2612,6 +2723,9 @@ class BundleFileStorageService(
2612
2723
  pendingTransition = null,
2613
2724
  verificationPending = false,
2614
2725
  launchInProgress = false,
2726
+ // Selections made before the reset no longer stage; the
2727
+ // high-water marks stay, so an older catalog is still refused.
2728
+ currentSelectionContexts = emptyMap(),
2615
2729
  updatedAt = System.currentTimeMillis(),
2616
2730
  )
2617
2731
 
@@ -14,6 +14,12 @@ class CohortService(
14
14
  companion object {
15
15
  private const val COHORT_KEY = "custom_cohort"
16
16
  private const val FALLBACK_IDENTIFIER_KEY = "fallback_identifier"
17
+
18
+ /**
19
+ * App-wide: every instance's service reads and creates the same stored
20
+ * cohort, so instances that check at once never pick different ones.
21
+ */
22
+ private val lock = Any()
17
23
  }
18
24
 
19
25
  private fun hashString(value: String): Int {
@@ -46,10 +52,12 @@ class CohortService(
46
52
  return
47
53
  }
48
54
  // Cohort changes can be followed immediately by a process restart during OTA flows.
49
- prefs.edit().putString(COHORT_KEY, cohort).commit()
55
+ synchronized(lock) { prefs.edit().putString(COHORT_KEY, cohort).commit() }
50
56
  }
51
57
 
52
- fun getCohort(): String {
58
+ fun getCohort(): String = synchronized(lock) { readOrCreateCohort() }
59
+
60
+ private fun readOrCreateCohort(): String {
53
61
  val cohort = prefs.getString(COHORT_KEY, null)
54
62
  if (!cohort.isNullOrEmpty()) {
55
63
  return cohort
@@ -99,6 +99,8 @@ interface FileSystemService {
99
99
  */
100
100
  class FileManagerService(
101
101
  private val context: Context,
102
+ // A named instance's root beside the app's files; null is the app's files directory.
103
+ private val internalFilesDir: File? = null,
102
104
  ) : FileSystemService {
103
105
  override fun fileExists(path: String): Boolean = File(path).exists()
104
106
 
@@ -145,5 +147,5 @@ class FileManagerService(
145
147
  return directory.listFiles()?.map { it.name } ?: listOf()
146
148
  }
147
149
 
148
- override fun getInternalFilesDir(): File? = context.filesDir
150
+ override fun getInternalFilesDir(): File? = internalFilesDir ?: context.filesDir
149
151
  }