@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.
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 +492 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/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,1007 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import kotlinx.coroutines.CancellationException
5
+ import kotlinx.coroutines.CoroutineScope
6
+ import kotlinx.coroutines.CoroutineStart
7
+ import kotlinx.coroutines.Deferred
8
+ import kotlinx.coroutines.Dispatchers
9
+ import kotlinx.coroutines.SupervisorJob
10
+ import kotlinx.coroutines.async
11
+ import kotlinx.coroutines.sync.Mutex
12
+ import kotlinx.coroutines.sync.withLock
13
+ import org.json.JSONArray
14
+ import org.json.JSONException
15
+ import org.json.JSONObject
16
+ import java.io.IOException
17
+ import java.net.MalformedURLException
18
+ import java.net.URL
19
+ import java.util.concurrent.CopyOnWriteArrayList
20
+
21
+ /*
22
+ * The update check native code runs without JavaScript, ported from
23
+ * checkForUpdate.ts and httpClient.ts in packages/react-native/src. It reads
24
+ * and writes the same native state as the TurboModule, so a JavaScript check
25
+ * and a native one agree, and queues the plugin events JavaScript replays.
26
+ */
27
+
28
+ internal class UpdateCheckRequest(
29
+ val url: String,
30
+ val headers: Map<String, String>,
31
+ val timeoutMillis: Long,
32
+ /** "catalog" or "artifact", as `onHttpResponse` reports the request. */
33
+ val resource: String = "catalog",
34
+ )
35
+
36
+ internal class UpdateCheckResponse(
37
+ val status: Int,
38
+ val statusText: String,
39
+ headers: Map<String, String>,
40
+ /** The body, or null when it could not be read. */
41
+ val body: String?,
42
+ ) {
43
+ private val headers = headers.mapKeys { it.key.lowercase() }
44
+
45
+ fun header(name: String): String? = headers[name.lowercase()]
46
+ }
47
+
48
+ /** GET requests for the check. Throws an IOException when no response arrives. */
49
+ internal interface UpdateCheckTransport {
50
+ suspend fun get(request: UpdateCheckRequest): UpdateCheckResponse
51
+ }
52
+
53
+ internal data class ReleaseSelectionGuard(
54
+ val catalogId: String,
55
+ val scopeKey: String,
56
+ val generation: Long,
57
+ val catalogHash: String,
58
+ val channel: String,
59
+ val selectionContextHash: String,
60
+ )
61
+
62
+ internal class ActiveSelections(
63
+ val activeSelection: PersistedSelection?,
64
+ val stableSelection: PersistedSelection?,
65
+ )
66
+
67
+ /** The arguments of the native `updateBundle`, as the TurboModule parses them. */
68
+ internal data class StageBundleRequest(
69
+ val bundleId: String,
70
+ val channel: String,
71
+ val manifestUrl: String,
72
+ val manifestFileHash: String,
73
+ val assets: Map<String, ChangedAssetDescriptor>,
74
+ val archiveUrl: String?,
75
+ val selection: PersistedSelection,
76
+ /** Artifact protocol v2: where the staging asks for the URLs of the files it downloads. */
77
+ val filesUrl: String? = null,
78
+ /** The check's headers and timeout, which the files request repeats. */
79
+ val requestHeaders: Map<String, String> = emptyMap(),
80
+ val requestTimeoutMillis: Long = HttpArtifactFileResolver.DEFAULT_TIMEOUT_MS,
81
+ /** Reports each files response as `onHttpResponse`, as the other artifact requests are. */
82
+ val onFilesResponse: ((status: Int, body: String?) -> Unit)? = null,
83
+ )
84
+
85
+ /** The native state the check reads and writes, the same the TurboModule exposes. */
86
+ internal interface UpdateCheckState {
87
+ fun getAppVersion(): String?
88
+
89
+ /** The running bundle, or before React Native starts the one its launch would pick; null is the built-in bundle. */
90
+ fun getBundleId(): String?
91
+
92
+ fun getMinBundleId(): String
93
+
94
+ fun getDefaultChannel(): String
95
+
96
+ fun getChannel(): String
97
+
98
+ fun getCohort(): String
99
+
100
+ fun getFingerprintHash(): String?
101
+
102
+ fun getCrashHistory(): List<String>
103
+
104
+ fun getActiveUpdateState(): ActiveSelections
105
+
106
+ fun acceptReleaseCatalog(guard: ReleaseSelectionGuard): Boolean
107
+
108
+ fun isReleaseSelectionCurrent(guard: ReleaseSelectionGuard): Boolean
109
+
110
+ suspend fun commitReleaseSelection(selection: PersistedSelection): Boolean
111
+
112
+ /** Stages a bundle, reporting its download progress from 0 to 1. */
113
+ suspend fun updateBundle(
114
+ request: StageBundleRequest,
115
+ onProgress: (Double) -> Unit,
116
+ ): UpdateBundleResult
117
+
118
+ fun getReleaseCatalogCache(partition: String): String?
119
+
120
+ fun setReleaseCatalogCache(
121
+ partition: String,
122
+ payload: String,
123
+ ): Boolean
124
+
125
+ fun removeReleaseCatalogCache(partition: String): Boolean
126
+
127
+ fun getStorageItem(key: String): String?
128
+
129
+ fun setStorageItem(
130
+ key: String,
131
+ value: String?,
132
+ )
133
+
134
+ /** Whether the device has no network, asked only after a failed host lookup. */
135
+ fun isOffline(): Boolean
136
+ }
137
+
138
+ /** A request the server answered with a status the SDK does not accept. */
139
+ internal class UpdateHttpException(
140
+ val status: Int,
141
+ statusText: String,
142
+ ) : Exception("Request failed with HTTP $status${if (statusText.isEmpty()) "" else " $statusText"}")
143
+
144
+ /** A response the SDK cannot use: a malformed or stale catalog, an artifact in another protocol, or an invalid URL. */
145
+ internal class InvalidUpdateResponseException(
146
+ message: String,
147
+ cause: Throwable? = null,
148
+ ) : Exception(message, cause)
149
+
150
+ /** A selection that became stale is a race between checks, not a failure. */
151
+ internal class StaleReleaseCatalogException : IllegalStateException("Release catalog selection became stale before it was committed")
152
+
153
+ /**
154
+ * Fans a staging's progress out to every caller waiting on it: values from 0
155
+ * to 1, increasing, at most one per percent, on the downloading thread.
156
+ */
157
+ internal class ProgressFanout {
158
+ private val listeners = CopyOnWriteArrayList<(Double) -> Unit>()
159
+ private var lastReported = -1.0
160
+
161
+ fun add(listener: (Double) -> Unit) {
162
+ listeners.add(listener)
163
+ }
164
+
165
+ fun remove(listener: (Double) -> Unit) {
166
+ listeners.remove(listener)
167
+ }
168
+
169
+ fun report(progress: Double) {
170
+ val value = if (progress.isNaN()) return else progress.coerceIn(0.0, 1.0)
171
+ synchronized(this) {
172
+ if (value <= lastReported || (value < 1.0 && value - lastReported < 0.01)) return
173
+ lastReported = value
174
+ }
175
+ listeners.forEach { listener ->
176
+ // A caller that stopped waiting while this report ran removed it.
177
+ if (!listeners.contains(listener)) return@forEach
178
+ try {
179
+ listener(value)
180
+ } catch (error: Exception) {
181
+ Log.w("HotUpdaterCheck", "A progress listener threw", error)
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ private val HTTP_URL = Regex("^https?://.+", RegexOption.IGNORE_CASE)
188
+
189
+ /** Throws unless [baseURL] is an absolute HTTP(S) URL with a host, as `HotUpdater.init` requires. */
190
+ internal fun requireValidBaseURL(baseURL: String) {
191
+ val valid =
192
+ HTTP_URL.matches(baseURL) &&
193
+ try {
194
+ // java.net.URL parses a hostless "https:///api"; a request needs a host.
195
+ !URL(baseURL).host.isNullOrEmpty()
196
+ } catch (_: MalformedURLException) {
197
+ false
198
+ }
199
+ require(valid) { "Invalid baseURL: $baseURL" }
200
+ }
201
+
202
+ internal class NativeUpdateChecker(
203
+ private val state: UpdateCheckState,
204
+ private val transport: UpdateCheckTransport,
205
+ private val stagingScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
206
+ ) {
207
+ companion object {
208
+ private const val TAG = "HotUpdaterCheck"
209
+ private const val PLATFORM = "android"
210
+ private const val NIL_UUID = "00000000-0000-0000-0000-000000000000"
211
+
212
+ /** The longest response body queued: the queue lives in the key-value store. */
213
+ private const val HTTP_RESPONSE_BODY_LIMIT = 4_096
214
+ private const val INVALID_COHORT_MESSAGE =
215
+ "Invalid cohort. Use 1-1000 or a lowercase slug without spaces, up to ${ReleaseSelector.MAX_COHORT_LENGTH} characters."
216
+ }
217
+
218
+ private class StagingOutcome(
219
+ val committed: Boolean,
220
+ )
221
+
222
+ private class Staging(
223
+ val update: HotUpdaterUpdate?,
224
+ val result: Deferred<StagingOutcome>?,
225
+ val progress: ProgressFanout,
226
+ )
227
+
228
+ /** Held from a check through the staging it starts, so one runs at a time in this process. */
229
+ private val mutex = Mutex()
230
+ private val inFlightLock = Any()
231
+ private var inFlight: Staging? = null
232
+
233
+ /**
234
+ * The channel the check or staging holding the lock requests, recorded with
235
+ * its HTTP responses: an explicit channel is not the runtime one until the
236
+ * switch commits.
237
+ */
238
+ @Volatile
239
+ private var requestChannel: String? = null
240
+
241
+ /** The cohort the check or staging holding the lock selected for, which its events report. */
242
+ @Volatile
243
+ private var requestCohort: String? = null
244
+
245
+ /** The target the last staging committed, for another retained result of the same target. */
246
+ @Volatile
247
+ private var lastCommittedTargetKey: String? = null
248
+ private val events =
249
+ NativeUpdateEventQueue(state::getStorageItem, state::setStorageItem, build = {
250
+ NativeUpdateEventQueue.buildOf(state.getAppVersion(), state.getMinBundleId(), requestCohort ?: state.getCohort())
251
+ })
252
+
253
+ /** Queues each catalog and artifact response for `onHttpResponse`, as fetchUpdateResponse.ts reports it. */
254
+ private val recordingTransport =
255
+ object : UpdateCheckTransport {
256
+ override suspend fun get(request: UpdateCheckRequest): UpdateCheckResponse =
257
+ try {
258
+ transport.get(request).also { queueHttpResponse(request, it) }
259
+ } catch (error: UpdateResponseBodyException) {
260
+ // The response arrived without its body: reported with a null body, as
261
+ // fetchUpdateResponse.ts does, and the request fails as it did.
262
+ queueHttpResponse(request, error.response)
263
+ throw error.cause
264
+ }
265
+ }
266
+ private val catalogClient = ReleaseCatalogClient(state, recordingTransport)
267
+
268
+ /**
269
+ * Resets the channel once no check or staging runs. A reset deletes staged
270
+ * bundles, so their queued downloads are dropped rather than replayed.
271
+ */
272
+ suspend fun resetChannel(reset: suspend () -> Boolean): Boolean =
273
+ whenIdle {
274
+ reset().also { reset -> if (reset) events.remove("onBundleDownloaded") }
275
+ }
276
+
277
+ /**
278
+ * Runs [block] once no check or staging runs, as a channel reset must: a
279
+ * staging that finished after the reset would save its channel again.
280
+ */
281
+ suspend fun <T> whenIdle(block: suspend () -> T): T =
282
+ mutex.withLock {
283
+ // What runs here, such as a reset, can undo what was staged.
284
+ lastCommittedTargetKey = null
285
+ block()
286
+ }
287
+
288
+ /** Waits for the lock, then checks; cancelling the caller ends the wait and the requests. */
289
+ suspend fun checkForUpdate(options: NativeCheckRequest): HotUpdaterUpdate? = mutex.withLock { check(options) }?.update
290
+
291
+ /**
292
+ * `HotUpdaterUpdate.updateBundle`: joins a staging of the same target,
293
+ * returns at once when this update already staged, or stages once the
294
+ * lock is free.
295
+ */
296
+ private suspend fun updateBundle(
297
+ update: HotUpdaterUpdate,
298
+ selection: SelectedUpdate,
299
+ onProgress: ((Double) -> Unit)?,
300
+ ): Boolean {
301
+ val running = synchronized(inFlightLock) { inFlight?.takeIf { it.update?.targetKey == update.targetKey } }
302
+ val staging =
303
+ running ?: run {
304
+ // A reset or a newer catalog can undo a staging; it counts only while current.
305
+ if (selection.staged && state.isReleaseSelectionCurrent(selection.guard)) return true
306
+ mutex.lock()
307
+ // A staging of the same target may have committed while this call
308
+ // waited, or before it was made; it counts while the selection is current.
309
+ if (selection.staged || lastCommittedTargetKey == update.targetKey) {
310
+ val current =
311
+ try {
312
+ state.isReleaseSelectionCurrent(selection.guard)
313
+ } finally {
314
+ mutex.unlock()
315
+ }
316
+ if (!current) throw StaleReleaseCatalogException()
317
+ selection.staged = true
318
+ return true
319
+ }
320
+ try {
321
+ startStaging(update, selection, onProgress)
322
+ } catch (error: Throwable) {
323
+ mutex.unlock()
324
+ throw error
325
+ }
326
+ }
327
+ val result = staging.result!!
328
+ // A new staging registered the listener before it started.
329
+ if (running != null) onProgress?.let(staging.progress::add)
330
+ try {
331
+ val committed = result.await().committed
332
+ // A joined staging of the same target staged this update too.
333
+ if (committed) selection.staged = true
334
+ return committed
335
+ } finally {
336
+ onProgress?.let(staging.progress::remove)
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Starts staging [update] in the process-wide scope, which takes over the
342
+ * lock the caller holds and releases it when the staging ends. The staging
343
+ * is registered before the caller's lock is handed over, and cleared
344
+ * together with the lock's release.
345
+ */
346
+ private fun startStaging(
347
+ update: HotUpdaterUpdate,
348
+ selection: SelectedUpdate,
349
+ onProgress: ((Double) -> Unit)?,
350
+ ): Staging {
351
+ val progress = ProgressFanout()
352
+ // Before the staging starts, so its first reports reach the caller.
353
+ onProgress?.let(progress::add)
354
+ lateinit var staging: Staging
355
+ val result =
356
+ stagingScope.async(start = CoroutineStart.LAZY) {
357
+ try {
358
+ val committed = install(selection, progress)
359
+ if (committed) {
360
+ selection.staged = true
361
+ lastCommittedTargetKey = update.targetKey
362
+ progress.report(1.0)
363
+ }
364
+ StagingOutcome(committed)
365
+ } catch (error: Exception) {
366
+ Log.w(TAG, "Update failed", error)
367
+ throw error
368
+ } finally {
369
+ synchronized(inFlightLock) {
370
+ if (inFlight === staging) inFlight = null
371
+ mutex.unlock()
372
+ }
373
+ }
374
+ }
375
+ staging = Staging(update, result, progress)
376
+ synchronized(inFlightLock) { inFlight = staging }
377
+ result.start()
378
+ return staging
379
+ }
380
+
381
+ private fun validateOptions(options: NativeCheckRequest) {
382
+ requireValidBaseURL(options.baseURL)
383
+ // OkHttp takes a call timeout of at most Int.MAX_VALUE milliseconds.
384
+ require(options.requestTimeoutMillis in 1..Int.MAX_VALUE.toLong()) {
385
+ "requestTimeoutMillis must be between 1 and ${Int.MAX_VALUE}"
386
+ }
387
+ }
388
+
389
+ private suspend fun check(options: NativeCheckRequest): CheckedUpdate? {
390
+ val currentAppVersion = state.getAppVersion()
391
+ val currentBundleId = resolveBundleId(state.getBundleId())
392
+ val minimumReleaseId = state.getMinBundleId()
393
+ val defaultChannel = state.getDefaultChannel()
394
+ val runtimeChannel = state.getChannel()
395
+ val isSwitched = runtimeChannel != defaultChannel
396
+ val currentChannel = if (isSwitched) runtimeChannel else defaultChannel
397
+ val explicitChannel = options.channel?.takeIf { it.isNotEmpty() }
398
+ val targetChannel = explicitChannel ?: currentChannel
399
+ // A check that cannot start, with an invalid cohort, no app version, or
400
+ // another channel while the runtime channel is switched, throws without
401
+ // onUpdateError: checkForUpdate.ts reports these only to onError.
402
+ val cohort = normalizeAndValidateCohort(state.getCohort())
403
+
404
+ if (currentAppVersion.isNullOrEmpty()) {
405
+ throw IllegalStateException("Failed to get app version")
406
+ }
407
+
408
+ if (isSwitched && explicitChannel != null && explicitChannel != currentChannel) {
409
+ throw IllegalStateException(
410
+ "Runtime channel is already switched to \"$currentChannel\". " +
411
+ "Call hotUpdater.resetChannel() before checking \"$explicitChannel\".",
412
+ )
413
+ }
414
+
415
+ val input =
416
+ CheckInput(
417
+ options = options,
418
+ baseURL = options.baseURL.trimEnd('/'),
419
+ currentAppVersion = currentAppVersion,
420
+ currentBundleId = currentBundleId,
421
+ minimumReleaseId = minimumReleaseId,
422
+ currentChannel = currentChannel,
423
+ targetChannel = targetChannel,
424
+ explicitChannel = explicitChannel,
425
+ cohort = cohort,
426
+ fingerprintHash = state.getFingerprintHash(),
427
+ )
428
+ requestChannel = targetChannel
429
+ requestCohort = cohort
430
+
431
+ val checked =
432
+ try {
433
+ // Invalid options fail the check as a failed request does in
434
+ // JavaScript, so they are reported too.
435
+ validateOptions(options)
436
+ checkReleaseCatalog(input)
437
+ } catch (error: CancellationException) {
438
+ throw error
439
+ } catch (error: Exception) {
440
+ reportUpdateError(
441
+ error,
442
+ classifyUpdateError(error, UpdateFailureStage.CHECK, UpdateFailureResource.CATALOG),
443
+ ErrorScope(
444
+ bundleId = currentBundleId,
445
+ channel = targetChannel,
446
+ updateStrategy = options.updateStrategy,
447
+ ),
448
+ )
449
+ throw error
450
+ }
451
+ if (checked == null) {
452
+ // The app keeps its bundle, as checkForUpdate.ts reports it.
453
+ val releaseId = runningReleaseId(currentBundleId, currentChannel)
454
+ queueUnchanged(currentChannel, currentBundleId, releaseId, previousReleaseId = releaseId)
455
+ }
456
+ return checked
457
+ }
458
+
459
+ private fun queueHttpResponse(
460
+ request: UpdateCheckRequest,
461
+ response: UpdateCheckResponse,
462
+ ) {
463
+ val path =
464
+ try {
465
+ URL(request.url).path.ifEmpty { "/" }
466
+ } catch (_: MalformedURLException) {
467
+ "/"
468
+ }
469
+ val body = response.body
470
+ events.add(
471
+ "onHttpResponse",
472
+ JSONObject().apply {
473
+ put("resource", request.resource)
474
+ requestChannel?.let { put("channel", it) }
475
+ put("path", path)
476
+ put("status", response.status)
477
+ putNullable("body", body?.take(HTTP_RESPONSE_BODY_LIMIT))
478
+ put("bodyTruncated", body != null && body.length > HTTP_RESPONSE_BODY_LIMIT)
479
+ },
480
+ )
481
+ }
482
+
483
+ private fun queueUnchanged(
484
+ channel: String,
485
+ bundleId: String,
486
+ releaseId: String?,
487
+ previousReleaseId: String?,
488
+ ) {
489
+ events.add(
490
+ "onUpdateCheck",
491
+ JSONObject().apply {
492
+ put("status", "UNCHANGED")
493
+ put("channel", channel)
494
+ put("bundleId", bundleId)
495
+ putNullable("releaseId", releaseId)
496
+ putNullable("previousReleaseId", previousReleaseId)
497
+ },
498
+ )
499
+ }
500
+
501
+ private class CheckInput(
502
+ val options: NativeCheckRequest,
503
+ val baseURL: String,
504
+ val currentAppVersion: String,
505
+ val currentBundleId: String,
506
+ val minimumReleaseId: String,
507
+ val currentChannel: String,
508
+ val targetChannel: String,
509
+ val explicitChannel: String?,
510
+ val cohort: String,
511
+ val fingerprintHash: String?,
512
+ )
513
+
514
+ private class ErrorScope(
515
+ val bundleId: String,
516
+ val channel: String,
517
+ val updateStrategy: HotUpdaterUpdateStrategy,
518
+ val targetBundleId: String? = null,
519
+ val targetReleaseId: String? = null,
520
+ )
521
+
522
+ private class CheckedUpdate(
523
+ val update: HotUpdaterUpdate,
524
+ val selection: SelectedUpdate,
525
+ )
526
+
527
+ /** What staging a selected update needs. */
528
+ private class SelectedUpdate(
529
+ val input: CheckInput,
530
+ val desiredBundleId: String,
531
+ val desiredReleaseId: String?,
532
+ val receipt: PersistedSelection,
533
+ val guard: ReleaseSelectionGuard,
534
+ val transitionKind: String,
535
+ val fromReleaseId: String?,
536
+ /** The Release the active selection had before this update. */
537
+ val activeReleaseId: String?,
538
+ val errorScope: ErrorScope,
539
+ ) {
540
+ /** This update already staged or committed; calling `updateBundle` again returns at once. */
541
+ @Volatile
542
+ var staged = false
543
+ }
544
+
545
+ private suspend fun checkReleaseCatalog(input: CheckInput): CheckedUpdate? {
546
+ val options = input.options
547
+ val channelKey = ReleaseCatalogs.encodeChannelKey(input.targetChannel)
548
+ val strategy = options.updateStrategy.releaseStrategy
549
+ val canonicalAppVersion = ReleaseCatalogs.canonicalizeAppVersion(input.currentAppVersion)
550
+ if (strategy == ReleaseStrategy.APP_VERSION && canonicalAppVersion == null) {
551
+ throw IllegalStateException("Failed to canonicalize app version")
552
+ }
553
+ val fingerprintHash = input.fingerprintHash
554
+ if (strategy == ReleaseStrategy.FINGERPRINT && fingerprintHash.isNullOrEmpty()) {
555
+ throw IllegalStateException("Fingerprint hash is required")
556
+ }
557
+ val strategyValue = if (strategy == ReleaseStrategy.APP_VERSION) canonicalAppVersion!! else fingerprintHash!!
558
+ val expectedScope =
559
+ if (strategy == ReleaseStrategy.APP_VERSION) {
560
+ ReleaseCatalogScope.AppVersion(platform = PLATFORM, channelKey = channelKey)
561
+ } else {
562
+ ReleaseCatalogScope.Fingerprint(platform = PLATFORM, channelKey = channelKey, fingerprintHash = strategyValue)
563
+ }
564
+ val strategyPath = if (strategy == ReleaseStrategy.APP_VERSION) "app-version" else "fingerprint"
565
+ val catalog =
566
+ catalogClient.fetchReleaseCatalog(
567
+ baseURL = input.baseURL,
568
+ url =
569
+ "${input.baseURL}/release-catalogs/$strategyPath/$PLATFORM/$channelKey/" +
570
+ encodeURIComponent(strategyValue),
571
+ expectedScope = expectedScope,
572
+ requestHeaders = options.requestHeaders,
573
+ requestTimeoutMillis = options.requestTimeoutMillis,
574
+ )
575
+ // The server says this scope has no catalog yet: no update, not a failure.
576
+ ?: return null
577
+ if (!ReleaseCatalogs.hasExpectedScope(catalog.scopeKey, expectedScope)) {
578
+ throw InvalidUpdateResponseException("Received an invalid Release catalog")
579
+ }
580
+ val catalogId = catalog.catalogId
581
+ val scopeKey = catalog.scopeKey
582
+
583
+ val crashedBundleIds = state.getCrashHistory()
584
+ val activeState = state.getActiveUpdateState()
585
+ val active = activeState.activeSelection
586
+ val explicitScopeSwitch = input.explicitChannel != null && input.explicitChannel != input.currentChannel
587
+ val activeInTargetScope = active?.takeIf { it.catalogId == catalogId && it.scopeKey == scopeKey }
588
+ val hasAuthenticatedActive =
589
+ active != null && active.catalogId != null && active.scopeKey != null && active.generation != null
590
+ if (hasAuthenticatedActive && activeInTargetScope == null && !explicitScopeSwitch) {
591
+ throw IllegalStateException("Release transition rejected: UNSOLICITED_SCOPE")
592
+ }
593
+ val selectorCurrentBundleId =
594
+ if (activeInTargetScope != null || (!hasAuthenticatedActive && !explicitScopeSwitch)) {
595
+ input.currentBundleId
596
+ } else {
597
+ input.minimumReleaseId
598
+ }
599
+ val selectionContextHash =
600
+ ReleaseSelector.createSelectionContextHash(
601
+ ReleaseSelectionContextInput(
602
+ activeBundleId = selectorCurrentBundleId,
603
+ activeReleaseId = activeInTargetScope?.releaseId,
604
+ cohort = input.cohort,
605
+ minimumReleaseId = input.minimumReleaseId,
606
+ strategy = strategy,
607
+ strategyValue = strategyValue,
608
+ crashedBundleIds = crashedBundleIds,
609
+ ),
610
+ )
611
+ val guard =
612
+ ReleaseSelectionGuard(
613
+ catalogId = catalogId,
614
+ scopeKey = scopeKey,
615
+ generation = catalog.generation,
616
+ catalogHash = catalog.catalogHash,
617
+ channel = input.targetChannel,
618
+ selectionContextHash = selectionContextHash,
619
+ )
620
+ if (!state.acceptReleaseCatalog(guard)) {
621
+ throw InvalidUpdateResponseException("Rejected a stale or inconsistent Release catalog")
622
+ }
623
+ val stableInTargetScope =
624
+ activeState.stableSelection?.takeIf { it.catalogId == catalogId && it.scopeKey == scopeKey }
625
+ val desired =
626
+ ReleaseSelector.selectDesiredRelease(
627
+ catalog,
628
+ ReleaseSelectionInput(
629
+ builtInBundleId = input.minimumReleaseId,
630
+ currentBundleId = selectorCurrentBundleId,
631
+ activeReleaseId = activeInTargetScope?.releaseId,
632
+ // A staged update is active but not running yet; force decisions count
633
+ // from the Release the app runs.
634
+ runningReleaseId =
635
+ RunningReleaseId.Explicit(
636
+ listOf(activeInTargetScope, stableInTargetScope)
637
+ .firstOrNull { it?.bundleId == selectorCurrentBundleId }
638
+ ?.releaseId,
639
+ ),
640
+ minimumReleaseId = input.minimumReleaseId,
641
+ cohort = input.cohort,
642
+ crashedBundleIds = crashedBundleIds,
643
+ ),
644
+ ) ?: return null
645
+
646
+ if (
647
+ desired.kind == SelectionKind.BUILTIN &&
648
+ input.currentBundleId == input.minimumReleaseId &&
649
+ (active == null || active.kind == SelectionKind.BUILTIN.name)
650
+ ) {
651
+ return null
652
+ }
653
+
654
+ val receipt =
655
+ PersistedSelection(
656
+ kind = desired.kind.name,
657
+ releaseId = desired.releaseId,
658
+ bundleId = desired.bundleId,
659
+ catalogId = catalogId,
660
+ scopeKey = scopeKey,
661
+ generation = catalog.generation,
662
+ catalogHash = catalog.catalogHash,
663
+ channel = input.targetChannel,
664
+ selectionContextHash = selectionContextHash,
665
+ )
666
+ if (active == receipt) return null
667
+
668
+ val authorization = ReleaseSelector.authorizeReleaseTransition(active, receipt, explicitScopeSwitch)
669
+ if (!authorization.authorized) {
670
+ if (authorization == ReleaseTransitionReason.EMPTY_TARGET_SCOPE) return null
671
+ throw IllegalStateException("Release transition rejected: ${authorization.name}")
672
+ }
673
+
674
+ val release = desired.release
675
+ val transitionKind =
676
+ when {
677
+ desired.kind == SelectionKind.BUILTIN -> "USE_BUILTIN"
678
+ active?.bundleId == desired.bundleId -> "ADOPT_RELEASE"
679
+ else -> "INSTALL"
680
+ }
681
+ val fromReleaseId =
682
+ listOf(active, activeState.stableSelection)
683
+ .firstOrNull { it != null && it.bundleId == input.currentBundleId }
684
+ ?.releaseId
685
+ val selection =
686
+ SelectedUpdate(
687
+ input = input,
688
+ desiredBundleId = desired.bundleId,
689
+ desiredReleaseId = desired.releaseId,
690
+ receipt = receipt,
691
+ guard = guard,
692
+ transitionKind = transitionKind,
693
+ fromReleaseId = fromReleaseId,
694
+ activeReleaseId = active?.releaseId,
695
+ errorScope =
696
+ ErrorScope(
697
+ bundleId = input.currentBundleId,
698
+ channel = input.targetChannel,
699
+ updateStrategy = options.updateStrategy,
700
+ targetBundleId = desired.bundleId,
701
+ targetReleaseId = desired.releaseId,
702
+ ),
703
+ )
704
+
705
+ // Adopting the bundle the app already runs needs no reload; adopting a
706
+ // staged one does when the selector forces it.
707
+ val shouldForceUpdate =
708
+ if (desired.bundleId == input.currentBundleId) false else desired.shouldForceUpdate
709
+ events.add(
710
+ "onUpdateCheck",
711
+ JSONObject().apply {
712
+ put("status", "UPDATE_AVAILABLE")
713
+ put("channel", input.targetChannel)
714
+ put("fromBundleId", input.currentBundleId)
715
+ putNullable("fromReleaseId", fromReleaseId)
716
+ put("toBundleId", desired.bundleId)
717
+ putNullable("toReleaseId", desired.releaseId)
718
+ put("transitionKind", transitionKind)
719
+ put("updateStatus", desired.status.name)
720
+ put("shouldForceUpdate", shouldForceUpdate)
721
+ put("updateStrategy", options.updateStrategy.value)
722
+ },
723
+ )
724
+
725
+ lateinit var update: HotUpdaterUpdate
726
+ update =
727
+ HotUpdaterUpdate(
728
+ id = desired.releaseId ?: desired.bundleId,
729
+ releaseId = desired.releaseId,
730
+ status =
731
+ if (desired.status == DesiredReleaseStatus.ROLLBACK) {
732
+ HotUpdaterUpdateStatus.ROLLBACK
733
+ } else {
734
+ HotUpdaterUpdateStatus.UPDATE
735
+ },
736
+ shouldForceUpdate = shouldForceUpdate,
737
+ message = release?.message,
738
+ rolloutCohortCount = release?.rolloutCohortCount ?: ReleaseSelector.DEFAULT_ROLLOUT_COHORT_COUNT,
739
+ targetCohorts = release?.targetCohorts?.toList() ?: emptyList(),
740
+ transitionKind = transitionKind,
741
+ runsCurrentBundle = desired.bundleId == input.currentBundleId,
742
+ // JSON, so no field can spell another, such as a channel with a colon.
743
+ targetKey =
744
+ JSONArray()
745
+ .put(desired.kind.name)
746
+ .put(desired.bundleId)
747
+ .put(desired.releaseId ?: JSONObject.NULL)
748
+ .put(input.targetChannel)
749
+ .put(guard.catalogId)
750
+ .put(guard.generation)
751
+ .put(guard.catalogHash)
752
+ .put(guard.selectionContextHash)
753
+ .toString(),
754
+ install = { onProgress -> updateBundle(update, selection, onProgress) },
755
+ )
756
+ return CheckedUpdate(update, selection)
757
+ }
758
+
759
+ /**
760
+ * Commits or stages [selection] as `updateBundleForSelection` in
761
+ * checkForUpdate.ts does, queueing `onUpdateError` for a classified failure.
762
+ */
763
+ private suspend fun install(
764
+ selection: SelectedUpdate,
765
+ progress: ProgressFanout,
766
+ ): Boolean {
767
+ try {
768
+ return installSelection(selection, progress)
769
+ } catch (error: CancellationException) {
770
+ throw error
771
+ } catch (error: Exception) {
772
+ val failure =
773
+ if (error is NativeCall.Failure) {
774
+ (error.cause as? HotUpdaterException)?.failure
775
+ } else {
776
+ classifyUpdateError(error, UpdateFailureStage.DOWNLOAD, UpdateFailureResource.ARTIFACT)
777
+ }
778
+ val cause = (error as? NativeCall.Failure)?.cause ?: error
779
+ reportUpdateError(cause, failure, selection.errorScope)
780
+ throw cause
781
+ }
782
+ }
783
+
784
+ private suspend fun installSelection(
785
+ selection: SelectedUpdate,
786
+ progress: ProgressFanout,
787
+ ): Boolean {
788
+ val input = selection.input
789
+ val options = input.options
790
+ requestChannel = input.targetChannel
791
+ requestCohort = input.cohort
792
+ if (!state.isReleaseSelectionCurrent(selection.guard)) {
793
+ throw StaleReleaseCatalogException()
794
+ }
795
+
796
+ if (selection.transitionKind != "INSTALL") {
797
+ val committed = NativeCall.run { state.commitReleaseSelection(selection.receipt) }
798
+ if (committed && selection.transitionKind == "USE_BUILTIN") {
799
+ // The built-in bundle replaced every staged one: their downloads are void.
800
+ events.remove("onBundleDownloaded")
801
+ }
802
+ // Only the running bundle is unchanged: adopting a staged bundle's
803
+ // newer Release leaves it to report itself when it launches.
804
+ if (
805
+ committed &&
806
+ selection.transitionKind == "ADOPT_RELEASE" &&
807
+ selection.desiredBundleId == selection.input.currentBundleId
808
+ ) {
809
+ // The running bundle is now selected as a newer Release.
810
+ queueUnchanged(
811
+ channel = selection.receipt.channel,
812
+ bundleId = selection.receipt.bundleId,
813
+ releaseId = selection.receipt.releaseId,
814
+ previousReleaseId = selection.activeReleaseId,
815
+ )
816
+ }
817
+ return committed
818
+ }
819
+
820
+ val artifact =
821
+ catalogClient.resolveArtifact(
822
+ baseURL = input.baseURL,
823
+ currentBundleId = input.currentBundleId,
824
+ targetBundleId = selection.desiredBundleId,
825
+ requestHeaders = options.requestHeaders,
826
+ requestTimeoutMillis = options.requestTimeoutMillis,
827
+ )
828
+ if (!state.isReleaseSelectionCurrent(selection.guard)) {
829
+ throw StaleReleaseCatalogException()
830
+ }
831
+ val staged =
832
+ NativeCall.run {
833
+ state.updateBundle(
834
+ StageBundleRequest(
835
+ bundleId = selection.desiredBundleId,
836
+ channel = input.targetChannel,
837
+ manifestUrl = artifact.manifestUrl,
838
+ manifestFileHash = artifact.manifestFileHash,
839
+ assets = artifact.assets,
840
+ archiveUrl = artifact.archiveUrl,
841
+ selection = selection.receipt,
842
+ filesUrl = artifact.filesUrl,
843
+ requestHeaders = options.requestHeaders,
844
+ requestTimeoutMillis = options.requestTimeoutMillis,
845
+ onFilesResponse =
846
+ artifact.filesUrl?.let { filesUrl ->
847
+ { status, body ->
848
+ queueHttpResponse(
849
+ UpdateCheckRequest(filesUrl, emptyMap(), options.requestTimeoutMillis, "artifact"),
850
+ UpdateCheckResponse(status, "", emptyMap(), body),
851
+ )
852
+ }
853
+ },
854
+ ),
855
+ progress::report,
856
+ )
857
+ }
858
+ if (selection.desiredBundleId != input.currentBundleId) {
859
+ events.add(
860
+ "onBundleDownloaded",
861
+ JSONObject().apply {
862
+ put("channel", input.targetChannel)
863
+ put("fromBundleId", input.currentBundleId)
864
+ putNullable("fromReleaseId", selection.fromReleaseId)
865
+ put("toBundleId", selection.desiredBundleId)
866
+ putNullable("toReleaseId", selection.desiredReleaseId)
867
+ put("updateStrategy", options.updateStrategy.value)
868
+ put("delivery", staged.delivery.value)
869
+ put("patchFallback", staged.patchFallback)
870
+ },
871
+ )
872
+ }
873
+ return true
874
+ }
875
+
876
+ /**
877
+ * Wraps what the native `updateBundle` and `commitReleaseSelection` throw.
878
+ * JavaScript classifies a native rejection only by the classification
879
+ * native attached, so anything else is not an update failure.
880
+ */
881
+ private object NativeCall {
882
+ class Failure(
883
+ override val cause: Exception,
884
+ ) : Exception(cause)
885
+
886
+ suspend fun <T> run(block: suspend () -> T): T =
887
+ try {
888
+ block()
889
+ } catch (error: CancellationException) {
890
+ throw error
891
+ } catch (error: Exception) {
892
+ throw Failure(error)
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Where and why an update failed, or null when the error is not an update
898
+ * failure, as `classifyUpdateError` in updateError.ts reads an error the
899
+ * SDK raised itself.
900
+ */
901
+ private fun classifyUpdateError(
902
+ error: Exception,
903
+ stage: UpdateFailureStage,
904
+ resource: UpdateFailureResource,
905
+ ): UpdateFailure? =
906
+ when (error) {
907
+ is HotUpdaterException -> {
908
+ error.failure
909
+ }
910
+
911
+ is StaleReleaseCatalogException, is StaleReleaseSelectionException -> {
912
+ null
913
+ }
914
+
915
+ is UpdateHttpException -> {
916
+ UpdateFailure(stage, UpdateFailureReason.HTTP, httpStatus = error.status, resource = resource)
917
+ }
918
+
919
+ is InvalidUpdateResponseException, is JSONException -> {
920
+ UpdateFailure(stage, UpdateFailureReason.INVALID_RESPONSE, resource = resource)
921
+ }
922
+
923
+ is IOException -> {
924
+ UpdateFailure(
925
+ stage,
926
+ UpdateFailureReason.NETWORK,
927
+ resource = resource,
928
+ transport = UpdateFailure.transportOf(error, state::isOffline),
929
+ )
930
+ }
931
+
932
+ else -> {
933
+ UpdateFailure(stage, UpdateFailureReason.UNKNOWN, resource = resource)
934
+ }
935
+ }
936
+
937
+ /** [cohort] tags the event when it happened under another cohort than the running check's. */
938
+ private fun reportUpdateError(
939
+ error: Exception,
940
+ failure: UpdateFailure?,
941
+ scope: ErrorScope,
942
+ cohort: String? = null,
943
+ ) {
944
+ if (failure == null) return
945
+ events.add(
946
+ "onUpdateError",
947
+ JSONObject().apply {
948
+ failure.toUserInfo().forEach { (key, value) -> put(key, value) }
949
+ put("channel", scope.channel)
950
+ put("bundleId", scope.bundleId)
951
+ putNullable("releaseId", runningReleaseId(scope.bundleId, state.getChannel()))
952
+ put("updateStrategy", scope.updateStrategy.value)
953
+ scope.targetBundleId?.let { put("targetBundleId", it) }
954
+ scope.targetReleaseId?.let { put("targetReleaseId", it) }
955
+ put("message", error.message ?: error.javaClass.name)
956
+ },
957
+ cohort?.let { NativeUpdateEventQueue.buildOf(state.getAppVersion(), state.getMinBundleId(), it) },
958
+ )
959
+ }
960
+
961
+ /** The Release the running bundle is selected as on [channel], as `getRunningReleaseId` in appReady.ts. */
962
+ private fun runningReleaseId(
963
+ bundleId: String,
964
+ channel: String,
965
+ ): String? {
966
+ val activeState = state.getActiveUpdateState()
967
+ return listOf(activeState.activeSelection, activeState.stableSelection)
968
+ .firstOrNull { it != null && it.bundleId == bundleId && it.channel == channel }
969
+ ?.releaseId
970
+ }
971
+
972
+ private fun resolveBundleId(bundleId: String?): String =
973
+ if (bundleId.isNullOrEmpty() || bundleId == NIL_UUID) state.getMinBundleId() else bundleId
974
+
975
+ private fun normalizeAndValidateCohort(cohort: String): String {
976
+ val normalized = ReleaseSelector.normalizeCohortValue(cohort)
977
+ val isNumeric = normalized.isNotEmpty() && normalized.all { it in '0'..'9' } && normalized.toIntOrNull() in 1..1000
978
+ require(isNumeric || ReleaseSelector.isCustomCohort(normalized)) { INVALID_COHORT_MESSAGE }
979
+ return normalized
980
+ }
981
+ }
982
+
983
+ internal fun JSONObject.putNullable(
984
+ name: String,
985
+ value: Any?,
986
+ ): JSONObject = put(name, value ?: JSONObject.NULL)
987
+
988
+ /** `encodeURIComponent`: everything but `A-Z a-z 0-9 - _ . ! ~ * ' ( )` is percent-encoded as UTF-8. */
989
+ internal fun encodeURIComponent(value: String): String =
990
+ buildString {
991
+ for (byte in value.toByteArray(Charsets.UTF_8)) {
992
+ val code = byte.toInt() and 0xff
993
+ val char = code.toChar()
994
+ if (
995
+ char in 'A'..'Z' ||
996
+ char in 'a'..'z' ||
997
+ char in '0'..'9' ||
998
+ char in "-_.!~*'()"
999
+ ) {
1000
+ append(char)
1001
+ } else {
1002
+ append('%')
1003
+ append("0123456789ABCDEF"[code shr 4])
1004
+ append("0123456789ABCDEF"[code and 0x0f])
1005
+ }
1006
+ }
1007
+ }