@hot-updater/react-native 1.0.7 → 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/ReactIntegrationManagerBase.kt +0 -34
- package/android/src/newarch/ReactIntegrationManager.kt +0 -185
- 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
|
+
}
|