@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,111 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The plugin events of native update checks, queued in the key-value store
|
|
8
|
+
* for JavaScript to take after the next launch (nativeUpdateEvents.ts): a
|
|
9
|
+
* JSON array of `{ name, payload, occurredAt }`, oldest first, keeping the
|
|
10
|
+
* newest [MAX_EVENTS_PER_NAME] of each name. Every occurrence is kept, and
|
|
11
|
+
* [take] hands each event out at most once. Each event records the native
|
|
12
|
+
* [build] and cohort it happened in; [take] drops events of another, such as
|
|
13
|
+
* the app version before an upgrade or a cohort set since, so none is
|
|
14
|
+
* reported against a release or cohort it did not happen in.
|
|
15
|
+
*/
|
|
16
|
+
internal class NativeUpdateEventQueue(
|
|
17
|
+
private val getItem: (String) -> String?,
|
|
18
|
+
private val setItem: (String, String?) -> Unit,
|
|
19
|
+
private val build: () -> String,
|
|
20
|
+
private val clock: () -> Long = System::currentTimeMillis,
|
|
21
|
+
) {
|
|
22
|
+
companion object {
|
|
23
|
+
const val STORAGE_KEY = "hot-updater/native-update-events"
|
|
24
|
+
const val MAX_EVENTS_PER_NAME = 20
|
|
25
|
+
|
|
26
|
+
/** Guards appends and takes across every queue over the same store. */
|
|
27
|
+
private val lock = Any()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What an event is reported under: the native build (its app version
|
|
31
|
+
* and the minimum bundle ID its build generated) and the cohort.
|
|
32
|
+
*/
|
|
33
|
+
fun buildOf(
|
|
34
|
+
appVersion: String?,
|
|
35
|
+
minBundleId: String,
|
|
36
|
+
cohort: String,
|
|
37
|
+
): String = "${appVersion.orEmpty()}|$minBundleId|${ReleaseSelector.normalizeCohortValue(cohort)}"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** [eventBuild] replaces [build] for an event that happened under another cohort. */
|
|
41
|
+
fun add(
|
|
42
|
+
name: String,
|
|
43
|
+
payload: JSONObject,
|
|
44
|
+
eventBuild: String? = null,
|
|
45
|
+
) {
|
|
46
|
+
synchronized(lock) {
|
|
47
|
+
val events = read() ?: JSONArray()
|
|
48
|
+
events.put(
|
|
49
|
+
JSONObject()
|
|
50
|
+
.put("name", name)
|
|
51
|
+
.put("payload", payload)
|
|
52
|
+
.put("occurredAt", clock())
|
|
53
|
+
.put("build", eventBuild ?: build()),
|
|
54
|
+
)
|
|
55
|
+
var sameName = (0 until events.length()).count { nameAt(events, it) == name }
|
|
56
|
+
var index = 0
|
|
57
|
+
while (sameName > MAX_EVENTS_PER_NAME && index < events.length()) {
|
|
58
|
+
if (nameAt(events, index) == name) {
|
|
59
|
+
events.remove(index)
|
|
60
|
+
sameName -= 1
|
|
61
|
+
} else {
|
|
62
|
+
index += 1
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
setItem(STORAGE_KEY, events.toString())
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drops every queued event named [name]. */
|
|
70
|
+
fun remove(name: String) {
|
|
71
|
+
synchronized(lock) {
|
|
72
|
+
val events = read() ?: return
|
|
73
|
+
val kept = JSONArray()
|
|
74
|
+
for (index in 0 until events.length()) {
|
|
75
|
+
if (nameAt(events, index) != name) kept.put(events.get(index))
|
|
76
|
+
}
|
|
77
|
+
setItem(STORAGE_KEY, if (kept.length() == 0) null else kept.toString())
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The queued events as a JSON array, cleared from the store, or null when there are none. */
|
|
82
|
+
fun take(): String? =
|
|
83
|
+
synchronized(lock) {
|
|
84
|
+
if (getItem(STORAGE_KEY) == null) return@synchronized null
|
|
85
|
+
val events = read()
|
|
86
|
+
setItem(STORAGE_KEY, null)
|
|
87
|
+
val current = build()
|
|
88
|
+
val taken = JSONArray()
|
|
89
|
+
if (events != null) {
|
|
90
|
+
for (index in 0 until events.length()) {
|
|
91
|
+
val event = events.optJSONObject(index) ?: continue
|
|
92
|
+
if (event.optString("build") == current) taken.put(event)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
taken.takeIf { it.length() > 0 }?.toString()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun read(): JSONArray? =
|
|
99
|
+
getItem(STORAGE_KEY)?.let { stored ->
|
|
100
|
+
try {
|
|
101
|
+
JSONArray(stored)
|
|
102
|
+
} catch (_: Exception) {
|
|
103
|
+
null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun nameAt(
|
|
108
|
+
events: JSONArray,
|
|
109
|
+
index: Int,
|
|
110
|
+
): String? = events.optJSONObject(index)?.optString("name")
|
|
111
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
4
|
+
import okhttp3.Call
|
|
5
|
+
import okhttp3.Callback
|
|
6
|
+
import okhttp3.OkHttpClient
|
|
7
|
+
import okhttp3.Request
|
|
8
|
+
import okhttp3.Response
|
|
9
|
+
import java.io.IOException
|
|
10
|
+
import java.util.concurrent.TimeUnit
|
|
11
|
+
import kotlin.coroutines.resume
|
|
12
|
+
import kotlin.coroutines.resumeWithException
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The check's requests over OkHttp. The request timeout bounds the whole call,
|
|
16
|
+
* body included, as fetchUpdateResponse.ts does; there is no HTTP cache, so a
|
|
17
|
+
* 304 reaches the catalog cache. Cancelling the caller cancels the call at
|
|
18
|
+
* once, so it ends the wait without holding the update lock until the timeout.
|
|
19
|
+
*/
|
|
20
|
+
internal class OkHttpUpdateCheckTransport(
|
|
21
|
+
private val client: OkHttpClient = OkHttpClient(),
|
|
22
|
+
) : UpdateCheckTransport {
|
|
23
|
+
override suspend fun get(request: UpdateCheckRequest): UpdateCheckResponse {
|
|
24
|
+
val call =
|
|
25
|
+
client
|
|
26
|
+
.newBuilder()
|
|
27
|
+
.callTimeout(request.timeoutMillis, TimeUnit.MILLISECONDS)
|
|
28
|
+
.build()
|
|
29
|
+
.newCall(
|
|
30
|
+
Request
|
|
31
|
+
.Builder()
|
|
32
|
+
.url(request.url)
|
|
33
|
+
.apply { request.headers.forEach { (name, value) -> header(name, value) } }
|
|
34
|
+
.get()
|
|
35
|
+
.build(),
|
|
36
|
+
)
|
|
37
|
+
return suspendCancellableCoroutine { continuation ->
|
|
38
|
+
continuation.invokeOnCancellation { call.cancel() }
|
|
39
|
+
call.enqueue(
|
|
40
|
+
object : Callback {
|
|
41
|
+
override fun onFailure(
|
|
42
|
+
call: Call,
|
|
43
|
+
e: IOException,
|
|
44
|
+
) {
|
|
45
|
+
continuation.resumeWithException(e)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
override fun onResponse(
|
|
49
|
+
call: Call,
|
|
50
|
+
response: Response,
|
|
51
|
+
) {
|
|
52
|
+
val result = runCatching { response.use(::readResponse) }
|
|
53
|
+
result.fold(continuation::resume, continuation::resumeWithException)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private fun readResponse(response: Response): UpdateCheckResponse {
|
|
61
|
+
val body =
|
|
62
|
+
try {
|
|
63
|
+
response.body?.string()
|
|
64
|
+
} catch (error: IOException) {
|
|
65
|
+
// A broken error body must not hide an HTTP error already received;
|
|
66
|
+
// a 200 without its body fails, still reporting the response.
|
|
67
|
+
if (response.code == 200) throw UpdateResponseBodyException(responseOf(response, null), error)
|
|
68
|
+
null
|
|
69
|
+
}
|
|
70
|
+
return responseOf(response, body)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fun responseOf(
|
|
74
|
+
response: Response,
|
|
75
|
+
body: String?,
|
|
76
|
+
) = UpdateCheckResponse(
|
|
77
|
+
status = response.code,
|
|
78
|
+
statusText = response.message,
|
|
79
|
+
headers = response.headers.associate { (name, value) -> name to value },
|
|
80
|
+
body = body,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A response whose body could not be read: the request fails, but [response] arrived. */
|
|
85
|
+
internal class UpdateResponseBodyException(
|
|
86
|
+
val response: UpdateCheckResponse,
|
|
87
|
+
override val cause: IOException,
|
|
88
|
+
) : IOException(cause.message, cause)
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.text.Normalizer
|
|
6
|
+
import kotlin.math.abs
|
|
7
|
+
import kotlin.math.floor
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* The release catalog as a device accepts it, ported from @hot-updater/protocol
|
|
11
|
+
* (releaseCatalog.ts, releaseCatalogScope.ts, releaseCatalogWire.ts, uuid.ts and
|
|
12
|
+
* appVersion.ts). The shared conformance fixture in
|
|
13
|
+
* packages/test-utils/fixtures/release-selection-conformance.json pins every
|
|
14
|
+
* result to the TypeScript one, so change both together.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
internal enum class ReleaseStrategy {
|
|
18
|
+
APP_VERSION,
|
|
19
|
+
FINGERPRINT,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
internal data class ReleaseCatalogDescriptor(
|
|
23
|
+
val releaseId: String,
|
|
24
|
+
val kind: String,
|
|
25
|
+
val bundleId: String?,
|
|
26
|
+
val rolloutCohortCount: Int,
|
|
27
|
+
val targetCohorts: List<String>,
|
|
28
|
+
val shouldForceUpdate: Boolean,
|
|
29
|
+
val message: String?,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
internal data class ReleaseCatalog(
|
|
33
|
+
val schemaVersion: Int,
|
|
34
|
+
val catalogId: String,
|
|
35
|
+
val scopeKey: String,
|
|
36
|
+
val generation: Long,
|
|
37
|
+
val catalogHash: String,
|
|
38
|
+
val fallbackPolicy: String,
|
|
39
|
+
/** Release descriptors ordered newest first. */
|
|
40
|
+
val releases: List<ReleaseCatalogDescriptor>,
|
|
41
|
+
/**
|
|
42
|
+
* Enabled compatible Releases ordered newest first, where a device keeps its
|
|
43
|
+
* active Release or rolls back to the newest Release before it.
|
|
44
|
+
*/
|
|
45
|
+
val rollbackReleases: List<ReleaseCatalogDescriptor>,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
/** A release catalog scope: the one a client asks for, or the one a scope key names. */
|
|
49
|
+
internal sealed class ReleaseCatalogScope {
|
|
50
|
+
abstract val platform: String
|
|
51
|
+
abstract val channelKey: String
|
|
52
|
+
abstract val strategy: ReleaseStrategy
|
|
53
|
+
|
|
54
|
+
data class AppVersion(
|
|
55
|
+
override val platform: String,
|
|
56
|
+
override val channelKey: String,
|
|
57
|
+
) : ReleaseCatalogScope() {
|
|
58
|
+
override val strategy: ReleaseStrategy get() = ReleaseStrategy.APP_VERSION
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
data class Fingerprint(
|
|
62
|
+
override val platform: String,
|
|
63
|
+
override val channelKey: String,
|
|
64
|
+
val fingerprintHash: String,
|
|
65
|
+
) : ReleaseCatalogScope() {
|
|
66
|
+
override val strategy: ReleaseStrategy get() = ReleaseStrategy.FINGERPRINT
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
internal object ReleaseCatalogs {
|
|
71
|
+
const val SCHEMA_VERSION = 1
|
|
72
|
+
const val FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE"
|
|
73
|
+
const val KIND_BUNDLE = "BUNDLE"
|
|
74
|
+
const val MAX_CRASHED_BUNDLES = 10
|
|
75
|
+
const val MAX_TARGET_COHORTS_PER_RELEASE = 100
|
|
76
|
+
const val MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE = 512
|
|
77
|
+
const val MAX_COMPILED_CATALOG_BYTES = 256 * 1024
|
|
78
|
+
|
|
79
|
+
/** The largest release catalog response body a client accepts, in UTF-8 bytes. */
|
|
80
|
+
const val MAX_RELEASE_CATALOG_WIRE_BYTES = MAX_COMPILED_CATALOG_BYTES * 2 + 4 * 1024
|
|
81
|
+
|
|
82
|
+
private const val MAX_SAFE_INTEGER = 9007199254740991.0
|
|
83
|
+
private const val MAX_CATALOG_SEGMENT_LENGTH = 255
|
|
84
|
+
private const val MAX_CHANNEL_CODE_POINTS = 255
|
|
85
|
+
private const val BASE64URL_ALPHABET =
|
|
86
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
87
|
+
|
|
88
|
+
private val UUID_V7_PATTERN =
|
|
89
|
+
Regex("[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
|
90
|
+
private val CATALOG_HASH_PATTERN = Regex("sha256:[0-9a-f]{64}")
|
|
91
|
+
private val CATALOG_SEGMENT_PATTERN = Regex("[A-Za-z0-9._~-]+")
|
|
92
|
+
private val BASE64URL_PATTERN = Regex("[A-Za-z0-9_-]*")
|
|
93
|
+
|
|
94
|
+
// verkit's coerce pattern. JavaScript `\d` is ASCII and `$` is the end of input.
|
|
95
|
+
private val APP_VERSION_COERCE_PATTERN =
|
|
96
|
+
Regex("(^|[^0-9])([0-9]{1,16})(?:\\.([0-9]{1,16}))?(?:\\.([0-9]{1,16}))?(?:\\z|[^0-9])")
|
|
97
|
+
|
|
98
|
+
fun isUuidV7(value: Any?): Boolean = value is String && UUID_V7_PATTERN.matches(value)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The form of an app version that the device and the server compare:
|
|
102
|
+
* `major.minor.patch` of the first version in [appVersion], or null when it
|
|
103
|
+
* holds none.
|
|
104
|
+
*/
|
|
105
|
+
fun canonicalizeAppVersion(appVersion: String): String? {
|
|
106
|
+
val match = APP_VERSION_COERCE_PATTERN.find(appVersion) ?: return null
|
|
107
|
+
val parts =
|
|
108
|
+
listOf(
|
|
109
|
+
match.groupValues[2],
|
|
110
|
+
match.groups[3]?.value ?: "0",
|
|
111
|
+
match.groups[4]?.value ?: "0",
|
|
112
|
+
)
|
|
113
|
+
val numbers = mutableListOf<Long>()
|
|
114
|
+
for (part in parts) {
|
|
115
|
+
if (part.length > 1 && part[0] == '0') return null
|
|
116
|
+
val number = part.toLong()
|
|
117
|
+
if (number > MAX_SAFE_INTEGER.toLong()) return null
|
|
118
|
+
numbers.add(number)
|
|
119
|
+
}
|
|
120
|
+
return numbers.joinToString(".")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The length of [value] in UTF-8 bytes, counting a lone surrogate as 3 bytes. */
|
|
124
|
+
fun utf8ByteLength(value: String): Int {
|
|
125
|
+
var bytes = 0
|
|
126
|
+
var index = 0
|
|
127
|
+
while (index < value.length) {
|
|
128
|
+
val code = value[index].code
|
|
129
|
+
if (code <= 0x7f) {
|
|
130
|
+
bytes += 1
|
|
131
|
+
} else if (code <= 0x7ff) {
|
|
132
|
+
bytes += 2
|
|
133
|
+
} else if (
|
|
134
|
+
code in 0xd800..0xdbff &&
|
|
135
|
+
index + 1 < value.length &&
|
|
136
|
+
value[index + 1].code in 0xdc00..0xdfff
|
|
137
|
+
) {
|
|
138
|
+
bytes += 4
|
|
139
|
+
index += 1
|
|
140
|
+
} else {
|
|
141
|
+
bytes += 3
|
|
142
|
+
}
|
|
143
|
+
index += 1
|
|
144
|
+
}
|
|
145
|
+
return bytes
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fun normalizeChannelName(channel: String): String = Normalizer.normalize(channel.trimJsWhitespace(), Normalizer.Form.NFC)
|
|
149
|
+
|
|
150
|
+
fun assertCanonicalChannelName(channel: String) {
|
|
151
|
+
val normalized = normalizeChannelName(channel)
|
|
152
|
+
require(normalized.isNotEmpty() && normalized == channel) {
|
|
153
|
+
"Channel name must be non-empty, trimmed, and NFC-normalized"
|
|
154
|
+
}
|
|
155
|
+
require(normalized.codePointCount(0, normalized.length) <= MAX_CHANNEL_CODE_POINTS) {
|
|
156
|
+
"Channel name must not exceed 255 characters"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fun encodeChannelKey(channel: String): String {
|
|
161
|
+
assertCanonicalChannelName(channel)
|
|
162
|
+
return base64UrlEncode(encodeUtf8(channel))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fun decodeChannelKey(channelKey: String): String {
|
|
166
|
+
val channel = decodeUtf8(base64UrlDecode(channelKey))
|
|
167
|
+
assertCanonicalChannelName(channel)
|
|
168
|
+
require(encodeChannelKey(channel) == channelKey) { "Channel key is not canonically encoded" }
|
|
169
|
+
return channel
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fun createScopeKey(scope: ReleaseCatalogScope): String {
|
|
173
|
+
decodeChannelKey(scope.channelKey)
|
|
174
|
+
return when (scope) {
|
|
175
|
+
is ReleaseCatalogScope.AppVersion -> {
|
|
176
|
+
"v1:app-version:${scope.platform}:${scope.channelKey}"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
is ReleaseCatalogScope.Fingerprint -> {
|
|
180
|
+
assertCatalogSegment(scope.fingerprintHash, "Fingerprint hash")
|
|
181
|
+
"v1:fingerprint:${scope.platform}:${scope.channelKey}:${scope.fingerprintHash}"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fun parseScopeKey(scopeKey: String): ReleaseCatalogScope {
|
|
187
|
+
val segments = scopeKey.split(":")
|
|
188
|
+
val version = segments[0]
|
|
189
|
+
val strategy = segments.getOrNull(1)
|
|
190
|
+
val platform = segments.getOrNull(2)?.takeIf { it == "ios" || it == "android" }
|
|
191
|
+
val channelKey = segments.getOrNull(3)
|
|
192
|
+
require(version == "v1" && platform != null && channelKey != null) {
|
|
193
|
+
"Invalid release catalog scope key"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
val scope =
|
|
197
|
+
if (strategy == "app-version" && segments.size == 4) {
|
|
198
|
+
ReleaseCatalogScope.AppVersion(platform = platform, channelKey = channelKey)
|
|
199
|
+
} else if (strategy == "fingerprint" && segments.size == 5) {
|
|
200
|
+
ReleaseCatalogScope.Fingerprint(
|
|
201
|
+
platform = platform,
|
|
202
|
+
channelKey = channelKey,
|
|
203
|
+
fingerprintHash = segments[4],
|
|
204
|
+
)
|
|
205
|
+
} else {
|
|
206
|
+
throw IllegalArgumentException("Invalid release catalog scope key")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
require(createScopeKey(scope) == scopeKey) { "Release catalog scope key is not canonical" }
|
|
210
|
+
return scope
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether [scopeKey] names the [expected] scope. */
|
|
214
|
+
fun hasExpectedScope(
|
|
215
|
+
scopeKey: String,
|
|
216
|
+
expected: ReleaseCatalogScope,
|
|
217
|
+
): Boolean =
|
|
218
|
+
try {
|
|
219
|
+
parseScopeKey(scopeKey) == expected
|
|
220
|
+
} catch (_: IllegalArgumentException) {
|
|
221
|
+
false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parses a release catalog response body as a client accepts it: within the
|
|
226
|
+
* wire limit, in the schema's shape, for the expected scope, and within the
|
|
227
|
+
* cohort limits. Any other body is null.
|
|
228
|
+
*/
|
|
229
|
+
fun parse(
|
|
230
|
+
body: String,
|
|
231
|
+
expectedScope: ReleaseCatalogScope,
|
|
232
|
+
): ReleaseCatalog? {
|
|
233
|
+
if (utf8ByteLength(body) > MAX_RELEASE_CATALOG_WIRE_BYTES) return null
|
|
234
|
+
return try {
|
|
235
|
+
// As JSON.parse reads it, which org.json's parser does not.
|
|
236
|
+
val root = StrictJson.parse(body) as? JSONObject ?: return null
|
|
237
|
+
val catalog = parseCatalogObject(root) ?: return null
|
|
238
|
+
if (!hasExpectedScope(catalog.scopeKey, expectedScope)) return null
|
|
239
|
+
val distinctTargetCohorts =
|
|
240
|
+
(catalog.releases + catalog.rollbackReleases)
|
|
241
|
+
.flatMap { it.targetCohorts }
|
|
242
|
+
.toSet()
|
|
243
|
+
if (distinctTargetCohorts.size > MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE) return null
|
|
244
|
+
catalog
|
|
245
|
+
} catch (_: Exception) {
|
|
246
|
+
null
|
|
247
|
+
} catch (_: StackOverflowError) {
|
|
248
|
+
// org.json parses recursively; JSON.parse's RangeError on deep nesting is null too.
|
|
249
|
+
null
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private fun parseCatalogObject(json: JSONObject): ReleaseCatalog? {
|
|
254
|
+
val schemaVersion = safeInteger(json.opt("schemaVersion"))
|
|
255
|
+
val catalogId = json.opt("catalogId") as? String
|
|
256
|
+
val scopeKey = json.opt("scopeKey") as? String
|
|
257
|
+
val generation = safeInteger(json.opt("generation"))
|
|
258
|
+
val catalogHash = json.opt("catalogHash") as? String
|
|
259
|
+
val releases = (json.opt("releases") as? JSONArray)?.let(::parseDescriptors)
|
|
260
|
+
val rollbackReleases = (json.opt("rollbackReleases") as? JSONArray)?.let(::parseDescriptors)
|
|
261
|
+
if (
|
|
262
|
+
schemaVersion != SCHEMA_VERSION.toLong() ||
|
|
263
|
+
catalogId.isNullOrEmpty() ||
|
|
264
|
+
scopeKey == null ||
|
|
265
|
+
generation == null ||
|
|
266
|
+
generation < 1 ||
|
|
267
|
+
catalogHash == null ||
|
|
268
|
+
!CATALOG_HASH_PATTERN.matches(catalogHash) ||
|
|
269
|
+
json.opt("fallbackPolicy") != FALLBACK_POLICY ||
|
|
270
|
+
releases == null ||
|
|
271
|
+
rollbackReleases == null
|
|
272
|
+
) {
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
return ReleaseCatalog(
|
|
276
|
+
schemaVersion = SCHEMA_VERSION,
|
|
277
|
+
catalogId = catalogId,
|
|
278
|
+
scopeKey = scopeKey,
|
|
279
|
+
generation = generation,
|
|
280
|
+
catalogHash = catalogHash,
|
|
281
|
+
fallbackPolicy = FALLBACK_POLICY,
|
|
282
|
+
releases = releases,
|
|
283
|
+
rollbackReleases = rollbackReleases,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private fun parseDescriptors(array: JSONArray): List<ReleaseCatalogDescriptor>? {
|
|
288
|
+
val descriptors = ArrayList<ReleaseCatalogDescriptor>(array.length())
|
|
289
|
+
for (index in 0 until array.length()) {
|
|
290
|
+
descriptors.add(parseDescriptor(array.opt(index)) ?: return null)
|
|
291
|
+
}
|
|
292
|
+
return descriptors
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private fun parseDescriptor(value: Any?): ReleaseCatalogDescriptor? {
|
|
296
|
+
if (value !is JSONObject) return null
|
|
297
|
+
val releaseId = value.opt("releaseId")
|
|
298
|
+
val bundleId = value.opt("bundleId") as? String
|
|
299
|
+
val rolloutCohortCount = safeInteger(value.opt("rolloutCohortCount"))
|
|
300
|
+
val targetCohorts = (value.opt("targetCohorts") as? JSONArray)?.let(::parseStrings)
|
|
301
|
+
val shouldForceUpdate = value.opt("shouldForceUpdate") as? Boolean
|
|
302
|
+
val message = value.opt("message")
|
|
303
|
+
if (
|
|
304
|
+
!isUuidV7(releaseId) ||
|
|
305
|
+
value.opt("kind") != KIND_BUNDLE ||
|
|
306
|
+
bundleId == null ||
|
|
307
|
+
rolloutCohortCount == null ||
|
|
308
|
+
rolloutCohortCount < 0 ||
|
|
309
|
+
rolloutCohortCount > ReleaseSelector.NUMERIC_COHORT_SIZE ||
|
|
310
|
+
targetCohorts == null ||
|
|
311
|
+
targetCohorts.size > MAX_TARGET_COHORTS_PER_RELEASE ||
|
|
312
|
+
shouldForceUpdate == null ||
|
|
313
|
+
(message != JSONObject.NULL && message !is String)
|
|
314
|
+
) {
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
return ReleaseCatalogDescriptor(
|
|
318
|
+
releaseId = releaseId as String,
|
|
319
|
+
kind = KIND_BUNDLE,
|
|
320
|
+
bundleId = bundleId,
|
|
321
|
+
rolloutCohortCount = rolloutCohortCount.toInt(),
|
|
322
|
+
targetCohorts = targetCohorts,
|
|
323
|
+
shouldForceUpdate = shouldForceUpdate,
|
|
324
|
+
message = message as? String,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private fun parseStrings(array: JSONArray): List<String>? {
|
|
329
|
+
val strings = ArrayList<String>(array.length())
|
|
330
|
+
for (index in 0 until array.length()) {
|
|
331
|
+
strings.add(array.opt(index) as? String ?: return null)
|
|
332
|
+
}
|
|
333
|
+
return strings
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The value when `Number.isSafeInteger` holds for it. JavaScript reads every
|
|
338
|
+
* JSON number as a double, so `3.0` and `3e0` are safe integers. org.json
|
|
339
|
+
* returns Integer, Long, Double, BigInteger or BigDecimal depending on the
|
|
340
|
+
* literal and the org.json flavor, so each is read through its double.
|
|
341
|
+
*/
|
|
342
|
+
private fun safeInteger(value: Any?): Long? {
|
|
343
|
+
if (value !is Number) return null
|
|
344
|
+
val number = value.toDouble()
|
|
345
|
+
if (number.isNaN() || number.isInfinite() || number != floor(number) || abs(number) > MAX_SAFE_INTEGER) {
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
return number.toLong()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private fun assertCatalogSegment(
|
|
352
|
+
value: String,
|
|
353
|
+
name: String,
|
|
354
|
+
) {
|
|
355
|
+
require(
|
|
356
|
+
value.isNotEmpty() &&
|
|
357
|
+
value.length <= MAX_CATALOG_SEGMENT_LENGTH &&
|
|
358
|
+
CATALOG_SEGMENT_PATTERN.matches(value),
|
|
359
|
+
) {
|
|
360
|
+
"$name must be 1-$MAX_CATALOG_SEGMENT_LENGTH URL-safe ASCII characters"
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private fun encodeUtf8(value: String): List<Int> {
|
|
365
|
+
val bytes = ArrayList<Int>(value.length)
|
|
366
|
+
var index = 0
|
|
367
|
+
while (index < value.length) {
|
|
368
|
+
val codePoint = value.codePointAt(index)
|
|
369
|
+
if (codePoint <= 0x7f) {
|
|
370
|
+
bytes.add(codePoint)
|
|
371
|
+
} else if (codePoint <= 0x7ff) {
|
|
372
|
+
bytes.add(0xc0 or (codePoint shr 6))
|
|
373
|
+
bytes.add(0x80 or (codePoint and 0x3f))
|
|
374
|
+
} else if (codePoint <= 0xffff) {
|
|
375
|
+
bytes.add(0xe0 or (codePoint shr 12))
|
|
376
|
+
bytes.add(0x80 or ((codePoint shr 6) and 0x3f))
|
|
377
|
+
bytes.add(0x80 or (codePoint and 0x3f))
|
|
378
|
+
} else {
|
|
379
|
+
bytes.add(0xf0 or (codePoint shr 18))
|
|
380
|
+
bytes.add(0x80 or ((codePoint shr 12) and 0x3f))
|
|
381
|
+
bytes.add(0x80 or ((codePoint shr 6) and 0x3f))
|
|
382
|
+
bytes.add(0x80 or (codePoint and 0x3f))
|
|
383
|
+
}
|
|
384
|
+
index += Character.charCount(codePoint)
|
|
385
|
+
}
|
|
386
|
+
return bytes
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private fun decodeUtf8(bytes: List<Int>): String {
|
|
390
|
+
val result = StringBuilder()
|
|
391
|
+
var index = 0
|
|
392
|
+
while (index < bytes.size) {
|
|
393
|
+
val first = bytes[index++]
|
|
394
|
+
var codePoint: Int
|
|
395
|
+
val continuationCount: Int
|
|
396
|
+
if (first <= 0x7f) {
|
|
397
|
+
codePoint = first
|
|
398
|
+
continuationCount = 0
|
|
399
|
+
} else if ((first and 0xe0) == 0xc0) {
|
|
400
|
+
codePoint = first and 0x1f
|
|
401
|
+
continuationCount = 1
|
|
402
|
+
} else if ((first and 0xf0) == 0xe0) {
|
|
403
|
+
codePoint = first and 0x0f
|
|
404
|
+
continuationCount = 2
|
|
405
|
+
} else if ((first and 0xf8) == 0xf0) {
|
|
406
|
+
codePoint = first and 0x07
|
|
407
|
+
continuationCount = 3
|
|
408
|
+
} else {
|
|
409
|
+
throw IllegalArgumentException("Invalid UTF-8 channel key")
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
repeat(continuationCount) {
|
|
413
|
+
val continuation = bytes.getOrNull(index++)
|
|
414
|
+
require(continuation != null && (continuation and 0xc0) == 0x80) { "Invalid UTF-8 channel key" }
|
|
415
|
+
codePoint = (codePoint shl 6) or (continuation and 0x3f)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
require(
|
|
419
|
+
codePoint <= 0x10ffff &&
|
|
420
|
+
codePoint !in 0xd800..0xdfff &&
|
|
421
|
+
!(continuationCount == 1 && codePoint < 0x80) &&
|
|
422
|
+
!(continuationCount == 2 && codePoint < 0x800) &&
|
|
423
|
+
!(continuationCount == 3 && codePoint < 0x10000),
|
|
424
|
+
) {
|
|
425
|
+
"Invalid UTF-8 channel key"
|
|
426
|
+
}
|
|
427
|
+
result.appendCodePoint(codePoint)
|
|
428
|
+
}
|
|
429
|
+
return result.toString()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private fun base64UrlEncode(bytes: List<Int>): String {
|
|
433
|
+
val result = StringBuilder()
|
|
434
|
+
var index = 0
|
|
435
|
+
while (index < bytes.size) {
|
|
436
|
+
val first = bytes[index]
|
|
437
|
+
val second = bytes.getOrNull(index + 1)
|
|
438
|
+
val third = bytes.getOrNull(index + 2)
|
|
439
|
+
val value = (first shl 16) or ((second ?: 0) shl 8) or (third ?: 0)
|
|
440
|
+
|
|
441
|
+
result.append(BASE64URL_ALPHABET[(value shr 18) and 0x3f])
|
|
442
|
+
result.append(BASE64URL_ALPHABET[(value shr 12) and 0x3f])
|
|
443
|
+
if (second != null) result.append(BASE64URL_ALPHABET[(value shr 6) and 0x3f])
|
|
444
|
+
if (third != null) result.append(BASE64URL_ALPHABET[value and 0x3f])
|
|
445
|
+
index += 3
|
|
446
|
+
}
|
|
447
|
+
return result.toString()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private fun base64UrlDecode(value: String): List<Int> {
|
|
451
|
+
require(BASE64URL_PATTERN.matches(value) && value.length % 4 != 1) { "Invalid channel key" }
|
|
452
|
+
|
|
453
|
+
val bytes = ArrayList<Int>()
|
|
454
|
+
var index = 0
|
|
455
|
+
while (index < value.length) {
|
|
456
|
+
val chunk = value.substring(index, minOf(index + 4, value.length))
|
|
457
|
+
val encoded = chunk.map { BASE64URL_ALPHABET.indexOf(it) }
|
|
458
|
+
require(encoded.all { it >= 0 }) { "Invalid channel key" }
|
|
459
|
+
|
|
460
|
+
val bits =
|
|
461
|
+
(encoded[0] shl 18) or
|
|
462
|
+
(encoded[1] shl 12) or
|
|
463
|
+
((encoded.getOrNull(2) ?: 0) shl 6) or
|
|
464
|
+
(encoded.getOrNull(3) ?: 0)
|
|
465
|
+
bytes.add((bits shr 16) and 0xff)
|
|
466
|
+
if (chunk.length >= 3) bytes.add((bits shr 8) and 0xff)
|
|
467
|
+
if (chunk.length == 4) bytes.add(bits and 0xff)
|
|
468
|
+
index += 4
|
|
469
|
+
}
|
|
470
|
+
return bytes
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Whether JavaScript's `String.prototype.trim` removes [char]. */
|
|
475
|
+
internal fun isJsWhitespace(char: Char): Boolean {
|
|
476
|
+
val code = char.code
|
|
477
|
+
return code in 0x09..0x0d ||
|
|
478
|
+
code == 0x20 ||
|
|
479
|
+
code == 0xa0 ||
|
|
480
|
+
code == 0x1680 ||
|
|
481
|
+
code in 0x2000..0x200a ||
|
|
482
|
+
code == 0x2028 ||
|
|
483
|
+
code == 0x2029 ||
|
|
484
|
+
code == 0x202f ||
|
|
485
|
+
code == 0x205f ||
|
|
486
|
+
code == 0x3000 ||
|
|
487
|
+
code == 0xfeff
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** JavaScript's `String.prototype.trim`, whose whitespace differs from Kotlin's `trim`. */
|
|
491
|
+
internal fun String.trimJsWhitespace(): String = trim(::isJsWhitespace)
|