@hot-updater/react-native 1.1.4 → 1.2.0-rc.1

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 (48) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +136 -14
  2. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +26 -0
  3. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +13 -0
  4. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +129 -1
  5. package/android/src/newarch/HotUpdaterModule.kt +39 -0
  6. package/android/src/oldarch/HotUpdaterModule.kt +40 -0
  7. package/android/src/oldarch/HotUpdaterSpec.kt +5 -0
  8. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +7 -0
  9. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +147 -22
  10. package/ios/HotUpdater/Internal/HotUpdater.mm +18 -0
  11. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +31 -0
  12. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +14 -0
  13. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +37 -0
  14. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +52 -0
  15. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +124 -0
  16. package/lib/commonjs/checkForUpdate.js +101 -0
  17. package/lib/commonjs/checkForUpdate.js.map +1 -1
  18. package/lib/commonjs/native.js +22 -2
  19. package/lib/commonjs/native.js.map +1 -1
  20. package/lib/commonjs/sdkVersion.js +1 -1
  21. package/lib/commonjs/sdkVersion.js.map +1 -1
  22. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  23. package/lib/module/checkForUpdate.js +102 -1
  24. package/lib/module/checkForUpdate.js.map +1 -1
  25. package/lib/module/native.js +19 -0
  26. package/lib/module/native.js.map +1 -1
  27. package/lib/module/sdkVersion.js +1 -1
  28. package/lib/module/sdkVersion.js.map +1 -1
  29. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  30. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/native.d.ts +7 -0
  32. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  34. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -0
  36. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  37. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  38. package/lib/typescript/module/native.d.ts +7 -0
  39. package/lib/typescript/module/native.d.ts.map +1 -1
  40. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  41. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  42. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -0
  43. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  44. package/package.json +4 -4
  45. package/src/checkForUpdate.ts +133 -0
  46. package/src/native.ts +21 -0
  47. package/src/sdkVersion.ts +1 -1
  48. package/src/specs/NativeHotUpdater.ts +9 -0
@@ -241,6 +241,21 @@ interface BundleStorageService {
241
241
  * @return true if the reset was successful
242
242
  */
243
243
  suspend fun resetChannel(): Boolean
244
+
245
+ /**
246
+ * Resets the channel only while the named Release selection is current,
247
+ * checked and reset in one critical section with the catalog checks, so
248
+ * a newer catalog accepted meanwhile keeps the channel.
249
+ * @return false for a stale selection
250
+ */
251
+ suspend fun resetChannelIfSelectionCurrent(
252
+ catalogId: String,
253
+ scopeKey: String,
254
+ generation: Long,
255
+ catalogHash: String,
256
+ channel: String,
257
+ selectionContextHash: String,
258
+ ): Boolean = false
244
259
  }
245
260
 
246
261
  /**
@@ -254,6 +269,8 @@ class BundleFileStorageService(
254
269
  private val isolationKey: String,
255
270
  private val defaultChannelProvider: () -> String = { HotUpdaterImpl.getChannel(context) },
256
271
  private val fingerprintHashProvider: () -> String? = { HotUpdaterImpl.getFingerprintHash(context) },
272
+ // The built-in bundle's ID: the nil UUID in a Debug build, which has none.
273
+ private val minBundleIdProvider: () -> String = { HotUpdaterImpl.getMinBundleId() },
257
274
  // The key bundles must be signed with, or null when signing is off.
258
275
  private val publicKeyProvider: () -> String? = { SignatureVerifier.getPublicKeyFromConfig(context) },
259
276
  private val fallbackBundleURL: String = "assets://index.android.bundle",
@@ -268,6 +285,7 @@ class BundleFileStorageService(
268
285
  companion object {
269
286
  private const val TAG = "BundleStorage"
270
287
  private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
288
+ private const val NIL_UUID = "00000000-0000-0000-0000-000000000000"
271
289
  private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
272
290
 
273
291
  /** Files downloaded at once when the archive is not used. */
@@ -423,6 +441,42 @@ class BundleFileStorageService(
423
441
  return File(patchDir, "$safeName.bsdiff")
424
442
  }
425
443
 
