@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.
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +136 -14
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +26 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +13 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +129 -1
- package/android/src/newarch/HotUpdaterModule.kt +39 -0
- package/android/src/oldarch/HotUpdaterModule.kt +40 -0
- package/android/src/oldarch/HotUpdaterSpec.kt +5 -0
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +7 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +147 -22
- package/ios/HotUpdater/Internal/HotUpdater.mm +18 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +31 -0
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +14 -0
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +37 -0
- package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +52 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +124 -0
- package/lib/commonjs/checkForUpdate.js +101 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/native.js +22 -2
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/sdkVersion.js.map +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +102 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/native.js +19 -0
- package/lib/module/native.js.map +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/sdkVersion.js.map +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +7 -0
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +7 -0
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +133 -0
- package/src/native.ts +21 -0
- package/src/sdkVersion.ts +1 -1
- 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
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
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(
|
|
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 ?:
|
|
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,
|
|
2218
|
+
for ((assetPath, patch) in applicablePatches) {
|
|
2128
2219
|
val originalBytes = manifest.assets.getValue(assetPath).downloadByteSize ?: return null
|
|
2129
2220
|
val expectedBytes =
|
|
2130
|
-
|
|
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
|
-
}
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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 {
|
|
@@ -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:));
|