@hot-updater/react-native 0.28.0 → 0.29.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/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +156 -7
- package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
- package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +12 -0
- package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
- package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
- package/android/src/newarch/HotUpdaterModule.kt +16 -19
- package/android/src/oldarch/HotUpdaterModule.kt +20 -20
- package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +153 -31
- package/ios/HotUpdater/Internal/CohortService.swift +63 -0
- package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
- package/ios/HotUpdater/Internal/HotUpdater.mm +111 -59
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +28 -0
- package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
- package/lib/commonjs/DefaultResolver.js +3 -5
- package/lib/commonjs/DefaultResolver.js.map +1 -1
- package/lib/commonjs/checkForUpdate.js +2 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +193 -18
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/native.spec.js +361 -4
- package/lib/commonjs/native.spec.js.map +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/module/DefaultResolver.js +3 -5
- package/lib/module/DefaultResolver.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +14 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +187 -14
- package/lib/module/native.js.map +1 -1
- package/lib/module/native.spec.js +361 -4
- package/lib/module/native.spec.js.map +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +14 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +39 -8
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +28 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +4 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +14 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +39 -8
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +28 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +4 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +1 -1
- package/package.json +6 -6
- package/src/DefaultResolver.ts +4 -4
- package/src/checkForUpdate.ts +4 -0
- package/src/index.ts +21 -0
- package/src/native.spec.ts +400 -4
- package/src/native.ts +265 -20
- package/src/specs/NativeHotUpdater.ts +32 -0
- package/src/types.ts +5 -0
- package/src/wrap.tsx +1 -1
- package/lib/typescript/commonjs/native.spec.d.ts +0 -2
- package/lib/typescript/commonjs/native.spec.d.ts.map +0 -1
- package/lib/typescript/module/native.spec.d.ts +0 -2
- package/lib/typescript/module/native.spec.d.ts.map +0 -1
|
@@ -4,6 +4,7 @@ import android.os.StatFs
|
|
|
4
4
|
import android.util.Log
|
|
5
5
|
import kotlinx.coroutines.Dispatchers
|
|
6
6
|
import kotlinx.coroutines.withContext
|
|
7
|
+
import org.json.JSONObject
|
|
7
8
|
import java.io.File
|
|
8
9
|
import java.net.URL
|
|
9
10
|
|
|
@@ -80,6 +81,18 @@ interface BundleStorageService {
|
|
|
80
81
|
*/
|
|
81
82
|
fun getBaseURL(): String
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Gets the current active bundle ID from bundle storage.
|
|
86
|
+
* Reads manifest.json first and falls back to older metadata when needed.
|
|
87
|
+
*/
|
|
88
|
+
fun getBundleId(): String?
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Gets the current manifest from bundle storage.
|
|
92
|
+
* Returns an empty map when manifest.json is missing or invalid.
|
|
93
|
+
*/
|
|
94
|
+
fun getManifest(): Map<String, Any?>
|
|
95
|
+
|
|
83
96
|
/**
|
|
84
97
|
* Restores the original bundle and clears downloaded bundle state.
|
|
85
98
|
* @return true if the reset was successful
|
|
@@ -102,6 +115,12 @@ class BundleFileStorageService(
|
|
|
102
115
|
private const val TAG = "BundleStorage"
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
private data class ActiveBundleMetadataSnapshot(
|
|
119
|
+
val activeBundleId: String,
|
|
120
|
+
val bundleId: String?,
|
|
121
|
+
val manifest: Map<String, Any?>,
|
|
122
|
+
)
|
|
123
|
+
|
|
105
124
|
init {
|
|
106
125
|
// Ensure bundle store directory exists
|
|
107
126
|
getBundleStoreDir().mkdirs()
|
|
@@ -112,6 +131,10 @@ class BundleFileStorageService(
|
|
|
112
131
|
|
|
113
132
|
private var currentLaunchReport: LaunchReport? = null
|
|
114
133
|
|
|
134
|
+
@Volatile
|
|
135
|
+
private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
|
|
136
|
+
private val activeBundleMetadataLock = Any()
|
|
137
|
+
|
|
115
138
|
// MARK: - Bundle Store Directory
|
|
116
139
|
|
|
117
140
|
private fun getBundleStoreDir(): File {
|
|
@@ -182,6 +205,121 @@ class BundleFileStorageService(
|
|
|
182
205
|
else -> null
|
|
183
206
|
}
|
|
184
207
|
|
|
208
|
+
private fun getActiveBundleId(): String? {
|
|
209
|
+
val metadata = loadMetadataOrNull()
|
|
210
|
+
return when {
|
|
211
|
+
metadata?.stagingBundleId != null -> metadata.stagingBundleId
|
|
212
|
+
metadata?.stableBundleId != null -> metadata.stableBundleId
|
|
213
|
+
else -> extractBundleIdFromCurrentURL()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun getActiveBundleMetadataSnapshot(): ActiveBundleMetadataSnapshot? {
|
|
218
|
+
val activeBundleId =
|
|
219
|
+
getActiveBundleId() ?: run {
|
|
220
|
+
clearActiveBundleMetadataSnapshot()
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
activeBundleMetadataSnapshot
|
|
225
|
+
?.takeIf { it.activeBundleId == activeBundleId }
|
|
226
|
+
?.let { return it }
|
|
227
|
+
|
|
228
|
+
synchronized(activeBundleMetadataLock) {
|
|
229
|
+
activeBundleMetadataSnapshot
|
|
230
|
+
?.takeIf { it.activeBundleId == activeBundleId }
|
|
231
|
+
?.let { return it }
|
|
232
|
+
|
|
233
|
+
val bundleDir = File(getBundleStoreDir(), activeBundleId)
|
|
234
|
+
if (!bundleDir.exists()) {
|
|
235
|
+
activeBundleMetadataSnapshot = null
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return resolveActiveBundleMetadataSnapshot(bundleDir).also {
|
|
240
|
+
activeBundleMetadataSnapshot = it
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private fun clearActiveBundleMetadataSnapshot() {
|
|
246
|
+
synchronized(activeBundleMetadataLock) {
|
|
247
|
+
activeBundleMetadataSnapshot = null
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun resolveActiveBundleMetadataSnapshot(bundleDir: File): ActiveBundleMetadataSnapshot {
|
|
252
|
+
val manifest = readManifestFromBundleDir(bundleDir) ?: emptyMap()
|
|
253
|
+
val manifestBundleId =
|
|
254
|
+
(manifest["bundleId"] as? String)
|
|
255
|
+
?.trim()
|
|
256
|
+
?.takeIf { it.isNotEmpty() }
|
|
257
|
+
|
|
258
|
+
return ActiveBundleMetadataSnapshot(
|
|
259
|
+
activeBundleId = bundleDir.name,
|
|
260
|
+
bundleId = manifestBundleId ?: readCompatibilityBundleIdFromBundleDir(bundleDir),
|
|
261
|
+
manifest = manifest,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private fun readCompatibilityBundleIdFromBundleDir(bundleDir: File): String? {
|
|
266
|
+
val compatibilityBundleIdFile = File(bundleDir, compatibilityBundleIdFilename())
|
|
267
|
+
if (!compatibilityBundleIdFile.exists()) {
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return try {
|
|
272
|
+
compatibilityBundleIdFile.readText().trim().takeIf { it.isNotEmpty() }
|
|
273
|
+
} catch (e: Exception) {
|
|
274
|
+
Log.w(
|
|
275
|
+
TAG,
|
|
276
|
+
"Failed to read compatibility bundle metadata from ${compatibilityBundleIdFile.absolutePath}: ${e.message}",
|
|
277
|
+
)
|
|
278
|
+
null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private fun compatibilityBundleIdFilename(): String = "BUNDLE_ID"
|
|
283
|
+
|
|
284
|
+
private fun readManifestFromBundleDir(bundleDir: File): Map<String, Any?>? {
|
|
285
|
+
val manifestFile = File(bundleDir, "manifest.json")
|
|
286
|
+
if (!manifestFile.exists()) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return try {
|
|
291
|
+
JSONObject(manifestFile.readText()).let(::jsonObjectToMap)
|
|
292
|
+
} catch (e: Exception) {
|
|
293
|
+
Log.w(TAG, "Failed to read manifest from ${manifestFile.absolutePath}: ${e.message}")
|
|
294
|
+
null
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any?> {
|
|
299
|
+
val result = linkedMapOf<String, Any?>()
|
|
300
|
+
val keys = jsonObject.keys()
|
|
301
|
+
|
|
302
|
+
while (keys.hasNext()) {
|
|
303
|
+
val key = keys.next()
|
|
304
|
+
result[key] = jsonValueToKotlin(jsonObject.opt(key))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return result
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun jsonArrayToList(jsonArray: org.json.JSONArray): List<Any?> =
|
|
311
|
+
List(jsonArray.length()) { index ->
|
|
312
|
+
jsonValueToKotlin(jsonArray.opt(index))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private fun jsonValueToKotlin(value: Any?): Any? =
|
|
316
|
+
when (value) {
|
|
317
|
+
JSONObject.NULL -> null
|
|
318
|
+
is JSONObject -> jsonObjectToMap(value)
|
|
319
|
+
is org.json.JSONArray -> jsonArrayToList(value)
|
|
320
|
+
else -> value
|
|
321
|
+
}
|
|
322
|
+
|
|
185
323
|
/**
|
|
186
324
|
* Checks if isolationKey has changed and cleans up old bundles if needed.
|
|
187
325
|
* This handles migration when isolationKey format changes.
|
|
@@ -400,6 +538,7 @@ class BundleFileStorageService(
|
|
|
400
538
|
override fun setBundleURL(localPath: String?): Boolean {
|
|
401
539
|
Log.d(TAG, "setBundleURL: $localPath")
|
|
402
540
|
preferences.setItem("HotUpdaterBundleURL", localPath)
|
|
541
|
+
clearActiveBundleMetadataSnapshot()
|
|
403
542
|
return true
|
|
404
543
|
}
|
|
405
544
|
|
|
@@ -779,13 +918,7 @@ class BundleFileStorageService(
|
|
|
779
918
|
*/
|
|
780
919
|
override fun getBaseURL(): String {
|
|
781
920
|
return try {
|
|
782
|
-
val
|
|
783
|
-
val activeBundleId =
|
|
784
|
-
when {
|
|
785
|
-
metadata?.stagingBundleId != null -> metadata.stagingBundleId
|
|
786
|
-
metadata?.stableBundleId != null -> metadata.stableBundleId
|
|
787
|
-
else -> extractBundleIdFromCurrentURL()
|
|
788
|
-
}
|
|
921
|
+
val activeBundleId = getActiveBundleId()
|
|
789
922
|
|
|
790
923
|
if (activeBundleId != null) {
|
|
791
924
|
val bundleDir = File(getBundleStoreDir(), activeBundleId)
|
|
@@ -801,6 +934,22 @@ class BundleFileStorageService(
|
|
|
801
934
|
}
|
|
802
935
|
}
|
|
803
936
|
|
|
937
|
+
override fun getBundleId(): String? =
|
|
938
|
+
try {
|
|
939
|
+
getActiveBundleMetadataSnapshot()?.bundleId
|
|
940
|
+
} catch (e: Exception) {
|
|
941
|
+
Log.e(TAG, "Error getting bundle ID: ${e.message}")
|
|
942
|
+
null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
override fun getManifest(): Map<String, Any?> =
|
|
946
|
+
try {
|
|
947
|
+
getActiveBundleMetadataSnapshot()?.manifest ?: emptyMap()
|
|
948
|
+
} catch (e: Exception) {
|
|
949
|
+
Log.e(TAG, "Error getting manifest: ${e.message}")
|
|
950
|
+
emptyMap()
|
|
951
|
+
}
|
|
952
|
+
|
|
804
953
|
override suspend fun resetChannel(): Boolean =
|
|
805
954
|
withContext(Dispatchers.IO) {
|
|
806
955
|
if (!setBundleURL(null)) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import android.provider.Settings
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
|
|
8
|
+
class CohortService(
|
|
9
|
+
private val context: Context,
|
|
10
|
+
) {
|
|
11
|
+
private val prefs: SharedPreferences =
|
|
12
|
+
context.getSharedPreferences("HotUpdaterCohort", Context.MODE_PRIVATE)
|
|
13
|
+
|
|
14
|
+
companion object {
|
|
15
|
+
// Keep the legacy key so existing custom cohorts continue to work.
|
|
16
|
+
private const val COHORT_KEY = "custom_cohort"
|
|
17
|
+
private const val FALLBACK_IDENTIFIER_KEY = "fallback_identifier"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private fun hashString(value: String): Int {
|
|
21
|
+
var hash = 0
|
|
22
|
+
for (char in value) {
|
|
23
|
+
hash = (hash shl 5) - hash + char.code
|
|
24
|
+
}
|
|
25
|
+
return hash
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private fun defaultNumericCohort(identifier: String): String {
|
|
29
|
+
val hash = hashString(identifier)
|
|
30
|
+
val normalized = ((hash % 1000) + 1000) % 1000
|
|
31
|
+
return (normalized + 1).toString()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private fun fallbackIdentifier(): String {
|
|
35
|
+
val fallback = prefs.getString(FALLBACK_IDENTIFIER_KEY, null)
|
|
36
|
+
if (!fallback.isNullOrEmpty()) {
|
|
37
|
+
return fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
val generated = UUID.randomUUID().toString()
|
|
41
|
+
prefs.edit().putString(FALLBACK_IDENTIFIER_KEY, generated).apply()
|
|
42
|
+
return generated
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fun setCohort(cohort: String) {
|
|
46
|
+
if (cohort.isEmpty()) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
prefs.edit().putString(COHORT_KEY, cohort).apply()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fun getCohort(): String {
|
|
53
|
+
val cohort = prefs.getString(COHORT_KEY, null)
|
|
54
|
+
if (!cohort.isNullOrEmpty()) {
|
|
55
|
+
return cohort
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
val androidId =
|
|
59
|
+
Settings.Secure.getString(
|
|
60
|
+
context.contentResolver,
|
|
61
|
+
Settings.Secure.ANDROID_ID,
|
|
62
|
+
)
|
|
63
|
+
val initialCohort =
|
|
64
|
+
if (!androidId.isNullOrEmpty()) {
|
|
65
|
+
defaultNumericCohort(androidId)
|
|
66
|
+
} else {
|
|
67
|
+
defaultNumericCohort(fallbackIdentifier())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
prefs.edit().putString(COHORT_KEY, initialCohort).apply()
|
|
71
|
+
return initialCohort
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -12,14 +12,14 @@ class DecompressService {
|
|
|
12
12
|
private const val TAG = "DecompressService"
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
private val
|
|
15
|
+
// Strategies with reliable file signatures that can be validated cheaply.
|
|
16
|
+
// TAR.BR is attempted only as the final fallback because Brotli has no reliable magic bytes.
|
|
17
|
+
private val signatureStrategies =
|
|
18
18
|
listOf(
|
|
19
19
|
ZipDecompressionStrategy(),
|
|
20
20
|
TarGzDecompressionStrategy(),
|
|
21
|
-
TarBrDecompressionStrategy(),
|
|
22
21
|
)
|
|
22
|
+
private val tarBrStrategy = TarBrDecompressionStrategy()
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Extracts a compressed file to the destination directory.
|
|
@@ -39,45 +39,51 @@ class DecompressService {
|
|
|
39
39
|
val fileName = file.name
|
|
40
40
|
val fileSize = if (file.exists()) file.length() else 0L
|
|
41
41
|
|
|
42
|
-
// Try each strategy
|
|
43
|
-
for (strategy in
|
|
42
|
+
// Try each signature-based strategy first.
|
|
43
|
+
for (strategy in signatureStrategies) {
|
|
44
44
|
if (strategy.isValid(filePath)) {
|
|
45
45
|
Log.d(TAG, "Using strategy for $fileName")
|
|
46
46
|
return strategy.decompress(filePath, destinationPath, progressCallback)
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
val errorMessage =
|
|
52
|
-
"""
|
|
53
|
-
Failed to decompress file: $fileName ($fileSize bytes)
|
|
50
|
+
Log.d(TAG, "No ZIP/TAR.GZ signature matched for $fileName, trying TAR.BR fallback")
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
- GZIP compressed TAR archives (.tar.gz)
|
|
60
|
-
- Brotli compressed TAR archives (.tar.br)
|
|
61
|
-
|
|
62
|
-
Please verify the file is not corrupted and matches one of the supported formats.
|
|
63
|
-
""".trimIndent()
|
|
52
|
+
if (tarBrStrategy.decompress(filePath, destinationPath, progressCallback)) {
|
|
53
|
+
Log.d(TAG, "Using TAR.BR fallback for $fileName")
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
64
56
|
|
|
57
|
+
val errorMessage = createInvalidArchiveMessage(fileName, fileSize)
|
|
65
58
|
Log.e(TAG, errorMessage)
|
|
66
59
|
return false
|
|
67
60
|
}
|
|
68
61
|
|
|
69
62
|
/**
|
|
70
|
-
* Validates if a file
|
|
63
|
+
* Validates if a file matches one of the signature-based archive formats.
|
|
71
64
|
* @param filePath Path to the file to validate
|
|
72
65
|
* @return true if the file is a valid compressed archive
|
|
73
66
|
*/
|
|
74
67
|
fun isValidZipFile(filePath: String): Boolean {
|
|
75
|
-
for (strategy in
|
|
68
|
+
for (strategy in signatureStrategies) {
|
|
76
69
|
if (strategy.isValid(filePath)) {
|
|
77
70
|
return true
|
|
78
71
|
}
|
|
79
72
|
}
|
|
80
|
-
Log.d(TAG, "No
|
|
73
|
+
Log.d(TAG, "No ZIP/TAR.GZ signature matched for file: $filePath. TAR.BR is handled during extraction fallback.")
|
|
81
74
|
return false
|
|
82
75
|
}
|
|
76
|
+
|
|
77
|
+
private fun createInvalidArchiveMessage(
|
|
78
|
+
fileName: String,
|
|
79
|
+
fileSize: Long,
|
|
80
|
+
): String =
|
|
81
|
+
"""
|
|
82
|
+
The downloaded bundle file is not a valid compressed archive: $fileName ($fileSize bytes)
|
|
83
|
+
|
|
84
|
+
Supported formats:
|
|
85
|
+
- ZIP archives (.zip)
|
|
86
|
+
- GZIP compressed TAR archives (.tar.gz)
|
|
87
|
+
- Brotli compressed TAR archives (.tar.br)
|
|
88
|
+
""".trimIndent()
|
|
83
89
|
}
|
|
@@ -48,7 +48,7 @@ class HotUpdaterException(
|
|
|
48
48
|
fun extractionFormatError(cause: Throwable? = null) =
|
|
49
49
|
HotUpdaterException(
|
|
50
50
|
"EXTRACTION_FORMAT_ERROR",
|
|
51
|
-
"
|
|
51
|
+
"The downloaded bundle file is not a valid compressed archive",
|
|
52
52
|
cause,
|
|
53
53
|
)
|
|
54
54
|
|
|
@@ -389,6 +389,18 @@ class HotUpdaterImpl {
|
|
|
389
389
|
*/
|
|
390
390
|
fun getBaseURL(): String = bundleStorage.getBaseURL()
|
|
391
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Gets the current active bundle ID from bundle storage.
|
|
394
|
+
* Reads manifest.json first and falls back to the legacy BUNDLE_ID file.
|
|
395
|
+
* Built-in bundle fallback is handled in JS.
|
|
396
|
+
*/
|
|
397
|
+
fun getBundleId(): String? = bundleStorage.getBundleId()
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Gets the current manifest from bundle storage.
|
|
401
|
+
*/
|
|
402
|
+
fun getManifest(): Map<String, Any?> = bundleStorage.getManifest()
|
|
403
|
+
|
|
392
404
|
suspend fun resetChannel(): Boolean {
|
|
393
405
|
val success = bundleStorage.resetChannel()
|
|
394
406
|
if (success) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.WritableArray
|
|
4
|
+
import com.facebook.react.bridge.WritableMap
|
|
5
|
+
import com.facebook.react.bridge.WritableNativeArray
|
|
6
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
7
|
+
|
|
8
|
+
internal fun Map<String, Any?>.toWritableNativeMap(): WritableNativeMap {
|
|
9
|
+
val result = WritableNativeMap()
|
|
10
|
+
forEach { (key, value) ->
|
|
11
|
+
result.putReactValue(key, value)
|
|
12
|
+
}
|
|
13
|
+
return result
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
internal fun List<*>.toWritableNativeArray(): WritableNativeArray {
|
|
17
|
+
val result = WritableNativeArray()
|
|
18
|
+
forEach { value ->
|
|
19
|
+
result.pushReactValue(value)
|
|
20
|
+
}
|
|
21
|
+
return result
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private fun WritableMap.putReactValue(
|
|
25
|
+
key: String,
|
|
26
|
+
value: Any?,
|
|
27
|
+
) {
|
|
28
|
+
when (value) {
|
|
29
|
+
null -> putNull(key)
|
|
30
|
+
is Boolean -> putBoolean(key, value)
|
|
31
|
+
is Number -> putDouble(key, value.toDouble())
|
|
32
|
+
is String -> putString(key, value)
|
|
33
|
+
is Map<*, *> -> {
|
|
34
|
+
@Suppress("UNCHECKED_CAST")
|
|
35
|
+
putMap(key, (value as Map<String, Any?>).toWritableNativeMap())
|
|
36
|
+
}
|
|
37
|
+
is List<*> -> putArray(key, value.toWritableNativeArray())
|
|
38
|
+
else -> putString(key, value.toString())
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private fun WritableArray.pushReactValue(value: Any?) {
|
|
43
|
+
when (value) {
|
|
44
|
+
null -> pushNull()
|
|
45
|
+
is Boolean -> pushBoolean(value)
|
|
46
|
+
is Number -> pushDouble(value.toDouble())
|
|
47
|
+
is String -> pushString(value)
|
|
48
|
+
is Map<*, *> -> {
|
|
49
|
+
@Suppress("UNCHECKED_CAST")
|
|
50
|
+
pushMap((value as Map<String, Any?>).toWritableNativeMap())
|
|
51
|
+
}
|
|
52
|
+
is List<*> -> pushArray(value.toWritableNativeArray())
|
|
53
|
+
else -> pushString(value.toString())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -25,15 +25,27 @@ class TarBrDecompressionStrategy : DecompressionStrategy {
|
|
|
25
25
|
return false
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
return try {
|
|
29
|
+
FileInputStream(file).use { fileInputStream ->
|
|
30
|
+
BufferedInputStream(fileInputStream).use { bufferedInputStream ->
|
|
31
|
+
BrotliInputStream(bufferedInputStream).use { brotliInputStream ->
|
|
32
|
+
TarArchiveInputStream(brotliInputStream).use { tarInputStream ->
|
|
33
|
+
val firstEntry = tarInputStream.getNextEntry()
|
|
34
|
+
val isValid = firstEntry != null
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
if (!isValid) {
|
|
37
|
+
Log.d(TAG, "Invalid file: tar archive has no entries")
|
|
38
|
+
}
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
isValid
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (e: Exception) {
|
|
46
|
+
Log.d(TAG, "Invalid file: Brotli/TAR validation failed - ${e.message}")
|
|
47
|
+
false
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
override fun decompress(
|
|
@@ -19,6 +19,7 @@ class HotUpdaterModule internal constructor(
|
|
|
19
19
|
reactContext: ReactApplicationContext,
|
|
20
20
|
) : HotUpdaterSpec(reactContext) {
|
|
21
21
|
private val mReactApplicationContext: ReactApplicationContext = reactContext
|
|
22
|
+
private val cohortService = CohortService(reactContext)
|
|
22
23
|
|
|
23
24
|
// Managed coroutine scope for the module lifecycle
|
|
24
25
|
private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
@@ -145,26 +146,9 @@ class HotUpdaterModule internal constructor(
|
|
|
145
146
|
// No-op
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
override fun notifyAppReady(): WritableNativeMap
|
|
149
|
-
val result = WritableNativeMap()
|
|
150
|
-
val impl = getInstance()
|
|
151
|
-
val statusMap = impl.notifyAppReady()
|
|
152
|
-
|
|
153
|
-
result.putString("status", statusMap["status"] as? String ?: "STABLE")
|
|
154
|
-
statusMap["crashedBundleId"]?.let {
|
|
155
|
-
result.putString("crashedBundleId", it as String)
|
|
156
|
-
}
|
|
149
|
+
override fun notifyAppReady(): WritableNativeMap = getInstance().notifyAppReady().toWritableNativeMap()
|
|
157
150
|
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
override fun getCrashHistory(): WritableNativeArray {
|
|
162
|
-
val impl = getInstance()
|
|
163
|
-
val crashHistory = impl.getCrashHistory()
|
|
164
|
-
val result = WritableNativeArray()
|
|
165
|
-
crashHistory.forEach { result.pushString(it) }
|
|
166
|
-
return result
|
|
167
|
-
}
|
|
151
|
+
override fun getCrashHistory(): WritableNativeArray = getInstance().getCrashHistory().toWritableNativeArray()
|
|
168
152
|
|
|
169
153
|
override fun clearCrashHistory(): Boolean {
|
|
170
154
|
val impl = getInstance()
|
|
@@ -176,6 +160,19 @@ class HotUpdaterModule internal constructor(
|
|
|
176
160
|
return impl.getBaseURL()
|
|
177
161
|
}
|
|
178
162
|
|
|
163
|
+
override fun setCohort(cohort: String) {
|
|
164
|
+
cohortService.setCohort(cohort)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
override fun getCohort(): String = cohortService.getCohort()
|
|
168
|
+
|
|
169
|
+
override fun getBundleId(): String? {
|
|
170
|
+
val impl = getInstance()
|
|
171
|
+
return impl.getBundleId()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override fun getManifest(): WritableNativeMap = getInstance().getManifest().toWritableNativeMap()
|
|
175
|
+
|
|
179
176
|
override fun resetChannel(promise: Promise) {
|
|
180
177
|
moduleScope.launch {
|
|
181
178
|
try {
|
|
@@ -7,6 +7,7 @@ import com.facebook.react.bridge.Promise
|
|
|
7
7
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
8
|
import com.facebook.react.bridge.ReactMethod
|
|
9
9
|
import com.facebook.react.bridge.ReadableMap
|
|
10
|
+
import com.facebook.react.bridge.WritableNativeArray
|
|
10
11
|
import com.facebook.react.bridge.WritableNativeMap
|
|
11
12
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
12
13
|
import kotlinx.coroutines.CoroutineScope
|
|
@@ -14,13 +15,12 @@ import kotlinx.coroutines.Dispatchers
|
|
|
14
15
|
import kotlinx.coroutines.SupervisorJob
|
|
15
16
|
import kotlinx.coroutines.cancel
|
|
16
17
|
import kotlinx.coroutines.launch
|
|
17
|
-
import org.json.JSONArray
|
|
18
|
-
import org.json.JSONObject
|
|
19
18
|
|
|
20
19
|
class HotUpdaterModule internal constructor(
|
|
21
20
|
context: ReactApplicationContext,
|
|
22
21
|
) : HotUpdaterSpec(context) {
|
|
23
22
|
private val mReactApplicationContext: ReactApplicationContext = context
|
|
23
|
+
private val cohortService = CohortService(context)
|
|
24
24
|
|
|
25
25
|
// Managed coroutine scope for the module lifecycle
|
|
26
26
|
private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
|
@@ -153,26 +153,10 @@ class HotUpdaterModule internal constructor(
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
156
|
-
override fun notifyAppReady():
|
|
157
|
-
val result = JSONObject()
|
|
158
|
-
|
|
159
|
-
val impl = getInstance()
|
|
160
|
-
val statusMap = impl.notifyAppReady()
|
|
161
|
-
|
|
162
|
-
result.put("status", statusMap["status"] as? String ?: "STABLE")
|
|
163
|
-
statusMap["crashedBundleId"]?.let {
|
|
164
|
-
result.put("crashedBundleId", it as String)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return result.toString()
|
|
168
|
-
}
|
|
156
|
+
override fun notifyAppReady(): WritableNativeMap = getInstance().notifyAppReady().toWritableNativeMap()
|
|
169
157
|
|
|
170
158
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
171
|
-
override fun getCrashHistory():
|
|
172
|
-
val impl = getInstance()
|
|
173
|
-
val crashHistory = impl.getCrashHistory()
|
|
174
|
-
return JSONArray(crashHistory).toString()
|
|
175
|
-
}
|
|
159
|
+
override fun getCrashHistory(): WritableNativeArray = getInstance().getCrashHistory().toWritableNativeArray()
|
|
176
160
|
|
|
177
161
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
178
162
|
override fun clearCrashHistory(): Boolean {
|
|
@@ -186,7 +170,23 @@ class HotUpdaterModule internal constructor(
|
|
|
186
170
|
return impl.getBaseURL()
|
|
187
171
|
}
|
|
188
172
|
|
|
173
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
174
|
+
override fun getBundleId(): String? {
|
|
175
|
+
val impl = getInstance()
|
|
176
|
+
return impl.getBundleId()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
180
|
+
override fun getManifest(): WritableNativeMap = getInstance().getManifest().toWritableNativeMap()
|
|
181
|
+
|
|
189
182
|
@ReactMethod
|
|
183
|
+
override fun setCohort(cohort: String) {
|
|
184
|
+
cohortService.setCohort(cohort)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
188
|
+
override fun getCohort(): String = cohortService.getCohort()
|
|
189
|
+
|
|
190
190
|
override fun resetChannel(promise: Promise) {
|
|
191
191
|
moduleScope.launch {
|
|
192
192
|
try {
|
|
@@ -4,6 +4,8 @@ import com.facebook.react.bridge.Promise
|
|
|
4
4
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
5
5
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
6
6
|
import com.facebook.react.bridge.ReadableMap
|
|
7
|
+
import com.facebook.react.bridge.WritableArray
|
|
8
|
+
import com.facebook.react.bridge.WritableMap
|
|
7
9
|
|
|
8
10
|
abstract class HotUpdaterSpec internal constructor(
|
|
9
11
|
context: ReactApplicationContext,
|
|
@@ -17,13 +19,21 @@ abstract class HotUpdaterSpec internal constructor(
|
|
|
17
19
|
|
|
18
20
|
abstract fun reloadProcess(promise: Promise)
|
|
19
21
|
|
|
20
|
-
abstract fun notifyAppReady():
|
|
22
|
+
abstract fun notifyAppReady(): WritableMap
|
|
21
23
|
|
|
22
|
-
abstract fun getCrashHistory():
|
|
24
|
+
abstract fun getCrashHistory(): WritableArray
|
|
23
25
|
|
|
24
26
|
abstract fun clearCrashHistory(): Boolean
|
|
25
27
|
|
|
26
28
|
abstract fun getBaseURL(): String
|
|
27
29
|
|
|
30
|
+
abstract fun setCohort(customId: String)
|
|
31
|
+
|
|
32
|
+
abstract fun getCohort(): String
|
|
33
|
+
|
|
34
|
+
abstract fun getBundleId(): String?
|
|
35
|
+
|
|
36
|
+
abstract fun getManifest(): WritableMap
|
|
37
|
+
|
|
28
38
|
abstract fun resetChannel(promise: Promise)
|
|
29
39
|
}
|