@hot-updater/react-native 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HotUpdater.podspec +14 -2
- package/Package.swift +24 -3
- package/android/build.gradle +2 -6
- package/android/hot-updater-fingerprint.gradle +135 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
- package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
- package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
- package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
- package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
- package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
- package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
- package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
- package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
- package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
- package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
- package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
- package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
- package/android/src/newarch/HotUpdater.kt +53 -15
- package/android/src/newarch/HotUpdaterModule.kt +42 -17
- package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
- package/android/src/oldarch/HotUpdater.kt +51 -12
- package/android/src/oldarch/HotUpdaterModule.kt +43 -17
- package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
- package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
- package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
- package/ios/HotUpdater/Internal/CohortService.swift +8 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
- package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
- package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
- package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
- package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
- package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
- package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
- package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
- package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
- package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
- package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
- package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
- package/ios/HotUpdater/Package.swift +11 -0
- package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
- package/ios/HotUpdater/Public/HotUpdater.h +21 -2
- package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
- package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
- package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
- package/lib/commonjs/checkForUpdate.js +3 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +9 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +24 -79
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/nativeUpdateEvents.js +66 -0
- package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +10 -13
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +19 -73
- package/lib/module/native.js.map +1 -1
- package/lib/module/nativeUpdateEvents.js +61 -0
- package/lib/module/nativeUpdateEvents.js.map +1 -0
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -14
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +9 -46
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -14
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +9 -46
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +7 -1
- package/src/index.ts +9 -17
- package/src/native.ts +19 -91
- package/src/nativeUpdateEvents.ts +77 -0
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +20 -3
- package/android/libs/README.md +0 -50
- package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
- package/android/src/bridge/BridgeReactInstance.kt +0 -38
- package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
- package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
- package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
- package/android/src/newarch/ReactIntegrationManager.kt +0 -212
- package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.net.MalformedURLException
|
|
6
|
+
import java.net.URL
|
|
7
|
+
import java.util.Locale
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How native downloads a Bundle, as httpClient.ts resolves it, its assets
|
|
11
|
+
* parsed like the TurboModule's `updateBundle`. In artifact protocol v1,
|
|
12
|
+
* [assets] holds a URL for every file. In v2, it holds only the Hermes patch,
|
|
13
|
+
* and the staging asks [filesUrl] for the URLs of the files it downloads.
|
|
14
|
+
*/
|
|
15
|
+
internal class ResolvedArtifact(
|
|
16
|
+
val manifestUrl: String,
|
|
17
|
+
val manifestFileHash: String,
|
|
18
|
+
val archiveUrl: String?,
|
|
19
|
+
val assets: Map<String, ChangedAssetDescriptor>,
|
|
20
|
+
val filesUrl: String? = null,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The catalog and artifact requests of httpClient.ts, with the catalog's ETag
|
|
25
|
+
* cache of releaseCatalogCache.ts kept in the native release catalog cache.
|
|
26
|
+
*/
|
|
27
|
+
internal class ReleaseCatalogClient(
|
|
28
|
+
private val state: UpdateCheckState,
|
|
29
|
+
private val transport: UpdateCheckTransport,
|
|
30
|
+
) {
|
|
31
|
+
companion object {
|
|
32
|
+
private const val CACHE_FORMAT_VERSION = "1"
|
|
33
|
+
private const val MAX_ETAG_BYTES = 1024
|
|
34
|
+
private const val MAX_CACHE_ENTRY_BYTES = ReleaseCatalogs.MAX_RELEASE_CATALOG_WIRE_BYTES + MAX_ETAG_BYTES + 3
|
|
35
|
+
private const val ARTIFACT_PROTOCOL_VERSION = 1.0
|
|
36
|
+
private const val ARTIFACT_FILES_PROTOCOL_VERSION = 2.0
|
|
37
|
+
private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
|
|
38
|
+
private val HTTP_URL = Regex("^https?://.*", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private class CachedCatalog(
|
|
42
|
+
val catalog: ReleaseCatalog,
|
|
43
|
+
val etag: String,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetches a scope's catalog, or null when the server answers that the scope
|
|
48
|
+
* has none (a 404 with `x-hot-updater-catalog: none`).
|
|
49
|
+
*/
|
|
50
|
+
suspend fun fetchReleaseCatalog(
|
|
51
|
+
baseURL: String,
|
|
52
|
+
url: String,
|
|
53
|
+
expectedScope: ReleaseCatalogScope,
|
|
54
|
+
requestHeaders: Map<String, String>,
|
|
55
|
+
requestTimeoutMillis: Long,
|
|
56
|
+
): ReleaseCatalog? {
|
|
57
|
+
val partition = cachePartition(baseURL, url, requestHeaders)
|
|
58
|
+
val cached = readValidatedCache(partition, expectedScope)
|
|
59
|
+
val response = fetchCatalogResponse(url, requestHeaders, requestTimeoutMillis, cached?.etag)
|
|
60
|
+
|
|
61
|
+
if (response.status == 304) {
|
|
62
|
+
if (cached != null) return cached.catalog
|
|
63
|
+
|
|
64
|
+
val repairResponse = fetchCatalogResponse(url, requestHeaders, requestTimeoutMillis, null)
|
|
65
|
+
return consumeSuccessfulResponse(repairResponse, expectedScope, partition)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return consumeSuccessfulResponse(response, expectedScope, partition)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
suspend fun resolveArtifact(
|
|
72
|
+
baseURL: String,
|
|
73
|
+
currentBundleId: String,
|
|
74
|
+
targetBundleId: String,
|
|
75
|
+
requestHeaders: Map<String, String>,
|
|
76
|
+
requestTimeoutMillis: Long,
|
|
77
|
+
): ResolvedArtifact {
|
|
78
|
+
val headers = LinkedHashMap(effectiveHeaders(requestHeaders))
|
|
79
|
+
if (!headers.containsKey("accept")) headers["accept"] = "application/json"
|
|
80
|
+
val target = encodeURIComponent(targetBundleId)
|
|
81
|
+
val route = "$target/from/${encodeURIComponent(currentBundleId)}"
|
|
82
|
+
val response =
|
|
83
|
+
transport.get(
|
|
84
|
+
UpdateCheckRequest(
|
|
85
|
+
url = "$baseURL/artifacts/v2/$route",
|
|
86
|
+
headers = headers,
|
|
87
|
+
timeoutMillis = requestTimeoutMillis,
|
|
88
|
+
resource = "artifact",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
// Legacy: artifact protocol v1: a server older than v2 has no such
|
|
92
|
+
// route. Remove this fallback once no supported server predates v2.
|
|
93
|
+
if (response.status == 404) {
|
|
94
|
+
return resolveArtifactV1(baseURL, route, headers, requestTimeoutMillis)
|
|
95
|
+
}
|
|
96
|
+
if (response.status != 200) {
|
|
97
|
+
throw UpdateHttpException(response.status, response.statusText)
|
|
98
|
+
}
|
|
99
|
+
return parseArtifactV2(
|
|
100
|
+
response.body ?: throw InvalidUpdateResponseException("Received an empty artifact response"),
|
|
101
|
+
filesUrl = "$baseURL/artifacts/v2/$target/files",
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Legacy: artifact protocol v1: reads a server that predates v2; remove with the fallback.
|
|
106
|
+
private suspend fun resolveArtifactV1(
|
|
107
|
+
baseURL: String,
|
|
108
|
+
route: String,
|
|
109
|
+
headers: Map<String, String>,
|
|
110
|
+
requestTimeoutMillis: Long,
|
|
111
|
+
): ResolvedArtifact {
|
|
112
|
+
val response =
|
|
113
|
+
transport.get(
|
|
114
|
+
UpdateCheckRequest(
|
|
115
|
+
url = "$baseURL/artifacts/v1/$route",
|
|
116
|
+
headers = headers,
|
|
117
|
+
timeoutMillis = requestTimeoutMillis,
|
|
118
|
+
resource = "artifact",
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
if (response.status != 200) {
|
|
122
|
+
throw UpdateHttpException(response.status, response.statusText)
|
|
123
|
+
}
|
|
124
|
+
return parseArtifact(response.body ?: throw InvalidUpdateResponseException("Received an empty artifact response"))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `requireArtifactProtocolV2` of httpClient.ts; the patch becomes its asset's only entry. */
|
|
128
|
+
private fun parseArtifactV2(
|
|
129
|
+
body: String,
|
|
130
|
+
filesUrl: String,
|
|
131
|
+
): ResolvedArtifact {
|
|
132
|
+
// As JSON.parse reads it, which org.json's parser does not.
|
|
133
|
+
val info =
|
|
134
|
+
StrictJson.parse(body) as? JSONObject
|
|
135
|
+
?: throw InvalidUpdateResponseException("Received an invalid artifact response")
|
|
136
|
+
val protocolVersion = info.opt("artifactProtocolVersion")
|
|
137
|
+
val manifestUrl = info.opt("manifestUrl") as? String
|
|
138
|
+
val manifestFileHash = info.opt("manifestFileHash") as? String
|
|
139
|
+
val archiveUrl = info.opt("archiveUrl")
|
|
140
|
+
val patch = info.opt("patch")
|
|
141
|
+
val patchObject = patch as? JSONObject
|
|
142
|
+
if (
|
|
143
|
+
protocolVersion !is Number ||
|
|
144
|
+
protocolVersion.toDouble() != ARTIFACT_FILES_PROTOCOL_VERSION ||
|
|
145
|
+
manifestUrl.isNullOrEmpty() ||
|
|
146
|
+
manifestFileHash.isNullOrEmpty() ||
|
|
147
|
+
(archiveUrl != null && archiveUrl !is String) ||
|
|
148
|
+
(
|
|
149
|
+
patch != null &&
|
|
150
|
+
(
|
|
151
|
+
(patchObject?.opt("assetPath") as? String).isNullOrEmpty() ||
|
|
152
|
+
(patchObject?.opt("patchUrl") as? String).isNullOrEmpty()
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
) {
|
|
156
|
+
throw InvalidUpdateResponseException("Server does not support artifact protocol 2.")
|
|
157
|
+
}
|
|
158
|
+
requireArtifactUrl(manifestUrl)
|
|
159
|
+
val archive = (archiveUrl as? String)?.takeIf { it.isNotEmpty() }
|
|
160
|
+
archive?.let(::requireArtifactUrl)
|
|
161
|
+
val assets = linkedMapOf<String, ChangedAssetDescriptor>()
|
|
162
|
+
if (patchObject != null) {
|
|
163
|
+
// An incomplete patch is skipped, as the TurboModule's asset parsing skips it.
|
|
164
|
+
parsePatch(patchObject)?.let {
|
|
165
|
+
assets[patchObject.getString("assetPath")] = ChangedAssetDescriptor(fileUrl = null, fileHash = "", patch = it)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return ResolvedArtifact(
|
|
169
|
+
manifestUrl = manifestUrl,
|
|
170
|
+
manifestFileHash = manifestFileHash,
|
|
171
|
+
archiveUrl = archive,
|
|
172
|
+
assets = assets,
|
|
173
|
+
filesUrl = filesUrl,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Any stable string per server, route, and headers; it need not match JavaScript's partition. */
|
|
178
|
+
private fun cachePartition(
|
|
179
|
+
baseURL: String,
|
|
180
|
+
url: String,
|
|
181
|
+
requestHeaders: Map<String, String>,
|
|
182
|
+
): String {
|
|
183
|
+
val headers = JSONArray()
|
|
184
|
+
effectiveHeaders(requestHeaders)
|
|
185
|
+
.entries
|
|
186
|
+
.sortedBy { it.key }
|
|
187
|
+
.forEach { (name, value) -> headers.put(JSONArray().put(name).put(value)) }
|
|
188
|
+
return "native:" +
|
|
189
|
+
JSONObject()
|
|
190
|
+
.put("baseURL", baseURL)
|
|
191
|
+
.put("url", url)
|
|
192
|
+
.put("headers", headers)
|
|
193
|
+
.toString()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private suspend fun fetchCatalogResponse(
|
|
197
|
+
url: String,
|
|
198
|
+
requestHeaders: Map<String, String>,
|
|
199
|
+
requestTimeoutMillis: Long,
|
|
200
|
+
etag: String?,
|
|
201
|
+
): UpdateCheckResponse {
|
|
202
|
+
val headers = LinkedHashMap<String, String>()
|
|
203
|
+
effectiveHeaders(requestHeaders).forEach { (name, value) ->
|
|
204
|
+
if (name != "accept" && name != "if-none-match") headers[name] = value
|
|
205
|
+
}
|
|
206
|
+
headers["accept"] = "application/json"
|
|
207
|
+
if (etag != null) headers["if-none-match"] = etag
|
|
208
|
+
return transport.get(UpdateCheckRequest(url, headers, requestTimeoutMillis))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun readValidatedCache(
|
|
212
|
+
partition: String,
|
|
213
|
+
expectedScope: ReleaseCatalogScope,
|
|
214
|
+
): CachedCatalog? {
|
|
215
|
+
val value = state.getReleaseCatalogCache(partition) ?: return null
|
|
216
|
+
val cached = parseCache(value, expectedScope)
|
|
217
|
+
if (cached == null) {
|
|
218
|
+
state.removeReleaseCatalogCache(partition)
|
|
219
|
+
}
|
|
220
|
+
return cached
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun parseCache(
|
|
224
|
+
value: String,
|
|
225
|
+
expectedScope: ReleaseCatalogScope,
|
|
226
|
+
): CachedCatalog? {
|
|
227
|
+
val versionEnd = value.indexOf('\n')
|
|
228
|
+
val etagEnd = value.indexOf('\n', versionEnd + 1)
|
|
229
|
+
if (versionEnd == -1 || etagEnd == -1 || value.substring(0, versionEnd) != CACHE_FORMAT_VERSION) {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
val etag = value.substring(versionEnd + 1, etagEnd)
|
|
233
|
+
if (!isValidETag(etag)) return null
|
|
234
|
+
val catalog = ReleaseCatalogs.parse(value.substring(etagEnd + 1), expectedScope) ?: return null
|
|
235
|
+
return CachedCatalog(catalog, etag)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private fun consumeSuccessfulResponse(
|
|
239
|
+
response: UpdateCheckResponse,
|
|
240
|
+
expectedScope: ReleaseCatalogScope,
|
|
241
|
+
partition: String,
|
|
242
|
+
): ReleaseCatalog? {
|
|
243
|
+
// The server marks the 404 of a scope that has no catalog yet: no update,
|
|
244
|
+
// not a failure. Any other 404 is a wrong baseURL or route.
|
|
245
|
+
if (
|
|
246
|
+
response.status == 404 &&
|
|
247
|
+
response.header("x-hot-updater-catalog")?.trimJsWhitespace()?.lowercase(Locale.ROOT) == "none"
|
|
248
|
+
) {
|
|
249
|
+
return null
|
|
250
|
+
}
|
|
251
|
+
if (response.status != 200) {
|
|
252
|
+
throw UpdateHttpException(response.status, response.statusText)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
val body = response.body
|
|
256
|
+
val catalog = body?.let { ReleaseCatalogs.parse(it, expectedScope) }
|
|
257
|
+
if (body == null || catalog == null) {
|
|
258
|
+
state.removeReleaseCatalogCache(partition)
|
|
259
|
+
throw InvalidUpdateResponseException("Received an invalid Release catalog")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
val etag = response.header("etag")
|
|
263
|
+
if (isValidETag(etag)) {
|
|
264
|
+
val serialized = "$CACHE_FORMAT_VERSION\n$etag\n$body"
|
|
265
|
+
if (ReleaseCatalogs.utf8ByteLength(serialized) > MAX_CACHE_ENTRY_BYTES) {
|
|
266
|
+
state.removeReleaseCatalogCache(partition)
|
|
267
|
+
return catalog
|
|
268
|
+
}
|
|
269
|
+
state.setReleaseCatalogCache(partition, serialized)
|
|
270
|
+
} else {
|
|
271
|
+
state.removeReleaseCatalogCache(partition)
|
|
272
|
+
}
|
|
273
|
+
return catalog
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private fun isValidETag(value: String?): Boolean =
|
|
277
|
+
value != null &&
|
|
278
|
+
value.isNotEmpty() &&
|
|
279
|
+
!value.contains('\r') &&
|
|
280
|
+
!value.contains('\n') &&
|
|
281
|
+
ReleaseCatalogs.utf8ByteLength(value) <= MAX_ETAG_BYTES
|
|
282
|
+
|
|
283
|
+
/** `requireArtifactProtocolV1` and `requireArtifactUrls` of httpClient.ts, then the TurboModule's asset parsing. */
|
|
284
|
+
private fun parseArtifact(body: String): ResolvedArtifact {
|
|
285
|
+
// As JSON.parse reads it, which org.json's parser does not.
|
|
286
|
+
val info =
|
|
287
|
+
StrictJson.parse(body) as? JSONObject
|
|
288
|
+
?: throw InvalidUpdateResponseException("Received an invalid artifact response")
|
|
289
|
+
val protocolVersion = info.opt("artifactProtocolVersion")
|
|
290
|
+
val assets = info.opt("assets") as? JSONObject
|
|
291
|
+
val manifestUrl = info.opt("manifestUrl") as? String
|
|
292
|
+
val manifestFileHash = info.opt("manifestFileHash") as? String
|
|
293
|
+
val archiveUrl = info.opt("archiveUrl")
|
|
294
|
+
if (
|
|
295
|
+
protocolVersion !is Number ||
|
|
296
|
+
protocolVersion.toDouble() != ARTIFACT_PROTOCOL_VERSION ||
|
|
297
|
+
assets == null ||
|
|
298
|
+
manifestUrl.isNullOrEmpty() ||
|
|
299
|
+
manifestFileHash.isNullOrEmpty() ||
|
|
300
|
+
(archiveUrl != null && archiveUrl !is String)
|
|
301
|
+
) {
|
|
302
|
+
throw InvalidUpdateResponseException("Server does not support artifact protocol 1.")
|
|
303
|
+
}
|
|
304
|
+
val assetObjects =
|
|
305
|
+
assets
|
|
306
|
+
.keys()
|
|
307
|
+
.asSequence()
|
|
308
|
+
.map { it to assets.opt(it) }
|
|
309
|
+
.toList()
|
|
310
|
+
for ((_, asset) in assetObjects) {
|
|
311
|
+
val file = (asset as? JSONObject)?.opt("file") as? JSONObject
|
|
312
|
+
val fileUrl = file?.opt("url") as? String
|
|
313
|
+
val fileHash = (asset as? JSONObject)?.opt("fileHash") as? String
|
|
314
|
+
if (fileUrl.isNullOrEmpty() || fileHash.isNullOrEmpty()) {
|
|
315
|
+
throw InvalidUpdateResponseException("Artifact protocol 1 requires an original file for every asset.")
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
requireArtifactUrl(manifestUrl)
|
|
320
|
+
val archive = (archiveUrl as? String)?.takeIf { it.isNotEmpty() }
|
|
321
|
+
archive?.let(::requireArtifactUrl)
|
|
322
|
+
val parsedAssets = linkedMapOf<String, ChangedAssetDescriptor>()
|
|
323
|
+
for ((path, value) in assetObjects) {
|
|
324
|
+
val asset = value as JSONObject
|
|
325
|
+
val file = asset.getJSONObject("file")
|
|
326
|
+
val fileUrl = requireArtifactUrl(file.getString("url"))
|
|
327
|
+
val patch =
|
|
328
|
+
when (val patchValue = asset.opt("patch")) {
|
|
329
|
+
null, JSONObject.NULL, false -> null
|
|
330
|
+
is JSONObject -> parsePatch(patchValue)
|
|
331
|
+
else -> throw InvalidUpdateResponseException("Artifact URLs must be absolute HTTP(S) URLs.")
|
|
332
|
+
}
|
|
333
|
+
parsedAssets[path] =
|
|
334
|
+
ChangedAssetDescriptor(
|
|
335
|
+
fileUrl = fileUrl,
|
|
336
|
+
fileHash = asset.getString("fileHash"),
|
|
337
|
+
fileCompression = file.opt("compression") as? String,
|
|
338
|
+
patch = patch,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
return ResolvedArtifact(
|
|
342
|
+
manifestUrl = manifestUrl,
|
|
343
|
+
manifestFileHash = manifestFileHash,
|
|
344
|
+
archiveUrl = archive,
|
|
345
|
+
assets = parsedAssets,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private fun parsePatch(patch: JSONObject): BsdiffPatchDescriptor? {
|
|
350
|
+
// A patch's URL is checked even when the rest is incomplete.
|
|
351
|
+
requireArtifactUrl(patch.opt("patchUrl") as? String ?: "")
|
|
352
|
+
val algorithm = patch.opt("algorithm") as? String
|
|
353
|
+
val baseBundleId = patch.opt("baseBundleId") as? String
|
|
354
|
+
val baseFileHash = patch.opt("baseFileHash") as? String
|
|
355
|
+
val patchFileHash = patch.opt("patchFileHash") as? String
|
|
356
|
+
val patchUrl = patch.opt("patchUrl") as? String
|
|
357
|
+
if (algorithm == null || baseBundleId == null || baseFileHash == null || patchFileHash == null || patchUrl == null) {
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
return BsdiffPatchDescriptor(
|
|
361
|
+
algorithm = algorithm,
|
|
362
|
+
baseBundleId = baseBundleId,
|
|
363
|
+
baseFileHash = baseFileHash,
|
|
364
|
+
patchFileHash = patchFileHash,
|
|
365
|
+
patchUrl = patchUrl,
|
|
366
|
+
byteSize = readSafeInteger(patch.opt("byteSize")),
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private fun readSafeInteger(value: Any?): Long? {
|
|
371
|
+
if (value !is Number) return null
|
|
372
|
+
val number = value.toDouble()
|
|
373
|
+
if (!number.isFinite() || number % 1.0 != 0.0) return null
|
|
374
|
+
return number.toLong().takeIf { it in 0..MAX_SAFE_INTEGER }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** A URL the device downloads from: the server returns each one absolute. */
|
|
378
|
+
private fun requireArtifactUrl(value: String): String {
|
|
379
|
+
if (!HTTP_URL.matches(value)) {
|
|
380
|
+
throw InvalidUpdateResponseException("Artifact URLs must be absolute HTTP(S) URLs.")
|
|
381
|
+
}
|
|
382
|
+
val url =
|
|
383
|
+
try {
|
|
384
|
+
URL(value)
|
|
385
|
+
} catch (error: MalformedURLException) {
|
|
386
|
+
throw InvalidUpdateResponseException("Invalid artifact URL: $value", error)
|
|
387
|
+
}
|
|
388
|
+
// java.net.URL parses a hostless "https://"; a download needs a host.
|
|
389
|
+
if (url.host.isNullOrEmpty()) {
|
|
390
|
+
throw InvalidUpdateResponseException("Invalid artifact URL: $value")
|
|
391
|
+
}
|
|
392
|
+
return value
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The headers a request sends: names are case-insensitive and lowercased, and
|
|
398
|
+
* the last value of a name wins, as OkHttp's `header` replaces it. Request
|
|
399
|
+
* identities use these, so two maps that send the same headers share one.
|
|
400
|
+
*/
|
|
401
|
+
internal fun effectiveHeaders(headers: Map<String, String>): Map<String, String> {
|
|
402
|
+
val effective = LinkedHashMap<String, String>()
|
|
403
|
+
headers.forEach { (name, value) -> effective[name.lowercase(Locale.ROOT)] = value }
|
|
404
|
+
return effective
|
|
405
|
+
}
|