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