@hot-updater/react-native 1.0.8 → 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.
- package/HotUpdater.podspec +14 -2
- package/Package.swift +24 -3
- package/android/build.gradle +2 -6
- package/android/hot-updater-fingerprint.gradle +135 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
- package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
- package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
- package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
- package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
- package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
- package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
- package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
- package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
- package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
- package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
- package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
- package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
- package/android/src/newarch/HotUpdater.kt +53 -15
- package/android/src/newarch/HotUpdaterModule.kt +42 -17
- package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
- package/android/src/oldarch/HotUpdater.kt +51 -12
- package/android/src/oldarch/HotUpdaterModule.kt +43 -17
- package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
- package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
- package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
- package/ios/HotUpdater/Internal/CohortService.swift +8 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
- package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
- package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
- package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
- package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
- package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
- package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
- package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
- package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
- package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
- package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
- package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
- package/ios/HotUpdater/Package.swift +11 -0
- package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
- package/ios/HotUpdater/Public/HotUpdater.h +21 -2
- package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
- package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
- package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
- package/lib/commonjs/checkForUpdate.js +3 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +9 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +24 -79
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/nativeUpdateEvents.js +66 -0
- package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +10 -13
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +19 -73
- package/lib/module/native.js.map +1 -1
- package/lib/module/nativeUpdateEvents.js +61 -0
- package/lib/module/nativeUpdateEvents.js.map +1 -0
- package/lib/module/sdkVersion.js +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/index.d.ts +4 -14
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +9 -46
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
- 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/index.d.ts +4 -14
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +9 -46
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +7 -1
- package/src/index.ts +9 -17
- package/src/native.ts +19 -91
- package/src/nativeUpdateEvents.ts +77 -0
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +20 -3
- package/android/libs/README.md +0 -50
- package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
- package/android/src/bridge/BridgeReactInstance.kt +0 -38
- package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
- package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
- package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
- package/android/src/newarch/ReactIntegrationManager.kt +0 -212
- package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import java.util.Locale
|
|
4
|
+
import kotlin.math.abs
|
|
5
|
+
import kotlin.math.floor
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Release selection, ported from @hot-updater/protocol (releaseCatalog.ts and
|
|
9
|
+
* rollout.ts). The shared conformance fixture in
|
|
10
|
+
* packages/test-utils/fixtures/release-selection-conformance.json pins every
|
|
11
|
+
* result to the TypeScript one, so change both together. Strings compare by
|
|
12
|
+
* UTF-16 code units, as JavaScript's `<` and `>` do.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
internal enum class SelectionKind {
|
|
16
|
+
BUNDLE,
|
|
17
|
+
BUILTIN,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
internal enum class DesiredReleaseStatus {
|
|
21
|
+
UPDATE,
|
|
22
|
+
ROLLBACK,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Which Release the app is running, which differs from the active one while a newer bundle is staged. */
|
|
26
|
+
internal sealed class RunningReleaseId {
|
|
27
|
+
/** The running Release is the active Release. */
|
|
28
|
+
object SameAsActive : RunningReleaseId()
|
|
29
|
+
|
|
30
|
+
/** The running Release, where null compares by the current bundle ID. */
|
|
31
|
+
data class Explicit(
|
|
32
|
+
val releaseId: String?,
|
|
33
|
+
) : RunningReleaseId()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
internal data class ReleaseSelectionInput(
|
|
37
|
+
val builtInBundleId: String,
|
|
38
|
+
val currentBundleId: String,
|
|
39
|
+
val activeReleaseId: String?,
|
|
40
|
+
val runningReleaseId: RunningReleaseId = RunningReleaseId.SameAsActive,
|
|
41
|
+
val minimumReleaseId: String,
|
|
42
|
+
val cohort: String?,
|
|
43
|
+
val crashedBundleIds: List<String>,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
internal data class ReleaseSelectionContextInput(
|
|
47
|
+
val activeBundleId: String?,
|
|
48
|
+
val activeReleaseId: String?,
|
|
49
|
+
val cohort: String?,
|
|
50
|
+
val minimumReleaseId: String,
|
|
51
|
+
val strategy: ReleaseStrategy,
|
|
52
|
+
val strategyValue: String,
|
|
53
|
+
val crashedBundleIds: List<String>,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
internal data class DesiredRelease(
|
|
57
|
+
val kind: SelectionKind,
|
|
58
|
+
val releaseId: String?,
|
|
59
|
+
val bundleId: String,
|
|
60
|
+
val release: ReleaseCatalogDescriptor?,
|
|
61
|
+
val status: DesiredReleaseStatus,
|
|
62
|
+
/**
|
|
63
|
+
* Whether the device must apply this transition immediately: every rollback,
|
|
64
|
+
* and an update when a forced Release the device's cohort was eligible for
|
|
65
|
+
* lies after the running bundle's Release, up to and including the selected
|
|
66
|
+
* Release.
|
|
67
|
+
*/
|
|
68
|
+
val shouldForceUpdate: Boolean,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
internal enum class ReleaseTransitionReason(
|
|
72
|
+
val authorized: Boolean,
|
|
73
|
+
) {
|
|
74
|
+
FIRST_AUTHENTICATED_SELECTION(true),
|
|
75
|
+
NEWER_POLICY(true),
|
|
76
|
+
CONTEXT_RESELECTION(true),
|
|
77
|
+
EXPLICIT_SCOPE_SWITCH(true),
|
|
78
|
+
BACKWARD_NOT_AUTHORIZED(false),
|
|
79
|
+
EMPTY_TARGET_SCOPE(false),
|
|
80
|
+
STALE_GENERATION(false),
|
|
81
|
+
UNSOLICITED_SCOPE(false),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
internal object ReleaseSelector {
|
|
85
|
+
const val NUMERIC_COHORT_SIZE = 1000
|
|
86
|
+
const val DEFAULT_ROLLOUT_COHORT_COUNT = NUMERIC_COHORT_SIZE
|
|
87
|
+
const val MAX_COHORT_LENGTH = 64
|
|
88
|
+
private const val SELECTION_KIND_BUILTIN = "BUILTIN"
|
|
89
|
+
|
|
90
|
+
private val CUSTOM_COHORT_PATTERN = Regex("[a-z0-9-]+")
|
|
91
|
+
|
|
92
|
+
fun normalizeCohortValue(cohort: String): String {
|
|
93
|
+
val normalized = cohort.trimJsWhitespace().lowercase(Locale.ROOT)
|
|
94
|
+
return parseNumericCohortValue(normalized)?.toString() ?: normalized
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun normalizeRolloutCohortCount(rolloutCohortCount: Double?): Int {
|
|
98
|
+
if (rolloutCohortCount == null) return DEFAULT_ROLLOUT_COHORT_COUNT
|
|
99
|
+
if (rolloutCohortCount <= 0) return 0
|
|
100
|
+
if (rolloutCohortCount >= NUMERIC_COHORT_SIZE) return NUMERIC_COHORT_SIZE
|
|
101
|
+
return floor(rolloutCohortCount).toInt()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun isCustomCohort(cohort: String): Boolean {
|
|
105
|
+
val normalized = normalizeCohortValue(cohort)
|
|
106
|
+
return normalized.isNotEmpty() &&
|
|
107
|
+
normalized.length <= MAX_COHORT_LENGTH &&
|
|
108
|
+
!isAsciiDigits(normalized) &&
|
|
109
|
+
CUSTOM_COHORT_PATTERN.matches(normalized)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
fun isCohortEligibleForUpdate(
|
|
113
|
+
bundleId: String,
|
|
114
|
+
cohort: String?,
|
|
115
|
+
rolloutCohortCount: Double?,
|
|
116
|
+
targetCohorts: List<String>?,
|
|
117
|
+
): Boolean {
|
|
118
|
+
val normalizedCohort = cohort?.let(::normalizeCohortValue)
|
|
119
|
+
val normalizedTargetCohorts = targetCohorts?.map(::normalizeCohortValue) ?: emptyList()
|
|
120
|
+
|
|
121
|
+
if (normalizedCohort != null && normalizedTargetCohorts.contains(normalizedCohort)) {
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
val normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount)
|
|
126
|
+
if (normalizedRolloutCount <= 0) return false
|
|
127
|
+
if (normalizedCohort == null) return normalizedRolloutCount >= NUMERIC_COHORT_SIZE
|
|
128
|
+
|
|
129
|
+
val numericCohort = parseNumericCohortValue(normalizeCohortValue(normalizedCohort)) ?: return false
|
|
130
|
+
if (normalizedRolloutCount >= NUMERIC_COHORT_SIZE) return true
|
|
131
|
+
|
|
132
|
+
return getNumericCohortRolloutPosition(bundleId, numericCohort) < normalizedRolloutCount
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fun isReleaseEligibleForCohort(
|
|
136
|
+
releaseId: String,
|
|
137
|
+
rolloutCohortCount: Double,
|
|
138
|
+
targetCohorts: List<String>,
|
|
139
|
+
cohort: String?,
|
|
140
|
+
): Boolean {
|
|
141
|
+
if (isCohortEligibleForUpdate(releaseId, cohort, rolloutCohortCount, targetCohorts)) {
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return cohort != null &&
|
|
146
|
+
isCustomCohort(normalizeCohortValue(cohort)) &&
|
|
147
|
+
normalizeRolloutCohortCount(rolloutCohortCount) == NUMERIC_COHORT_SIZE
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fun isReleaseEligibleForCohort(
|
|
151
|
+
release: ReleaseCatalogDescriptor,
|
|
152
|
+
cohort: String?,
|
|
153
|
+
): Boolean =
|
|
154
|
+
isReleaseEligibleForCohort(
|
|
155
|
+
releaseId = release.releaseId,
|
|
156
|
+
rolloutCohortCount = release.rolloutCohortCount.toDouble(),
|
|
157
|
+
targetCohorts = release.targetCohorts,
|
|
158
|
+
cohort = cohort,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
fun createSelectionContextHash(input: ReleaseSelectionContextInput): String {
|
|
162
|
+
val crashedBundleIds =
|
|
163
|
+
input.crashedBundleIds
|
|
164
|
+
.distinct()
|
|
165
|
+
.sorted()
|
|
166
|
+
.take(ReleaseCatalogs.MAX_CRASHED_BUNDLES)
|
|
167
|
+
// JSON.stringify of the same keys in the same order.
|
|
168
|
+
val canonical =
|
|
169
|
+
buildString {
|
|
170
|
+
append("{\"activeBundleId\":")
|
|
171
|
+
appendJsonString(input.activeBundleId)
|
|
172
|
+
append(",\"activeReleaseId\":")
|
|
173
|
+
appendJsonString(input.activeReleaseId)
|
|
174
|
+
append(",\"cohort\":")
|
|
175
|
+
appendJsonString(normalizeCohortValue(input.cohort ?: ""))
|
|
176
|
+
append(",\"crashedBundleIds\":[")
|
|
177
|
+
crashedBundleIds.forEachIndexed { index, bundleId ->
|
|
178
|
+
if (index > 0) append(',')
|
|
179
|
+
appendJsonString(bundleId)
|
|
180
|
+
}
|
|
181
|
+
append("],\"minimumReleaseId\":")
|
|
182
|
+
appendJsonString(input.minimumReleaseId)
|
|
183
|
+
append(",\"selectorSchemaVersion\":")
|
|
184
|
+
append(ReleaseCatalogs.SCHEMA_VERSION)
|
|
185
|
+
append(",\"strategy\":")
|
|
186
|
+
appendJsonString(input.strategy.name)
|
|
187
|
+
append(",\"strategyValue\":")
|
|
188
|
+
appendJsonString(input.strategyValue)
|
|
189
|
+
append('}')
|
|
190
|
+
}
|
|
191
|
+
return hashSelectionContext(canonical)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fun selectDesiredRelease(
|
|
195
|
+
catalog: ReleaseCatalog,
|
|
196
|
+
input: ReleaseSelectionInput,
|
|
197
|
+
): DesiredRelease? {
|
|
198
|
+
val crashedBundleIds = input.crashedBundleIds.toHashSet()
|
|
199
|
+
val activeReleaseId = input.activeReleaseId
|
|
200
|
+
val hasActiveBundle = input.currentBundleId != input.builtInBundleId
|
|
201
|
+
|
|
202
|
+
fun isSafeBundleRelease(release: ReleaseCatalogDescriptor): Boolean {
|
|
203
|
+
val bundleId = release.bundleId ?: return false
|
|
204
|
+
return !crashedBundleIds.contains(bundleId) && bundleId >= input.minimumReleaseId
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
val running = input.runningReleaseId
|
|
208
|
+
val runningReleaseId = if (running is RunningReleaseId.Explicit) running.releaseId else activeReleaseId
|
|
209
|
+
|
|
210
|
+
fun isNewerThanRunning(release: ReleaseCatalogDescriptor): Boolean {
|
|
211
|
+
if (runningReleaseId != null) return release.releaseId > runningReleaseId
|
|
212
|
+
val bundleId = release.bundleId ?: return false
|
|
213
|
+
return bundleId > input.currentBundleId
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// `releases` keeps only the newest few Bundles, so the forced Releases an
|
|
217
|
+
// update carries the device past are read from the complete spine. A
|
|
218
|
+
// forced Release of the running bundle is already satisfied, and one
|
|
219
|
+
// below the native floor is older than what the binary ships.
|
|
220
|
+
fun passesForcedRelease(selected: ReleaseCatalogDescriptor): Boolean =
|
|
221
|
+
(listOf(selected) + catalog.rollbackReleases).any { passed ->
|
|
222
|
+
val bundleId = passed.bundleId
|
|
223
|
+
passed.shouldForceUpdate &&
|
|
224
|
+
bundleId != null &&
|
|
225
|
+
bundleId >= input.minimumReleaseId &&
|
|
226
|
+
bundleId != input.currentBundleId &&
|
|
227
|
+
passed.releaseId <= selected.releaseId &&
|
|
228
|
+
isNewerThanRunning(passed) &&
|
|
229
|
+
isReleaseEligibleForCohort(passed, input.cohort)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fun desiredBundle(
|
|
233
|
+
release: ReleaseCatalogDescriptor,
|
|
234
|
+
status: DesiredReleaseStatus,
|
|
235
|
+
): DesiredRelease =
|
|
236
|
+
DesiredRelease(
|
|
237
|
+
kind = SelectionKind.BUNDLE,
|
|
238
|
+
releaseId = release.releaseId,
|
|
239
|
+
bundleId = requireNotNull(release.bundleId),
|
|
240
|
+
release = release,
|
|
241
|
+
status = status,
|
|
242
|
+
shouldForceUpdate = status == DesiredReleaseStatus.ROLLBACK || passesForcedRelease(release),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
for (release in catalog.releases) {
|
|
246
|
+
if (!isReleaseEligibleForCohort(release, input.cohort)) continue
|
|
247
|
+
|
|
248
|
+
val bundleId = release.bundleId
|
|
249
|
+
val notNewer =
|
|
250
|
+
if (activeReleaseId != null) {
|
|
251
|
+
release.releaseId <= activeReleaseId
|
|
252
|
+
} else {
|
|
253
|
+
hasActiveBundle && (bundleId == null || bundleId <= input.currentBundleId)
|
|
254
|
+
}
|
|
255
|
+
if (notNewer) continue
|
|
256
|
+
|
|
257
|
+
if (isSafeBundleRelease(release)) {
|
|
258
|
+
return desiredBundle(release, DesiredReleaseStatus.UPDATE)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (activeReleaseId != null || hasActiveBundle) {
|
|
263
|
+
val rollbackReleases = catalog.rollbackReleases
|
|
264
|
+
val current =
|
|
265
|
+
rollbackReleases.firstOrNull { release ->
|
|
266
|
+
release.releaseId == activeReleaseId ||
|
|
267
|
+
(activeReleaseId == null && release.bundleId == input.currentBundleId)
|
|
268
|
+
}
|
|
269
|
+
if (current != null && isSafeBundleRelease(current) && isReleaseEligibleForCohort(current, input.cohort)) {
|
|
270
|
+
return desiredBundle(current, DesiredReleaseStatus.UPDATE)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
val rollback =
|
|
274
|
+
rollbackReleases.firstOrNull { release ->
|
|
275
|
+
isSafeBundleRelease(release) &&
|
|
276
|
+
if (activeReleaseId != null) {
|
|
277
|
+
release.releaseId < activeReleaseId
|
|
278
|
+
} else {
|
|
279
|
+
requireNotNull(release.bundleId) < input.currentBundleId
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (rollback != null) {
|
|
283
|
+
return desiredBundle(rollback, DesiredReleaseStatus.ROLLBACK)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (input.currentBundleId <= input.minimumReleaseId) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (catalog.fallbackPolicy != ReleaseCatalogs.FALLBACK_POLICY) {
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return DesiredRelease(
|
|
296
|
+
kind = SelectionKind.BUILTIN,
|
|
297
|
+
releaseId = null,
|
|
298
|
+
bundleId = input.builtInBundleId,
|
|
299
|
+
release = null,
|
|
300
|
+
status = DesiredReleaseStatus.ROLLBACK,
|
|
301
|
+
shouldForceUpdate = true,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fun authorizeReleaseTransition(
|
|
306
|
+
active: PersistedSelection?,
|
|
307
|
+
desired: PersistedSelection,
|
|
308
|
+
explicitScopeSwitch: Boolean,
|
|
309
|
+
): ReleaseTransitionReason {
|
|
310
|
+
val activeGeneration = active?.generation
|
|
311
|
+
if (active?.scopeKey == null || activeGeneration == null) {
|
|
312
|
+
return ReleaseTransitionReason.FIRST_AUTHENTICATED_SELECTION
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (desired.catalogId != active.catalogId || desired.scopeKey != active.scopeKey) {
|
|
316
|
+
if (!explicitScopeSwitch) return ReleaseTransitionReason.UNSOLICITED_SCOPE
|
|
317
|
+
if (desired.releaseId == null || desired.kind == SELECTION_KIND_BUILTIN) {
|
|
318
|
+
return ReleaseTransitionReason.EMPTY_TARGET_SCOPE
|
|
319
|
+
}
|
|
320
|
+
return ReleaseTransitionReason.EXPLICIT_SCOPE_SWITCH
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
val desiredGeneration = desired.generation
|
|
324
|
+
if (desiredGeneration == null || desiredGeneration < activeGeneration) {
|
|
325
|
+
return ReleaseTransitionReason.STALE_GENERATION
|
|
326
|
+
}
|
|
327
|
+
if (desiredGeneration > activeGeneration) {
|
|
328
|
+
return ReleaseTransitionReason.NEWER_POLICY
|
|
329
|
+
}
|
|
330
|
+
if (desired.selectionContextHash != active.selectionContextHash) {
|
|
331
|
+
return ReleaseTransitionReason.CONTEXT_RESELECTION
|
|
332
|
+
}
|
|
333
|
+
return ReleaseTransitionReason.BACKWARD_NOT_AUTHORIZED
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private fun isAsciiDigits(value: String): Boolean = value.isNotEmpty() && value.all { it in '0'..'9' }
|
|
337
|
+
|
|
338
|
+
private fun parseNumericCohortValue(cohort: String): Int? {
|
|
339
|
+
if (!isAsciiDigits(cohort)) return null
|
|
340
|
+
val digits = cohort.trimStart('0')
|
|
341
|
+
if (digits.length > 4) return null
|
|
342
|
+
val parsed = if (digits.isEmpty()) 0 else digits.toInt()
|
|
343
|
+
return if (parsed in 1..NUMERIC_COHORT_SIZE) parsed else null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** rollout.ts `hashString`: a 32-bit wrapping hash of UTF-16 code units. */
|
|
347
|
+
private fun hashString(value: String): Int {
|
|
348
|
+
var hash = 0
|
|
349
|
+
for (char in value) {
|
|
350
|
+
hash = (hash shl 5) - hash + char.code
|
|
351
|
+
}
|
|
352
|
+
return hash
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private fun positiveMod(
|
|
356
|
+
value: Int,
|
|
357
|
+
modulus: Int,
|
|
358
|
+
): Int = ((value % modulus) + modulus) % modulus
|
|
359
|
+
|
|
360
|
+
private fun gcd(
|
|
361
|
+
a: Int,
|
|
362
|
+
b: Int,
|
|
363
|
+
): Int {
|
|
364
|
+
var x = abs(a)
|
|
365
|
+
var y = abs(b)
|
|
366
|
+
while (y != 0) {
|
|
367
|
+
val next = x % y
|
|
368
|
+
x = y
|
|
369
|
+
y = next
|
|
370
|
+
}
|
|
371
|
+
return x
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private fun modularInverse(
|
|
375
|
+
value: Int,
|
|
376
|
+
modulus: Int,
|
|
377
|
+
): Int {
|
|
378
|
+
var t = 0
|
|
379
|
+
var newT = 1
|
|
380
|
+
var r = modulus
|
|
381
|
+
var newR = positiveMod(value, modulus)
|
|
382
|
+
while (newR != 0) {
|
|
383
|
+
val quotient = r / newR
|
|
384
|
+
val nextT = t - quotient * newT
|
|
385
|
+
t = newT
|
|
386
|
+
newT = nextT
|
|
387
|
+
val nextR = r - quotient * newR
|
|
388
|
+
r = newR
|
|
389
|
+
newR = nextR
|
|
390
|
+
}
|
|
391
|
+
check(r <= 1) { "No modular inverse for $value mod $modulus" }
|
|
392
|
+
return positiveMod(t, modulus)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private fun getNumericCohortRolloutPosition(
|
|
396
|
+
bundleId: String,
|
|
397
|
+
cohortValue: Int,
|
|
398
|
+
): Int {
|
|
399
|
+
require(cohortValue in 1..NUMERIC_COHORT_SIZE) { "Invalid numeric cohort: $cohortValue" }
|
|
400
|
+
|
|
401
|
+
var multiplier = positiveMod(hashString("$bundleId:multiplier"), 997)
|
|
402
|
+
if (multiplier == 0) multiplier = 1
|
|
403
|
+
while (gcd(multiplier, NUMERIC_COHORT_SIZE) != 1) {
|
|
404
|
+
multiplier = positiveMod(multiplier + 1, NUMERIC_COHORT_SIZE)
|
|
405
|
+
if (multiplier == 0) multiplier = 1
|
|
406
|
+
}
|
|
407
|
+
val offset = positiveMod(hashString("$bundleId:offset"), NUMERIC_COHORT_SIZE)
|
|
408
|
+
val inverseMultiplier = modularInverse(multiplier, NUMERIC_COHORT_SIZE)
|
|
409
|
+
|
|
410
|
+
return positiveMod(inverseMultiplier * (cohortValue - 1 - offset), NUMERIC_COHORT_SIZE)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Two 32-bit multiplicative hashes of the UTF-16 code units, as `Math.imul` computes them. */
|
|
414
|
+
private fun hashSelectionContext(value: String): String {
|
|
415
|
+
var first = 0x811c9dc5L.toInt()
|
|
416
|
+
var second = 0x9e3779b9L.toInt()
|
|
417
|
+
for (char in value) {
|
|
418
|
+
val code = char.code
|
|
419
|
+
first = (first xor code) * 0x01000193
|
|
420
|
+
second = (second xor code) * 0x85ebca6bL.toInt()
|
|
421
|
+
}
|
|
422
|
+
return "v1:" + Integer.toHexString(first).padStart(8, '0') + Integer.toHexString(second).padStart(8, '0')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Appends [value] as `JSON.stringify` writes it, or `null`. */
|
|
426
|
+
private fun StringBuilder.appendJsonString(value: String?) {
|
|
427
|
+
if (value == null) {
|
|
428
|
+
append("null")
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
append('"')
|
|
432
|
+
var index = 0
|
|
433
|
+
while (index < value.length) {
|
|
434
|
+
val char = value[index]
|
|
435
|
+
if (char == '"') {
|
|
436
|
+
append("\\\"")
|
|
437
|
+
} else if (char == '\\') {
|
|
438
|
+
append("\\\\")
|
|
439
|
+
} else if (char == '\b') {
|
|
440
|
+
append("\\b")
|
|
441
|
+
} else if (char == '\u000c') {
|
|
442
|
+
append("\\f")
|
|
443
|
+
} else if (char == '\n') {
|
|
444
|
+
append("\\n")
|
|
445
|
+
} else if (char == '\r') {
|
|
446
|
+
append("\\r")
|
|
447
|
+
} else if (char == '\t') {
|
|
448
|
+
append("\\t")
|
|
449
|
+
} else if (char.code < 0x20) {
|
|
450
|
+
appendUnicodeEscape(char)
|
|
451
|
+
} else if (Character.isHighSurrogate(char)) {
|
|
452
|
+
val next = value.getOrNull(index + 1)
|
|
453
|
+
if (next != null && Character.isLowSurrogate(next)) {
|
|
454
|
+
append(char)
|
|
455
|
+
append(next)
|
|
456
|
+
index += 1
|
|
457
|
+
} else {
|
|
458
|
+
appendUnicodeEscape(char)
|
|
459
|
+
}
|
|
460
|
+
} else if (Character.isLowSurrogate(char)) {
|
|
461
|
+
appendUnicodeEscape(char)
|
|
462
|
+
} else {
|
|
463
|
+
append(char)
|
|
464
|
+
}
|
|
465
|
+
index += 1
|
|
466
|
+
}
|
|
467
|
+
append('"')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private fun StringBuilder.appendUnicodeEscape(char: Char) {
|
|
471
|
+
append("\\u")
|
|
472
|
+
append(Integer.toHexString(char.code).padStart(4, '0'))
|
|
473
|
+
}
|
|
474
|
+
}
|
|
@@ -96,7 +96,7 @@ object SignatureVerifier {
|
|
|
96
96
|
* @param context Application context
|
|
97
97
|
* @return Public key PEM string or null if not configured
|
|
98
98
|
*/
|
|
99
|
-
|
|
99
|
+
internal fun getPublicKeyFromConfig(context: Context): String? {
|
|
100
100
|
val publicKeyPEM =
|
|
101
101
|
NativeConfigUtils.getString(
|
|
102
102
|
context,
|
|
@@ -148,8 +148,17 @@ object SignatureVerifier {
|
|
|
148
148
|
context: Context,
|
|
149
149
|
bundleFile: File,
|
|
150
150
|
fileHash: String?,
|
|
151
|
+
) = verifyBundle(getPublicKeyFromConfig(context), bundleFile, fileHash)
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* [verifyBundle] with an instance's own public key, null when signing is off.
|
|
155
|
+
*/
|
|
156
|
+
internal fun verifyBundle(
|
|
157
|
+
publicKeyPEM: String?,
|
|
158
|
+
bundleFile: File,
|
|
159
|
+
fileHash: String?,
|
|
151
160
|
) {
|
|
152
|
-
val signingEnabled =
|
|
161
|
+
val signingEnabled = publicKeyPEM != null
|
|
153
162
|
|
|
154
163
|
// Rule: null/empty fileHash → REJECT
|
|
155
164
|
if (fileHash.isNullOrEmpty()) {
|
|
@@ -172,7 +181,7 @@ object SignatureVerifier {
|
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
// Rule: sig:... + public key configured → verify signature
|
|
175
|
-
verifySignature(
|
|
184
|
+
verifySignature(publicKeyPEM, bundleFile, signature)
|
|
176
185
|
} else {
|
|
177
186
|
// Unsigned format: <hex_hash>
|
|
178
187
|
|
|
@@ -220,12 +229,18 @@ object SignatureVerifier {
|
|
|
220
229
|
context: Context,
|
|
221
230
|
bundleFile: File,
|
|
222
231
|
signatureBase64: String,
|
|
232
|
+
) = verifySignature(getPublicKeyFromConfig(context), bundleFile, signatureBase64)
|
|
233
|
+
|
|
234
|
+
/** [verifySignature] with an instance's own public key. */
|
|
235
|
+
internal fun verifySignature(
|
|
236
|
+
publicKeyPEM: String?,
|
|
237
|
+
bundleFile: File,
|
|
238
|
+
signatureBase64: String,
|
|
223
239
|
) {
|
|
224
240
|
Log.d(TAG, "Verifying signature for file: ${bundleFile.name}")
|
|
225
241
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
getPublicKeyFromConfig(context)
|
|
242
|
+
val configuredKey =
|
|
243
|
+
publicKeyPEM
|
|
229
244
|
?: run {
|
|
230
245
|
Log.e(TAG, "Cannot verify signature: public key not configured")
|
|
231
246
|
throw SignatureVerificationException.PublicKeyNotConfigured()
|
|
@@ -233,7 +248,7 @@ object SignatureVerifier {
|
|
|
233
248
|
|
|
234
249
|
try {
|
|
235
250
|
// Convert PEM to PublicKey
|
|
236
|
-
val publicKey = createPublicKey(
|
|
251
|
+
val publicKey = createPublicKey(configuredKey)
|
|
237
252
|
|
|
238
253
|
// Calculate file hash
|
|
239
254
|
val fileHashHex =
|
|
@@ -261,9 +276,16 @@ object SignatureVerifier {
|
|
|
261
276
|
context: Context,
|
|
262
277
|
fileHash: String,
|
|
263
278
|
signatureBase64: String,
|
|
279
|
+
) = verifyHashSignature(getPublicKeyFromConfig(context), fileHash, signatureBase64)
|
|
280
|
+
|
|
281
|
+
/** [verifyHashSignature] with an instance's own public key. */
|
|
282
|
+
internal fun verifyHashSignature(
|
|
283
|
+
publicKeyPEM: String?,
|
|
284
|
+
fileHash: String,
|
|
285
|
+
signatureBase64: String,
|
|
264
286
|
) {
|
|
265
|
-
val
|
|
266
|
-
|
|
287
|
+
val configuredKey =
|
|
288
|
+
publicKeyPEM
|
|
267
289
|
?: run {
|
|
268
290
|
Log.e(TAG, "Cannot verify signature: public key not configured")
|
|
269
291
|
throw SignatureVerificationException.PublicKeyNotConfigured()
|
|
@@ -271,7 +293,7 @@ object SignatureVerifier {
|
|
|
271
293
|
|
|
272
294
|
try {
|
|
273
295
|
verifySignatureForHash(
|
|
274
|
-
createPublicKey(
|
|
296
|
+
createPublicKey(configuredKey),
|
|
275
297
|
fileHash,
|
|
276
298
|
signatureBase64,
|
|
277
299
|
)
|