@hot-updater/react-native 1.0.8 → 1.1.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 (145) hide show
  1. package/HotUpdater.podspec +14 -2
  2. package/Package.swift +24 -3
  3. package/android/build.gradle +2 -6
  4. package/android/hot-updater-fingerprint.gradle +135 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
  6. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
  7. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
  9. package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
  10. package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
  12. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
  13. package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
  14. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
  15. package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
  16. package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
  17. package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
  18. package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
  19. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +493 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
  143. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  144. package/android/src/newarch/ReactIntegrationManager.kt +0 -212
  145. 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
- private fun getPublicKeyFromConfig(context: Context): String? {
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 = isSigningEnabled(context)
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(context, bundleFile, signature)
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
- // Get public key from config
227
- val publicKeyPEM =
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(publicKeyPEM)
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 publicKeyPEM =
266
- getPublicKeyFromConfig(context)
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(publicKeyPEM),
296
+ createPublicKey(configuredKey),
275
297
  fileHash,
276
298
  signatureBase64,
277
299
  )