@hot-updater/react-native 0.29.8 → 0.30.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.
@@ -81,6 +81,12 @@ interface BundleStorageService {
81
81
  */
82
82
  fun getBaseURL(): String
83
83
 
84
+ /**
85
+ * Gets the base URL for a specific launched bundle.
86
+ * Returns an empty string for the built-in bundle or when the bundle is unavailable.
87
+ */
88
+ fun getBaseURLForBundle(bundleId: String?): String
89
+
84
90
  /**
85
91
  * Gets the current active bundle ID from bundle storage.
86
92
  * Reads manifest.json first and falls back to older metadata when needed.
@@ -93,6 +99,12 @@ interface BundleStorageService {
93
99
  */
94
100
  fun getManifest(): Map<String, Any?>
95
101
 
102
+ /**
103
+ * Gets the manifest for a specific launched bundle.
104
+ * Returns an empty map for the built-in bundle or when the bundle is unavailable.
105
+ */
106
+ fun getManifestForBundle(bundleId: String?): Map<String, Any?>
107
+
96
108
  /**
97
109
  * Restores the original bundle and clears downloaded bundle state.
98
110
  * @return true if the reset was successful
@@ -239,11 +251,13 @@ class BundleFileStorageService(
239
251
  }
240
252
 
241
253
  private fun getActiveBundleId(): String? {
254
+ extractBundleIdFromCurrentURL()?.let { return it }
255
+
242
256
  val metadata = loadMetadataOrNull()
243
257
  return when {
244
- metadata?.stagingBundleId != null -> metadata.stagingBundleId
258
+ metadata?.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
245
259
  metadata?.stableBundleId != null -> metadata.stableBundleId
246
- else -> extractBundleIdFromCurrentURL()
260
+ else -> null
247
261
  }
248
262
  }
249
263
 
@@ -295,6 +309,19 @@ class BundleFileStorageService(
295
309
  )
296
310
  }
297
311
 
312
+ private fun getBundleMetadataSnapshot(bundleId: String?): ActiveBundleMetadataSnapshot? {
313
+ if (bundleId.isNullOrBlank()) {
314
+ return null
315
+ }
316
+
317
+ val bundleDir = File(getBundleStoreDir(), bundleId)
318
+ if (!bundleDir.exists()) {
319
+ return null
320
+ }
321
+
322
+ return resolveActiveBundleMetadataSnapshot(bundleDir)
323
+ }
324
+
298
325
  private fun readCompatibilityBundleIdFromBundleDir(bundleDir: File): String? {
299
326
  val compatibilityBundleIdFile = File(bundleDir, compatibilityBundleIdFilename())
300
327
  if (!compatibilityBundleIdFile.exists()) {
@@ -967,6 +994,21 @@ class BundleFileStorageService(
967
994
  }
968
995
  }
969
996
 
997
+ override fun getBaseURLForBundle(bundleId: String?): String {
998
+ return try {
999
+ val activeBundleId = bundleId?.takeIf { it.isNotBlank() } ?: return ""
1000
+ val bundleDir = File(getBundleStoreDir(), activeBundleId)
1001
+ if (!bundleDir.exists()) {
1002
+ return ""
1003
+ }
1004
+
1005
+ "file://${bundleDir.absolutePath}"
1006
+ } catch (e: Exception) {
1007
+ Log.e(TAG, "Error getting base URL for bundle $bundleId: ${e.message}")
1008
+ ""
1009
+ }
1010
+ }
1011
+
970
1012
  override fun getBundleId(): String? =
971
1013
  try {
972
1014
  getActiveBundleMetadataSnapshot()?.bundleId
@@ -983,6 +1025,14 @@ class BundleFileStorageService(
983
1025
  emptyMap()
984
1026
  }
985
1027
 
1028
+ override fun getManifestForBundle(bundleId: String?): Map<String, Any?> =
1029
+ try {
1030
+ getBundleMetadataSnapshot(bundleId)?.manifest ?: emptyMap()
1031
+ } catch (e: Exception) {
1032
+ Log.e(TAG, "Error getting manifest for bundle $bundleId: ${e.message}")
1033
+ emptyMap()
1034
+ }
1035
+
986
1036
  override suspend fun resetChannel(): Boolean =
987
1037
  withContext(Dispatchers.IO) {
988
1038
  if (!setBundleURL(null)) {
@@ -38,7 +38,7 @@ class CohortService(
38
38
  }
39
39
 
40
40
  val generated = UUID.randomUUID().toString()
41
- prefs.edit().putString(FALLBACK_IDENTIFIER_KEY, generated).apply()
41
+ prefs.edit().putString(FALLBACK_IDENTIFIER_KEY, generated).commit()
42
42
  return generated
43
43
  }
44
44
 
@@ -46,7 +46,8 @@ class CohortService(
46
46
  if (cohort.isEmpty()) {
47
47
  return
48
48
  }
49
- prefs.edit().putString(COHORT_KEY, cohort).apply()
49
+ // Cohort changes can be followed immediately by a process restart during OTA flows.
50
+ prefs.edit().putString(COHORT_KEY, cohort).commit()
50
51
  }
51
52
 
52
53
  fun getCohort(): String {
@@ -67,7 +68,7 @@ class CohortService(
67
68
  defaultNumericCohort(fallbackIdentifier())
68
69
  }
69
70
 
70
- prefs.edit().putString(COHORT_KEY, initialCohort).apply()
71
+ prefs.edit().putString(COHORT_KEY, initialCohort).commit()
71
72
  return initialCohort
72
73
  }
73
74
  }
@@ -387,19 +387,40 @@ class HotUpdaterImpl {
387
387
  * This is used for Expo DOM components to construct full asset paths.
388
388
  * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123/") or empty string
389
389
  */
390
- fun getBaseURL(): String = bundleStorage.getBaseURL()
390
+ fun getBaseURL(): String {
391
+ val launchSelection = currentLaunchSelection
392
+ if (launchSelection != null) {
393
+ return bundleStorage.getBaseURLForBundle(launchSelection.launchedBundleId)
394
+ }
395
+
396
+ return bundleStorage.getBaseURL()
397
+ }
391
398
 
392
399
  /**
393
400
  * Gets the current active bundle ID from bundle storage.
394
401
  * Reads manifest.json first and falls back to the legacy BUNDLE_ID file.
395
402
  * Built-in bundle fallback is handled in JS.
396
403
  */
397
- fun getBundleId(): String? = bundleStorage.getBundleId()
404
+ fun getBundleId(): String? {
405
+ val launchSelection = currentLaunchSelection
406
+ if (launchSelection != null) {
407
+ return launchSelection.launchedBundleId
408
+ }
409
+
410
+ return bundleStorage.getBundleId()
411
+ }
398
412
 
399
413
  /**
400
414
  * Gets the current manifest from bundle storage.
401
415
  */
402
- fun getManifest(): Map<String, Any?> = bundleStorage.getManifest()
416
+ fun getManifest(): Map<String, Any?> {
417
+ val launchSelection = currentLaunchSelection
418
+ if (launchSelection != null) {
419
+ return bundleStorage.getManifestForBundle(launchSelection.launchedBundleId)
420
+ }
421
+
422
+ return bundleStorage.getManifest()
423
+ }
403
424
 
404
425
  suspend fun resetChannel(): Boolean {
405
426
  val success = bundleStorage.resetChannel()
@@ -182,6 +182,53 @@ class BundleFileStorageServiceTest {
182
182
  assertEquals(stagingDir.name, report["crashedBundleId"])
183
183
  }
184
184
 
185
+ @Test
186
+ fun `getBundleId falls back to built in while staging verification is pending`() {
187
+ val rootDir = temporaryFolder.newFolder("pending-staging-built-in")
188
+ val service = createService(rootDir)
189
+
190
+ val stagingDir = createBundleDir(rootDir, "staging-bundle")
191
+ writeFile(stagingDir, "index.android.bundle")
192
+ writeManifest(stagingDir, listOf("index.android.bundle"))
193
+
194
+ writeMetadata(
195
+ rootDir,
196
+ BundleMetadata(
197
+ isolationKey = TEST_ISOLATION_KEY,
198
+ stableBundleId = null,
199
+ stagingBundleId = stagingDir.name,
200
+ verificationPending = true,
201
+ ),
202
+ )
203
+
204
+ assertNull(service.getBundleId())
205
+ }
206
+
207
+ @Test
208
+ fun `getBundleId returns launched staging bundle while verification is pending`() {
209
+ val rootDir = temporaryFolder.newFolder("pending-staging-active")
210
+ val preferences = InMemoryPreferencesService()
211
+ val service = createService(rootDir, preferences)
212
+
213
+ val stagingDir = createBundleDir(rootDir, "staging-bundle")
214
+ val stagingBundleFile = writeFile(stagingDir, "index.android.bundle")
215
+ writeManifest(stagingDir, listOf("index.android.bundle"))
216
+
217
+ writeMetadata(
218
+ rootDir,
219
+ BundleMetadata(
220
+ isolationKey = TEST_ISOLATION_KEY,
221
+ stableBundleId = null,
222
+ stagingBundleId = stagingDir.name,
223
+ verificationPending = true,
224
+ ),
225
+ )
226
+
227
+ preferences.setItem("HotUpdaterBundleURL", stagingBundleFile.absolutePath)
228
+
229
+ assertEquals(stagingDir.name, service.getBundleId())
230
+ }
231
+
185
232
  private fun createService(
186
233
  rootDir: File,
187
234
  preferences: InMemoryPreferencesService = InMemoryPreferencesService(),
@@ -0,0 +1,208 @@
1
+ package com.hotupdater
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class HotUpdaterImplTest {
9
+ @Test
10
+ fun `getBundleId falls back to bundle storage without a current launch selection`() {
11
+ val impl = createImpl(storageBundleId = "staged-bundle")
12
+
13
+ assertEquals("staged-bundle", impl.getBundleId())
14
+ }
15
+
16
+ @Test
17
+ fun `getBundleId returns current launched bundle over staged metadata`() {
18
+ val impl = createImpl(storageBundleId = "staged-bundle")
19
+
20
+ setCurrentLaunchSelection(
21
+ impl,
22
+ LaunchSelection(
23
+ bundleUrl = "file:///bundle-store/launched-bundle/index.android.bundle",
24
+ launchedBundleId = "launched-bundle",
25
+ shouldRollbackOnCrash = false,
26
+ ),
27
+ )
28
+
29
+ assertEquals("launched-bundle", impl.getBundleId())
30
+ }
31
+
32
+ @Test
33
+ fun `getBundleId returns null for built in launch even when staged metadata exists`() {
34
+ val impl = createImpl(storageBundleId = "staged-bundle")
35
+
36
+ setCurrentLaunchSelection(
37
+ impl,
38
+ LaunchSelection(
39
+ bundleUrl = "assets://index.android.bundle",
40
+ launchedBundleId = null,
41
+ shouldRollbackOnCrash = false,
42
+ ),
43
+ )
44
+
45
+ assertNull(impl.getBundleId())
46
+ }
47
+
48
+ @Test
49
+ fun `getManifest and getBaseURL return current launched bundle over staged metadata`() {
50
+ val impl =
51
+ createImpl(
52
+ storageBundleId = "staged-bundle",
53
+ storageManifest = mapOf("bundleId" to "staged-bundle"),
54
+ storageBaseURL = "file:///bundle-store/staged-bundle",
55
+ launchedBundleManifests =
56
+ mapOf(
57
+ "launched-bundle" to mapOf("bundleId" to "launched-bundle"),
58
+ ),
59
+ launchedBundleBaseURLs =
60
+ mapOf(
61
+ "launched-bundle" to "file:///bundle-store/launched-bundle",
62
+ ),
63
+ )
64
+
65
+ setCurrentLaunchSelection(
66
+ impl,
67
+ LaunchSelection(
68
+ bundleUrl = "file:///bundle-store/launched-bundle/index.android.bundle",
69
+ launchedBundleId = "launched-bundle",
70
+ shouldRollbackOnCrash = false,
71
+ ),
72
+ )
73
+
74
+ assertEquals(
75
+ mapOf("bundleId" to "launched-bundle"),
76
+ impl.getManifest(),
77
+ )
78
+ assertEquals("file:///bundle-store/launched-bundle", impl.getBaseURL())
79
+ }
80
+
81
+ @Test
82
+ fun `getManifest and getBaseURL return built in values for built in launch`() {
83
+ val impl =
84
+ createImpl(
85
+ storageBundleId = "staged-bundle",
86
+ storageManifest = mapOf("bundleId" to "staged-bundle"),
87
+ storageBaseURL = "file:///bundle-store/staged-bundle",
88
+ )
89
+
90
+ setCurrentLaunchSelection(
91
+ impl,
92
+ LaunchSelection(
93
+ bundleUrl = "assets://index.android.bundle",
94
+ launchedBundleId = null,
95
+ shouldRollbackOnCrash = false,
96
+ ),
97
+ )
98
+
99
+ assertTrue(impl.getManifest().isEmpty())
100
+ assertEquals("", impl.getBaseURL())
101
+ }
102
+
103
+ private fun createImpl(
104
+ storageBundleId: String?,
105
+ storageManifest: Map<String, Any?> = emptyMap(),
106
+ storageBaseURL: String = "",
107
+ launchedBundleManifests: Map<String, Map<String, Any?>> = emptyMap(),
108
+ launchedBundleBaseURLs: Map<String, String> = emptyMap(),
109
+ ): HotUpdaterImpl =
110
+ allocateWithoutConstructor<HotUpdaterImpl>().also { impl ->
111
+ setField(
112
+ impl,
113
+ "bundleStorage",
114
+ FakeBundleStorageService(
115
+ bundleId = storageBundleId,
116
+ manifest = storageManifest,
117
+ baseURL = storageBaseURL,
118
+ launchedBundleManifests = launchedBundleManifests,
119
+ launchedBundleBaseURLs = launchedBundleBaseURLs,
120
+ ),
121
+ )
122
+ }
123
+
124
+ private fun setCurrentLaunchSelection(
125
+ impl: HotUpdaterImpl,
126
+ selection: LaunchSelection,
127
+ ) {
128
+ setField(impl, "currentLaunchSelection", selection)
129
+ }
130
+
131
+ private fun setField(
132
+ target: Any,
133
+ fieldName: String,
134
+ value: Any?,
135
+ ) {
136
+ val field = HotUpdaterImpl::class.java.getDeclaredField(fieldName)
137
+ field.isAccessible = true
138
+ field.set(target, value)
139
+ }
140
+
141
+ private inline fun <reified T> allocateWithoutConstructor(): T {
142
+ val field = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe")
143
+ field.isAccessible = true
144
+ val unsafe = field.get(null)
145
+ val allocateInstance = unsafe.javaClass.getMethod("allocateInstance", Class::class.java)
146
+ @Suppress("UNCHECKED_CAST")
147
+ return allocateInstance.invoke(unsafe, T::class.java) as T
148
+ }
149
+
150
+ private class FakeBundleStorageService(
151
+ private val bundleId: String?,
152
+ private val manifest: Map<String, Any?> = emptyMap(),
153
+ private val baseURL: String = "",
154
+ private val launchedBundleManifests: Map<String, Map<String, Any?>> =
155
+ emptyMap(),
156
+ private val launchedBundleBaseURLs: Map<String, String> = emptyMap(),
157
+ ) : BundleStorageService {
158
+ override fun setBundleURL(localPath: String?): Boolean = true
159
+
160
+ override fun getCachedBundleURL(): String? = null
161
+
162
+ override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
163
+
164
+ override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection =
165
+ LaunchSelection(
166
+ bundleUrl = "assets://index.android.bundle",
167
+ launchedBundleId = null,
168
+ shouldRollbackOnCrash = false,
169
+ )
170
+
171
+ override suspend fun updateBundle(
172
+ bundleId: String,
173
+ fileUrl: String?,
174
+ fileHash: String?,
175
+ progressCallback: (Double) -> Unit,
176
+ ) = Unit
177
+
178
+ override fun markLaunchCompleted(currentBundleId: String?) = Unit
179
+
180
+ override fun notifyAppReady(): Map<String, Any?> = mapOf("status" to "STABLE")
181
+
182
+ override fun getCrashHistory(): CrashedHistory = CrashedHistory()
183
+
184
+ override fun clearCrashHistory(): Boolean = true
185
+
186
+ override fun getBaseURL(): String = baseURL
187
+
188
+ override fun getBaseURLForBundle(bundleId: String?): String =
189
+ if (bundleId == null) {
190
+ ""
191
+ } else {
192
+ launchedBundleBaseURLs[bundleId] ?: ""
193
+ }
194
+
195
+ override fun getBundleId(): String? = bundleId
196
+
197
+ override fun getManifest(): Map<String, Any?> = manifest
198
+
199
+ override fun getManifestForBundle(bundleId: String?): Map<String, Any?> =
200
+ if (bundleId == null) {
201
+ emptyMap()
202
+ } else {
203
+ launchedBundleManifests[bundleId] ?: emptyMap()
204
+ }
205
+
206
+ override suspend fun resetChannel(): Boolean = true
207
+ }
208
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "0.29.8",
3
+ "version": "0.30.0",
4
4
  "description": "React Native OTA solution for self-hosted",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -120,14 +120,14 @@
120
120
  "react-native": "0.79.1",
121
121
  "react-native-builder-bob": "^0.40.10",
122
122
  "typescript": "^6.0.2",
123
- "hot-updater": "0.29.8"
123
+ "hot-updater": "0.30.0"
124
124
  },
125
125
  "dependencies": {
126
126
  "use-sync-external-store": "1.5.0",
127
- "@hot-updater/core": "0.29.8",
128
- "@hot-updater/cli-tools": "0.29.8",
129
- "@hot-updater/js": "0.29.8",
130
- "@hot-updater/plugin-core": "0.29.8"
127
+ "@hot-updater/core": "0.30.0",
128
+ "@hot-updater/js": "0.30.0",
129
+ "@hot-updater/plugin-core": "0.30.0",
130
+ "@hot-updater/cli-tools": "0.30.0"
131
131
  },
132
132
  "scripts": {
133
133
  "build": "bob build && tsc -p plugin/tsconfig.build.json",