444
+ /**
445
+ * Copies, with no OTA bundle installed, the packaged file each built-in
446
+ * patch was made from, when its bytes are the patch's base: store
447
+ * processing may have changed them. Only these patches are priced and
448
+ * applied; the others fall back to their files.
449
+ */
450
+ private fun prepareBuiltInPatchBases(
451
+ downloads: Map<String, ChangedAssetDescriptor>,
452
+ currentBundleId: String?,
453
+ tempDir: File,
454
+ ): Map<String, File> {
455
+ if (currentBundleId != null) return emptyMap()
456
+ return buildMap {
457
+ for ((assetPath, descriptor) in downloads) {
458
+ val patch = descriptor.patch?.takeIf { isPatchForRunningBundle(it, null) } ?: continue
459
+ val baseFile = patchBaseTempFile(tempDir, assetPath)
460
+ if (builtInAssetResolver.copyIfMatches(assetPath, patch.baseFileHash, baseFile)) {
461
+ put(assetPath, baseFile)
462
+ } else {
463
+ baseFile.delete()
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ /** Where the built-in bundle's file is copied to serve as a patch's base. */
470
+ private fun patchBaseTempFile(
471
+ tempDir: File,
472
+ assetPath: String,
473
+ ): File {
474
+ val safeName = assetPath.replace("/", "__").replace("\\", "__")
475
+ val patchDir = File(tempDir, "patches")
476
+ patchDir.mkdirs()
477
+ return File(patchDir, "$safeName.base")
478
+ }
479
+
426
480
  private fun patchDownloadPath(assetPath: String): String = "$assetPath.bsdiff"
427
481
 
428
482
  private fun downloadPathForChangedAsset(
@@ -493,6 +547,21 @@ class BundleFileStorageService(
493
547
  }
494
548
  }
495
549
 
550
+ /**
551
+ * Whether [patch] was made against the running bundle: the OTA bundle
552
+ * [currentBundleId], or with none, the built-in bundle, whose ID is
553
+ * MIN_BUNDLE_ID once its native build is registered.
554
+ */
555
+ private fun isPatchForRunningBundle(
556
+ patch: BsdiffPatchDescriptor,
557
+ currentBundleId: String?,
558
+ ): Boolean {
559
+ if (patch.algorithm != "bsdiff") return false
560
+ if (currentBundleId != null) return currentBundleId == patch.baseBundleId
561
+ val builtInBundleId = minBundleIdProvider()
562
+ return builtInBundleId != NIL_UUID && builtInBundleId == patch.baseBundleId
563
+ }
564
+
496
565
  /**
497
566
  * Tries to produce [targetFile] from a bsdiff patch against the running
498
567
  * bundle. A patch for another base bundle or algorithm is not attempted.
@@ -503,6 +572,7 @@ class BundleFileStorageService(
503
572
  changedAsset: ChangedAssetDescriptor,
504
573
  currentBundleId: String?,
505
574
  activeBundleDir: File?,
575
+ builtInBaseFile: File?,
506
576
  targetFile: File,
507
577
  expectedHash: String,
508
578
  tempDir: File,
@@ -510,15 +580,21 @@ class BundleFileStorageService(
510
580
  progressCallback: (UpdateProgressPayload) -> Unit,
511
581
  ): PatchOutcome {
512
582
  val patch = changedAsset.patch ?: return PatchOutcome.NOT_ATTEMPTED
513
- if (patch.algorithm != "bsdiff" || currentBundleId != patch.baseBundleId) {
583
+ if (!isPatchForRunningBundle(patch, currentBundleId)) {
514
584
  return PatchOutcome.NOT_ATTEMPTED
515
585
  }
516
586
 
517
587
  // The patch targets the running bundle, so from here a miss is a fallback.
518
- val sourceDir = activeBundleDir ?: return PatchOutcome.FAILED
519
- val sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return PatchOutcome.FAILED
520
- if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, patch.baseFileHash)) {
521
- return PatchOutcome.FAILED
588
+ val sourceFile: File
589
+ if (currentBundleId == null) {
590
+ // Copied and checked before the plan priced the patch.
591
+ sourceFile = builtInBaseFile ?: return PatchOutcome.FAILED
592
+ } else {
593
+ val sourceDir = activeBundleDir ?: return PatchOutcome.FAILED
594
+ sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return PatchOutcome.FAILED
595
+ if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, patch.baseFileHash)) {
596
+ return PatchOutcome.FAILED
597
+ }
522
598
  }
523
599
 
524
600
  val patchFile = patchTempFile(tempDir, assetPath)
@@ -573,6 +649,7 @@ class BundleFileStorageService(
573
649
  false
574
650
  } finally {
575
651
  patchFile.delete()
652
+ builtInBaseFile?.delete()
576
653
  if (!targetFile.exists() || !HashUtils.verifyHash(targetFile, expectedHash)) {
577
654
  targetFile.delete()
578
655
  }
@@ -705,8 +782,7 @@ class BundleFileStorageService(
705
782
  "appVersion"
706
783
  }
707
784
 
708
- private fun getKnownLaunchBundleId(metadata: BundleMetadata): String =
709
- getCurrentVerifiedBundleId(metadata) ?: HotUpdaterImpl.getMinBundleId()
785
+ private fun getKnownLaunchBundleId(metadata: BundleMetadata): String = getCurrentVerifiedBundleId(metadata) ?: minBundleIdProvider()
710
786
 
711
787
  private fun createInitialMetadata(): BundleMetadata = BundleMetadata()
712
788
 
@@ -1154,7 +1230,7 @@ class BundleFileStorageService(
1154
1230
  val stagingSelection = incomingSelection ?: PersistedSelection.bareBundle(bundleId)
1155
1231
  val fromSelection =
1156
1232
  currentVerifiedSelection
1157
- ?: PersistedSelection.bareBundle(HotUpdaterImpl.getMinBundleId())
1233
+ ?: PersistedSelection.bareBundle(minBundleIdProvider())
1158
1234
 
1159
1235
  return metadata.copy(
1160
1236
  stableBundleId = currentVerifiedBundleId,
@@ -1231,7 +1307,7 @@ class BundleFileStorageService(
1231
1307
  fromReleaseId = metadata.pendingTransition?.toReleaseId,
1232
1308
  fromBundleId = stagingBundleId,
1233
1309
  toReleaseId = metadata.stableSelection?.releaseId,
1234
- toBundleId = fallbackBundleId ?: HotUpdaterImpl.getMinBundleId(),
1310
+ toBundleId = fallbackBundleId ?: minBundleIdProvider(),
1235
1311
  updateStrategy = metadata.pendingUpdateStrategy ?: resolveUpdateStrategy(),
1236
1312
  ),
1237
1313
  )
@@ -1924,6 +2000,7 @@ class BundleFileStorageService(
1924
2000
  changedAsset: ChangedAssetDescriptor,
1925
2001
  currentBundleId: String?,
1926
2002
  activeBundleDir: File?,
2003
+ builtInBaseFile: File?,
1927
2004
  targetFile: File,
1928
2005
  tempDir: File,
1929
2006
  progressFile: DiffProgressFileSnapshot,
@@ -1937,6 +2014,7 @@ class BundleFileStorageService(
1937
2014
  changedAsset = changedAsset,
1938
2015
  currentBundleId = currentBundleId,
1939
2016
  activeBundleDir = activeBundleDir,
2017
+ builtInBaseFile = builtInBaseFile,
1940
2018
  targetFile = targetFile,
1941
2019
  expectedHash = expectedHash,
1942
2020
  tempDir = tempDir,
@@ -2118,19 +2196,34 @@ class BundleFileStorageService(
2118
2196
  manifest: ParsedBundleManifest,
2119
2197
  downloads: Map<String, ChangedAssetDescriptor>,
2120
2198
  archiveUrl: String?,
2199
+ currentBundleId: String?,
2200
+ builtInPatchBases: Map<String, File>,
2121
2201
  ): ParsedArchive? {
2122
2202
  if (archiveUrl.isNullOrBlank() || downloads.size < 2) return null
2123
2203
  val archive = manifest.archive ?: return null
2124
2204
  if (manifest.assets.values.any { it.byteSize == null }) return null
2125
2205
 
2206
+ // Only a patch this device applies saves bytes.
2207
+ val applicablePatches =
2208
+ downloads.mapValues { (assetPath, descriptor) ->
2209
+ descriptor.patch?.takeIf {
2210
+ if (currentBundleId == null) {
2211
+ assetPath in builtInPatchBases
2212
+ } else {
2213
+ isPatchForRunningBundle(it, currentBundleId)
2214
+ }
2215
+ }
2216
+ }
2126
2217
  var individualBytes = 0L
2127
- for ((assetPath, descriptor) in downloads) {
2218
+ for ((assetPath, patch) in applicablePatches) {
2128
2219
  val originalBytes = manifest.assets.getValue(assetPath).downloadByteSize ?: return null
2129
2220
  val expectedBytes =
2130
- descriptor.patch?.let { patch ->
2221
+ if (patch == null) {
2222
+ originalBytes
2223
+ } else {
2131
2224
  val patchBytes = patch.byteSize?.takeIf { it in 0..MAX_SAFE_INTEGER } ?: return null
2132
2225
  minOf(originalBytes, patchBytes)
2133
- } ?: originalBytes
2226
+ }
2134
2227
  if (individualBytes > MAX_SAFE_INTEGER - expectedBytes) return null
2135
2228
  individualBytes += expectedBytes
2136
2229
  }
@@ -2151,7 +2244,7 @@ class BundleFileStorageService(
2151
2244
  val framedByteLimit = individualCost + tarFramingBytes
2152
2245
 
2153
2246
  if (archive.downloadByteSize <= individualCost) return archive
2154
- if (downloads.size != manifest.assets.size || downloads.values.any { it.patch != null }) return null
2247
+ if (downloads.size != manifest.assets.size || applicablePatches.values.any { it != null }) return null
2155
2248
  return archive.takeIf { it.downloadByteSize <= framedByteLimit }
2156
2249
  }
2157
2250
 
@@ -2427,7 +2520,11 @@ class BundleFileStorageService(
2427
2520
  }
2428
2521
  downloads
2429
2522
  }
2430
- val selectedArchive = selectArchive(targetManifest, downloads, archiveUrl)
2523
+ val builtInPatchBases =
2524
+ updateStep(UpdateFailureStage.INSTALL, UpdateFailureResource.FILE) {
2525
+ prepareBuiltInPatchBases(downloads, currentBundleId, tempDir)
2526
+ }
2527
+ val selectedArchive = selectArchive(targetManifest, downloads, archiveUrl, currentBundleId, builtInPatchBases)
2431
2528
  val archiveProgress =
2432
2529
  if (selectedArchive != null) {
2433
2530
  updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.ARCHIVE) {
@@ -2476,6 +2573,7 @@ class BundleFileStorageService(
2476
2573
  changedAsset = downloads.getValue(path),
2477
2574
  currentBundleId = currentBundleId,
2478
2575
  activeBundleDir = activeBundleDir,
2576
+ builtInBaseFile = builtInPatchBases[path],
2479
2577
  targetFile =
2480
2578
  RelativePathResolver.resolveInside(tmpDir, path)
2481
2579
  ?: throw HotUpdaterException.invalidBundle(),
@@ -2729,6 +2827,30 @@ class BundleFileStorageService(
2729
2827
  synchronized(releaseStateLock) { resetChannelLocked() }
2730
2828
  }
2731
2829
 
2830
+ override suspend fun resetChannelIfSelectionCurrent(
2831
+ catalogId: String,
2832
+ scopeKey: String,
2833
+ generation: Long,
2834
+ catalogHash: String,
2835
+ channel: String,
2836
+ selectionContextHash: String,
2837
+ ): Boolean =
2838
+ withContext(Dispatchers.IO) {
2839
+ // The catalog checks take the same reentrant monitor, so none
2840
+ // accepts a newer catalog between this check and the reset.
2841
+ synchronized(releaseStateLock) {
2842
+ isReleaseSelectionCurrent(
2843
+ catalogId = catalogId,
2844
+ scopeKey = scopeKey,
2845
+ generation = generation,
2846
+ catalogHash = catalogHash,
2847
+ channel = channel,
2848
+ selectionContextHash = selectionContextHash,
2849
+ ) &&
2850
+ resetChannelLocked()
2851
+ }
2852
+ }
2853
+
2732
2854
  private fun resetChannelLocked(): Boolean {
2733
2855
  if (!setBundleURL(null)) {
2734
2856
  return false
@@ -639,6 +639,32 @@ class HotUpdaterImpl {
639
639
  }
640
640
  }
641
641
 
642
+ /**
643
+ * Resets the channel while the Release selection [guard] names is
644
+ * current, as [resetChannel] does; false for a stale one.
645
+ */
646
+ internal suspend fun resetChannelIfSelectionCurrent(guard: ReleaseSelectionGuard): Boolean =
647
+ withContext(Dispatchers.IO) {
648
+ synchronized(launchPreparationLock) {
649
+ val success =
650
+ runBlocking {
651
+ bundleStorage.resetChannelIfSelectionCurrent(
652
+ catalogId = guard.catalogId,
653
+ scopeKey = guard.scopeKey,
654
+ generation = guard.generation,
655
+ catalogHash = guard.catalogHash,
656
+ channel = guard.channel,
657
+ selectionContextHash = guard.selectionContextHash,
658
+ )
659
+ }
660
+ if (success) {
661
+ preferences.setItem(CHANNEL_STORAGE_KEY, null)
662
+ currentLaunchSelection = null
663
+ }
664
+ success
665
+ }
666
+ }
667
+
642
668
  private fun prepareLaunchIfNeeded(): LaunchSelection =
643
669
  synchronized(launchPreparationLock) {
644
670
  prepareLaunchLocked()
@@ -57,6 +57,8 @@ internal class HotUpdaterImplUpdateCheckState(
57
57
 
58
58
  override suspend fun commitReleaseSelection(selection: PersistedSelection): Boolean = impl.commitReleaseSelection(selection)
59
59
 
60
+ override suspend fun resetChannelIfSelectionCurrent(guard: ReleaseSelectionGuard): Boolean = impl.resetChannelIfSelectionCurrent(guard)
61
+
60
62
  override suspend fun updateBundle(
61
63
  request: StageBundleRequest,
62
64
  onProgress: (Double) -> Unit,
@@ -171,4 +173,15 @@ internal object NativeUpdateCheck {
171
173
  name: String,
172
174
  impl: () -> HotUpdaterImpl,
173
175
  ): Boolean = checker(context, name, impl).resetChannel { impl().resetChannel() }
176
+
177
+ /**
178
+ * Clears [name]'s runtime channel, once a check or staging of that name
179
+ * finishes, only while the Release selection [guard] names is current.
180
+ */
181
+ suspend fun resetChannelIfSelectionCurrent(
182
+ context: Context,
183
+ name: String,
184
+ guard: ReleaseSelectionGuard,
185
+ impl: () -> HotUpdaterImpl,
186
+ ): Boolean = checker(context, name, impl).resetChannel { impl().resetChannelIfSelectionCurrent(guard) }
174
187
  }
@@ -109,6 +109,13 @@ internal interface UpdateCheckState {
109
109
 
110
110
  suspend fun commitReleaseSelection(selection: PersistedSelection): Boolean
111
111
 
112
+ /**
113
+ * Clears the runtime channel and restores the built-in bundle, as
114
+ * `resetChannel` does, only while the Release selection [guard] names is
115
+ * current: one step with the catalog checks.
116
+ */
117
+ suspend fun resetChannelIfSelectionCurrent(guard: ReleaseSelectionGuard): Boolean
118
+
112
119
  /** Stages a bundle, reporting its download progress from 0 to 1. */
113
120
  suspend fun updateBundle(
114
121
  request: StageBundleRequest,
@@ -248,6 +255,15 @@ internal class NativeUpdateChecker(
248
255
  /** The target the last staging committed, for another retained result of the same target. */
249
256
  @Volatile
250
257
  private var lastCommittedTargetKey: String? = null
258
+
259
+ /**
260
+ * The last switched-channel leave that reset the device, for another
261
+ * retained result of the same catalog. Kept apart from
262
+ * [lastCommittedTargetKey], which a reset clears.
263
+ */
264
+ @Volatile
265
+ private var lastLeftTargetKey: String? = null
266
+
251
267
  private val events =
252
268
  NativeUpdateEventQueue(state::getStorageItem, state::setStorageItem, build = {
253
269
  NativeUpdateEventQueue.buildOf(state.getAppVersion(), state.getMinBundleId(), requestCohort ?: state.getCohort())
@@ -423,6 +439,7 @@ internal class NativeUpdateChecker(
423
439
  currentBundleId = currentBundleId,
424
440
  minimumReleaseId = minimumReleaseId,
425
441
  currentChannel = currentChannel,
442
+ isSwitched = isSwitched,
426
443
  targetChannel = targetChannel,
427
444
  explicitChannel = explicitChannel,
428
445
  cohort = cohort,
@@ -508,6 +525,7 @@ internal class NativeUpdateChecker(
508
525
  val currentBundleId: String,
509
526
  val minimumReleaseId: String,
510
527
  val currentChannel: String,
528
+ val isSwitched: Boolean,
511
529
  val targetChannel: String,
512
530
  val explicitChannel: String?,
513
531
  val cohort: String,
@@ -524,7 +542,8 @@ internal class NativeUpdateChecker(
524
542
 
525
543
  private class CheckedUpdate(
526
544
  val update: HotUpdaterUpdate,
527
- val selection: SelectedUpdate,
545
+ /** What staging the update needs; none for leaving a switched channel. */
546
+ val selection: SelectedUpdate?,
528
547
  )
529
548
 
530
549
  /** What staging a selected update needs. */
@@ -625,6 +644,18 @@ internal class NativeUpdateChecker(
625
644
  if (!state.acceptReleaseCatalog(guard)) {
626
645
  throw InvalidUpdateResponseException("Rejected a stale or inconsistent Release catalog")
627
646
  }
647
+ // The switched channel holds nothing for this build any more, as a
648
+ // deleted channel does: the server answers its scope with an accepted
649
+ // catalog without releases. A scope without a catalog proves nothing,
650
+ // since a cache can still answer that for a channel deployed seconds ago.
651
+ if (
652
+ input.isSwitched &&
653
+ input.targetChannel == input.currentChannel &&
654
+ catalog.releases.isEmpty() &&
655
+ catalog.rollbackReleases.isEmpty()
656
+ ) {
657
+ return leaveSwitchedChannel(input, guard)
658
+ }
628
659
  val stableInTargetScope =
629
660
  activeState.stableSelection?.takeIf { it.catalogId == catalogId && it.scopeKey == scopeKey }
630
661
  val desired =
@@ -761,6 +792,103 @@ internal class NativeUpdateChecker(
761
792
  return CheckedUpdate(update, selection)
762
793
  }
763
794
 
795
+ /**
796
+ * Leaves a switched channel that holds nothing for this build, such as a
797
+ * deleted one, as checkForUpdate.ts does: a rollback to the built-in
798
+ * bundle whose staging also clears the runtime channel, so the next check
799
+ * reads the build's default channel. The check itself resets nothing,
800
+ * since the reset removes the running bundle's files.
801
+ */
802
+ private fun leaveSwitchedChannel(
803
+ input: CheckInput,
804
+ guard: ReleaseSelectionGuard,
805
+ ): CheckedUpdate {
806
+ val options = input.options
807
+ val channel = input.currentChannel
808
+ val runsBuiltIn = input.currentBundleId == input.minimumReleaseId
809
+ val errorScope =
810
+ ErrorScope(
811
+ bundleId = input.currentBundleId,
812
+ channel = channel,
813
+ updateStrategy = options.updateStrategy,
814
+ targetBundleId = input.minimumReleaseId,
815
+ )
816
+ events.add(
817
+ "onUpdateCheck",
818
+ JSONObject().apply {
819
+ put("status", "UPDATE_AVAILABLE")
820
+ put("channel", channel)
821
+ put("fromBundleId", input.currentBundleId)
822
+ putNullable("fromReleaseId", runningReleaseId(input.currentBundleId, channel))
823
+ put("toBundleId", input.minimumReleaseId)
824
+ put("toReleaseId", JSONObject.NULL)
825
+ put("transitionKind", "USE_BUILTIN")
826
+ put("updateStatus", DesiredReleaseStatus.ROLLBACK.name)
827
+ put("shouldForceUpdate", !runsBuiltIn)
828
+ put("updateStrategy", options.updateStrategy.value)
829
+ },
830
+ )
831
+ // With the catalog's freshness guard, as ordinary selections, so a
832
+ // leave from an older catalog never counts as a current one.
833
+ val targetKey =
834
+ JSONArray()
835
+ .put("LEAVE_CHANNEL")
836
+ .put(channel)
837
+ .put(input.minimumReleaseId)
838
+ .put(guard.catalogId)
839
+ .put(guard.generation)
840
+ .put(guard.catalogHash)
841
+ .put(guard.selectionContextHash)
842
+ .toString()
843
+ val update =
844
+ HotUpdaterUpdate(
845
+ id = input.minimumReleaseId,
846
+ releaseId = null,
847
+ status = HotUpdaterUpdateStatus.ROLLBACK,
848
+ shouldForceUpdate = !runsBuiltIn,
849
+ message = null,
850
+ rolloutCohortCount = ReleaseSelector.DEFAULT_ROLLOUT_COHORT_COUNT,
851
+ targetCohorts = emptyList(),
852
+ transitionKind = "USE_BUILTIN",
853
+ runsCurrentBundle = runsBuiltIn,
854
+ targetKey = targetKey,
855
+ install = { _ ->
856
+ try {
857
+ // Once no check or staging runs, as a reset must; only a real
858
+ // reset undoes what was staged.
859
+ mutex.withLock {
860
+ // This leave, from this or another result of the same catalog,
861
+ // already happened: calling it again changes nothing, not even
862
+ // what was staged since.
863
+ if (lastLeftTargetKey == targetKey && state.getChannel() == state.getDefaultChannel()) {
864
+ return@withLock true
865
+ }
866
+ // A device that left or changed the channel meanwhile keeps it;
867
+ // native checks the catalog again in the same step as the reset,
868
+ // so a newer one accepted meanwhile keeps it too.
869
+ if (state.getChannel() != channel || !state.resetChannelIfSelectionCurrent(guard)) {
870
+ throw StaleReleaseCatalogException()
871
+ }
872
+ lastCommittedTargetKey = null
873
+ lastLeftTargetKey = targetKey
874
+ events.remove("onBundleDownloaded")
875
+ true
876
+ }
877
+ } catch (error: CancellationException) {
878
+ throw error
879
+ } catch (error: Exception) {
880
+ reportUpdateError(
881
+ error,
882
+ classifyUpdateError(error, UpdateFailureStage.DOWNLOAD, UpdateFailureResource.ARTIFACT),
883
+ errorScope,
884
+ )
885
+ throw error
886
+ }
887
+ },
888
+ )
889
+ return CheckedUpdate(update, selection = null)
890
+ }
891
+
764
892
  /**
765
893
  * Commits or stages [selection] as `updateBundleForSelection` in
766
894
  * checkForUpdate.ts does, queueing `onUpdateError` for a classified failure.
@@ -543,6 +543,45 @@ class HotUpdaterModule internal constructor(
543
543
 
544
544
  override fun takeNativeUpdateEvents(): String? = getInstance().takeNativeUpdateEvents()
545
545
 
546
+ override fun resetChannelIfSelectionCurrent(
547
+ params: ReadableMap,
548
+ promise: Promise,
549
+ ) {
550
+ val catalogId = params.getString("catalogId")
551
+ val scopeKey = params.getString("scopeKey")
552
+ val catalogHash = params.getString("catalogHash")
553
+ val channel = params.getString("channel")
554
+ val selectionContextHash = params.getString("selectionContextHash")
555
+ if (catalogId == null || scopeKey == null || catalogHash == null || channel == null || selectionContextHash == null) {
556
+ promise.resolve(false)
557
+ return
558
+ }
559
+ val guard =
560
+ ReleaseSelectionGuard(
561
+ catalogId = catalogId,
562
+ scopeKey = scopeKey,
563
+ generation = params.getDouble("generation").toLong(),
564
+ catalogHash = catalogHash,
565
+ channel = channel,
566
+ selectionContextHash = selectionContextHash,
567
+ )
568
+ moduleScope.launch {
569
+ try {
570
+ // Waits for a native check or download, as resetChannel does.
571
+ val name = runtimeInstance.name
572
+ promise.resolve(
573
+ NativeUpdateCheck.resetChannelIfSelectionCurrent(mReactApplicationContext, name, guard) {
574
+ runtimeInstance.impl(name)
575
+ },
576
+ )
577
+ } catch (e: HotUpdaterException) {
578
+ promise.reject(e.code, e.message)
579
+ } catch (e: Exception) {
580
+ promise.reject("UNKNOWN_ERROR", e.message ?: "Failed to reset channel")
581
+ }
582
+ }
583
+ }
584
+
546
585
  override fun resetChannel(promise: Promise) {
547
586
  moduleScope.launch {
548
587
  try {
@@ -571,6 +571,46 @@ class HotUpdaterModule internal constructor(
571
571
  @ReactMethod(isBlockingSynchronousMethod = true)
572
572
  override fun takeNativeUpdateEvents(): String? = getInstance().takeNativeUpdateEvents()
573
573
 
574
+ @ReactMethod
575
+ override fun resetChannelIfSelectionCurrent(
576
+ params: ReadableMap,
577
+ promise: Promise,
578
+ ) {
579
+ val catalogId = params.getString("catalogId")
580
+ val scopeKey = params.getString("scopeKey")
581
+ val catalogHash = params.getString("catalogHash")
582
+ val channel = params.getString("channel")
583
+ val selectionContextHash = params.getString("selectionContextHash")
584
+ if (catalogId == null || scopeKey == null || catalogHash == null || channel == null || selectionContextHash == null) {
585
+ promise.resolve(false)
586
+ return
587
+ }
588
+ val guard =
589
+ ReleaseSelectionGuard(
590
+ catalogId = catalogId,
591
+ scopeKey = scopeKey,
592
+ generation = params.getDouble("generation").toLong(),
593
+ catalogHash = catalogHash,
594
+ channel = channel,
595
+ selectionContextHash = selectionContextHash,
596
+ )
597
+ moduleScope.launch {
598
+ try {
599
+ // Waits for a native check or download, as resetChannel does.
600
+ val name = runtimeInstance.name
601
+ promise.resolve(
602
+ NativeUpdateCheck.resetChannelIfSelectionCurrent(mReactApplicationContext, name, guard) {
603
+ runtimeInstance.impl(name)
604
+ },
605
+ )
606
+ } catch (e: HotUpdaterException) {
607
+ promise.reject(e.code, e.message)
608
+ } catch (e: Exception) {
609
+ promise.reject("UNKNOWN_ERROR", e.message ?: "Failed to reset channel")
610
+ }
611
+ }
612
+ }
613
+
574
614
  @ReactMethod
575
615
  override fun resetChannel(promise: Promise) {
576
616
  moduleScope.launch {
@@ -77,4 +77,9 @@ abstract class HotUpdaterSpec internal constructor(
77
77
  abstract fun takeNativeUpdateEvents(): String?
78
78
 
79
79
  abstract fun resetChannel(promise: Promise)
80
+
81
+ abstract fun resetChannelIfSelectionCurrent(
82
+ params: ReadableMap,
83
+ promise: Promise,
84
+ )
80
85
  }
@@ -51,6 +51,9 @@ NS_ASSUME_NONNULL_BEGIN
51
51
  - (NSDictionary<NSString *, id> *)getManifest;
52
52
  - (void)resetLaunchPreparation;
53
53
  - (void)resetChannel:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject;
54
+ - (void)resetChannelIfSelectionCurrent:(nullable NSDictionary *)params
55
+ resolver:(RCTPromiseResolveBlock)resolve
56
+ rejecter:(RCTPromiseRejectBlock)reject;
54
57
  @end
55
58
 
56
59
  @protocol HotUpdaterConfigInterface <NSObject>
@@ -68,6 +71,10 @@ NS_ASSUME_NONNULL_BEGIN
68
71
  - (void)resetChannelWithImpl:(id<HotUpdaterImplInterface>)impl
69
72
  resolver:(RCTPromiseResolveBlock)resolve
70
73
  rejecter:(RCTPromiseRejectBlock)reject;
74
+ - (void)resetChannelIfSelectionCurrentWithImpl:(id<HotUpdaterImplInterface>)impl
75
+ params:(nullable NSDictionary *)params
76
+ resolver:(RCTPromiseResolveBlock)resolve
77
+ rejecter:(RCTPromiseRejectBlock)reject;
71
78
  - (nullable NSString *)takeEventsWithImpl:(id<HotUpdaterImplInterface>)impl;
72
79
  - (id<HotUpdaterImplInterface>)implForBundleURL:(nullable NSURL *)bundleURL
73
80
  NS_SWIFT_NAME(impl(forBundleURL:));