@hot-updater/react-native 0.29.5 → 0.29.6

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.
@@ -89,6 +89,10 @@ android {
89
89
  }
90
90
  }
91
91
 
92
+ testOptions {
93
+ unitTests.returnDefaultValues = true
94
+ }
95
+
92
96
  lintOptions {
93
97
  disable "GradleCompatible"
94
98
  }
@@ -132,11 +136,9 @@ dependencies {
132
136
  // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
133
137
  //noinspection GradleDynamicVersion
134
138
  if (project.ext.shouldConsumeReactNativeFromMavenCentral()) {
135
- // noinspection GradleDynamicVersion
136
- implementation 'com.facebook.react:react-android:+'
139
+ implementation "com.facebook.react:react-android:${project.ext.reactNativeVersion}"
137
140
  } else {
138
- // noinspection GradleDynamicVersion
139
- implementation 'com.facebook.react:react-native:+'
141
+ implementation "com.facebook.react:react-native:${project.ext.reactNativeVersion}"
140
142
  }
141
143
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
142
144
  implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
@@ -144,6 +146,8 @@ dependencies {
144
146
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
145
147
  implementation "com.squareup.okhttp3:okhttp:4.12.0"
146
148
  implementation files('libs/org.brotli.dec-1.2.0.jar')
149
+ testImplementation "junit:junit:4.13.2"
150
+ testImplementation "org.json:json:20240303"
147
151
  }
148
152
 
149
153
  if (isNewArchitectureEnabled()) {
@@ -1,5 +1,6 @@
1
- HotUpdater_kotlinVersion=1.7.0
1
+ HotUpdater_kotlinVersion=2.0.21
2
2
  HotUpdater_minSdkVersion=21
3
3
  HotUpdater_targetSdkVersion=35
4
4
  HotUpdater_compileSdkVersion=35
5
5
  HotUpdater_ndkversion=21.4.7075529
6
+ android.useAndroidX=true
@@ -33,10 +33,13 @@ file("$reactNativeRootDir/ReactAndroid/gradle.properties").withInputStream { rea
33
33
  def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME")
34
34
  def REACT_NATIVE_MINOR_VERSION = REACT_NATIVE_VERSION.startsWith("0.0.0-") ? 1000 : REACT_NATIVE_VERSION.split("\\.")[1].toInteger()
35
35
 
36
+ project.ext.reactNativeVersion = REACT_NATIVE_VERSION
37
+ project.ext.reactNativeMinorVersion = REACT_NATIVE_MINOR_VERSION
38
+
36
39
  project.ext.resolveReactNativeDirectory = { ->
37
40
  return resolveReactNativeDirectory()
38
41
  }
39
42
 
40
43
  project.ext.shouldConsumeReactNativeFromMavenCentral = { ->
41
44
  return REACT_NATIVE_MINOR_VERSION >= 71
42
- }
45
+ }
@@ -191,9 +191,42 @@ class BundleFileStorageService(
191
191
  return regex.find(currentUrl)?.groupValues?.get(1)
192
192
  }
193
193
 
194
+ private fun resolveBundleFile(bundleDir: File): File? {
195
+ val manifest = readManifestFromBundleDir(bundleDir)
196
+ val manifestBundlePath =
197
+ (manifest?.get("assets") as? Map<*, *>)
198
+ ?.keys
199
+ ?.mapNotNull { key ->
200
+ (key as? String)
201
+ ?.trim()
202
+ ?.takeIf { it.isNotEmpty() }
203
+ ?.takeIf { File(it).name.endsWith(".android.bundle") }
204
+ }?.singleOrNull()
205
+
206
+ if (manifestBundlePath != null) {
207
+ try {
208
+ val canonicalBundleDir = bundleDir.canonicalFile
209
+ val canonicalBundleFile = File(bundleDir, manifestBundlePath).canonicalFile
210
+ val canonicalBundleDirPath = canonicalBundleDir.path
211
+ val canonicalBundleFilePath = canonicalBundleFile.path
212
+ val isWithinBundleDir =
213
+ canonicalBundleFilePath == canonicalBundleDirPath ||
214
+ canonicalBundleFilePath.startsWith("$canonicalBundleDirPath${File.separator}")
215
+
216
+ if (isWithinBundleDir && canonicalBundleFile.isFile) {
217
+ return canonicalBundleFile
218
+ }
219
+ } catch (e: Exception) {
220
+ Log.w(TAG, "Failed to resolve manifest bundle file from ${bundleDir.absolutePath}: ${e.message}")
221
+ }
222
+ }
223
+
224
+ return File(bundleDir, "index.android.bundle").absoluteFile.takeIf { it.isFile }
225
+ }
226
+
194
227
  private fun findBundleFile(bundleId: String): File? {
195
228
  val bundleDir = File(getBundleStoreDir(), bundleId)
196
- return bundleDir.walk().find { it.name == "index.android.bundle" && it.exists() }
229
+ return resolveBundleFile(bundleDir)
197
230
  }
198
231
 
199
232
  private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
@@ -637,8 +670,8 @@ class BundleFileStorageService(
637
670
  val finalBundleDir = File(bundleStoreDir, bundleId)
638
671
  if (finalBundleDir.exists()) {
639
672
  Log.d(TAG, "Bundle for bundleId $bundleId already exists. Using cached bundle.")
640
- val existingIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
641
- if (existingIndexFile != null) {
673
+ val existingBundleFile = resolveBundleFile(finalBundleDir)
674
+ if (existingBundleFile != null) {
642
675
  // Update last modified time
643
676
  finalBundleDir.setLastModified(System.currentTimeMillis())
644
677
 
@@ -648,7 +681,7 @@ class BundleFileStorageService(
648
681
  saveMetadata(updatedMetadata)
649
682
 
650
683
  // Set bundle URL for backwards compatibility
651
- setBundleURL(existingIndexFile.absolutePath)
684
+ setBundleURL(existingBundleFile.absolutePath)
652
685
 
653
686
  // Keep the current verified bundle as a fallback if one exists.
654
687
  cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
@@ -776,17 +809,17 @@ class BundleFileStorageService(
776
809
  throw HotUpdaterException.extractionFormatError()
777
810
  }
778
811
 
779
- // 4) Find index.android.bundle inside tmpDir
780
- val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
781
- if (extractedIndex == null) {
782
- Log.d("BundleStorage", "index.android.bundle not found in tmpDir.")
812
+ // 4) Resolve the extracted Android bundle file.
813
+ val extractedBundleFile = resolveBundleFile(tmpDir)
814
+ if (extractedBundleFile == null) {
815
+ Log.d("BundleStorage", "Android bundle file could not be resolved in tmpDir.")
783
816
  tempDir.deleteRecursively()
784
817
  tmpDir.deleteRecursively()
785
818
  throw HotUpdaterException.invalidBundle()
786
819
  }
787
820
 
788
821
  // 5) Log extracted bundle file size
789
- val bundleSize = extractedIndex.length()
822
+ val bundleSize = extractedBundleFile.length()
790
823
  Log.d("BundleStorage", "Extracted bundle size: $bundleSize bytes")
791
824
 
792
825
  // 6) If the realDir (bundle-store/<bundleId>) exists, delete it
@@ -815,10 +848,10 @@ class BundleFileStorageService(
815
848
  }
816
849
  }
817
850
 
818
- // 8) Verify index.android.bundle exists inside finalBundleDir
819
- val finalIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
820
- if (finalIndexFile == null) {
821
- Log.d("BundleStorage", "index.android.bundle not found in realDir.")
851
+ // 8) Verify the Android bundle file exists inside finalBundleDir.
852
+ val finalBundleFile = resolveBundleFile(finalBundleDir)
853
+ if (finalBundleFile == null) {
854
+ Log.d("BundleStorage", "Android bundle file could not be resolved in realDir.")
822
855
  tempDir.deleteRecursively()
823
856
  finalBundleDir.deleteRecursively()
824
857
  throw HotUpdaterException.invalidBundle()
@@ -828,7 +861,7 @@ class BundleFileStorageService(
828
861
  finalBundleDir.setLastModified(System.currentTimeMillis())
829
862
 
830
863
  // 10) Save the new bundle as STAGING with verification pending
831
- val bundlePath = finalIndexFile.absolutePath
864
+ val bundlePath = finalBundleFile.absolutePath
832
865
  Log.d(TAG, "Setting bundle as staging: $bundlePath")
833
866
 
834
867
  // Update metadata: set new bundle as staging
@@ -0,0 +1,329 @@
1
+ package com.hotupdater
2
+
3
+ import android.content.ContextWrapper
4
+ import org.json.JSONObject
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Rule
11
+ import org.junit.Test
12
+ import org.junit.rules.TemporaryFolder
13
+ import java.io.File
14
+ import java.net.URL
15
+
16
+ class BundleFileStorageServiceTest {
17
+ @get:Rule
18
+ val temporaryFolder = TemporaryFolder()
19
+
20
+ @Test
21
+ fun `resolveBundleFile uses single manifest bundle at root`() {
22
+ val rootDir = temporaryFolder.newFolder("root-manifest-bundle")
23
+ val service = createService(rootDir)
24
+ val bundleDir = createBundleDir(rootDir, "bundle-root")
25
+ val expectedBundleFile = writeFile(bundleDir, "foo.android.bundle")
26
+
27
+ writeManifest(bundleDir, listOf("foo.android.bundle"))
28
+
29
+ assertResolvedBundlePath(service, bundleDir, expectedBundleFile)
30
+ }
31
+
32
+ @Test
33
+ fun `resolveBundleFile uses single nested manifest bundle`() {
34
+ val rootDir = temporaryFolder.newFolder("nested-manifest-bundle")
35
+ val service = createService(rootDir)
36
+ val bundleDir = createBundleDir(rootDir, "bundle-nested")
37
+ val expectedBundleFile = writeFile(bundleDir, "dist/foo.android.bundle")
38
+
39
+ writeManifest(bundleDir, listOf("dist/foo.android.bundle"))
40
+
41
+ assertResolvedBundlePath(service, bundleDir, expectedBundleFile)
42
+ }
43
+
44
+ @Test
45
+ fun `resolveBundleFile falls back to root index when manifest has no android bundle candidate`() {
46
+ val rootDir = temporaryFolder.newFolder("no-android-candidate")
47
+ val service = createService(rootDir)
48
+ val bundleDir = createBundleDir(rootDir, "bundle-no-candidate")
49
+ val fallbackBundleFile = writeFile(bundleDir, "index.android.bundle")
50
+
51
+ writeManifest(bundleDir, listOf("index.ios.bundle", "assets/image.png"))
52
+
53
+ assertResolvedBundlePath(service, bundleDir, fallbackBundleFile)
54
+ }
55
+
56
+ @Test
57
+ fun `resolveBundleFile falls back to root index when manifest has multiple android bundle candidates`() {
58
+ val rootDir = temporaryFolder.newFolder("multiple-android-candidates")
59
+ val service = createService(rootDir)
60
+ val bundleDir = createBundleDir(rootDir, "bundle-multiple-candidates")
61
+ val fallbackBundleFile = writeFile(bundleDir, "index.android.bundle")
62
+
63
+ writeFile(bundleDir, "foo.android.bundle")
64
+ writeFile(bundleDir, "dist/bar.android.bundle")
65
+ writeManifest(bundleDir, listOf("foo.android.bundle", "dist/bar.android.bundle"))
66
+
67
+ assertResolvedBundlePath(service, bundleDir, fallbackBundleFile)
68
+ }
69
+
70
+ @Test
71
+ fun `resolveBundleFile returns null when manifest escapes root and no fallback exists`() {
72
+ val rootDir = temporaryFolder.newFolder("escaped-manifest-path")
73
+ val service = createService(rootDir)
74
+ val bundleDir = createBundleDir(rootDir, "bundle-escaped-path")
75
+
76
+ writeFile(bundleStoreDir(rootDir), "outside.android.bundle")
77
+ writeManifest(bundleDir, listOf("../outside.android.bundle"))
78
+
79
+ assertNull(invokeResolveBundleFile(service, bundleDir))
80
+ }
81
+
82
+ @Test
83
+ fun `resolveBundleFile returns null when manifest target is missing and no fallback exists`() {
84
+ val rootDir = temporaryFolder.newFolder("missing-manifest-target")
85
+ val service = createService(rootDir)
86
+ val bundleDir = createBundleDir(rootDir, "bundle-missing-target")
87
+
88
+ writeManifest(bundleDir, listOf("dist/missing.android.bundle"))
89
+
90
+ assertNull(invokeResolveBundleFile(service, bundleDir))
91
+ }
92
+
93
+ @Test
94
+ fun `resolveBundleFile allows legacy root index without manifest`() {
95
+ val rootDir = temporaryFolder.newFolder("legacy-root-index")
96
+ val service = createService(rootDir)
97
+ val bundleDir = createBundleDir(rootDir, "bundle-legacy")
98
+ val fallbackBundleFile = writeFile(bundleDir, "index.android.bundle")
99
+
100
+ assertResolvedBundlePath(service, bundleDir, fallbackBundleFile)
101
+ }
102
+
103
+ @Test
104
+ fun `resolveBundleFile returns null when manifest and root index are both missing`() {
105
+ val rootDir = temporaryFolder.newFolder("missing-everything")
106
+ val service = createService(rootDir)
107
+ val bundleDir = createBundleDir(rootDir, "bundle-invalid")
108
+
109
+ assertNull(invokeResolveBundleFile(service, bundleDir))
110
+ }
111
+
112
+ @Test
113
+ fun `prepareLaunch rolls back invalid staging and selects stable bundle`() {
114
+ val rootDir = temporaryFolder.newFolder("rollback-to-stable")
115
+ val preferences = InMemoryPreferencesService()
116
+ val service = createService(rootDir, preferences)
117
+
118
+ val stagingDir = createBundleDir(rootDir, "staging-bundle")
119
+ writeManifest(stagingDir, listOf("dist/missing.android.bundle"))
120
+
121
+ val stableDir = createBundleDir(rootDir, "stable-bundle")
122
+ val stableBundleFile = writeFile(stableDir, "dist/stable.android.bundle")
123
+ writeManifest(stableDir, listOf("dist/stable.android.bundle"))
124
+
125
+ writeMetadata(
126
+ rootDir,
127
+ BundleMetadata(
128
+ isolationKey = TEST_ISOLATION_KEY,
129
+ stableBundleId = stableDir.name,
130
+ stagingBundleId = stagingDir.name,
131
+ verificationPending = true,
132
+ ),
133
+ )
134
+
135
+ val selection = service.prepareLaunch(null)
136
+ val report = service.notifyAppReady()
137
+
138
+ assertEquals(stableBundleFile.canonicalFile.absolutePath, selection.bundleUrl)
139
+ assertEquals(stableDir.name, selection.launchedBundleId)
140
+ assertFalse(selection.shouldRollbackOnCrash)
141
+ assertFalse(stagingDir.exists())
142
+ assertEquals("RECOVERED", report["status"])
143
+ assertEquals(stagingDir.name, report["crashedBundleId"])
144
+
145
+ val metadata = loadMetadata(rootDir)
146
+ assertNotNull(metadata)
147
+ assertEquals(stableDir.name, metadata?.stagingBundleId)
148
+ assertNull(metadata?.stableBundleId)
149
+ assertFalse(metadata?.verificationPending ?: true)
150
+ assertEquals(stableBundleFile.canonicalFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
151
+ }
152
+
153
+ @Test
154
+ fun `prepareLaunch falls back to built in bundle when staging and stable are both invalid`() {
155
+ val rootDir = temporaryFolder.newFolder("fallback-to-built-in")
156
+ val service = createService(rootDir)
157
+
158
+ val stagingDir = createBundleDir(rootDir, "staging-bundle")
159
+ writeManifest(stagingDir, listOf("dist/missing.android.bundle"))
160
+
161
+ val stableDir = createBundleDir(rootDir, "stable-bundle")
162
+ writeManifest(stableDir, listOf("../outside.android.bundle"))
163
+
164
+ writeMetadata(
165
+ rootDir,
166
+ BundleMetadata(
167
+ isolationKey = TEST_ISOLATION_KEY,
168
+ stableBundleId = stableDir.name,
169
+ stagingBundleId = stagingDir.name,
170
+ verificationPending = true,
171
+ ),
172
+ )
173
+
174
+ val selection = service.prepareLaunch(null)
175
+ val report = service.notifyAppReady()
176
+
177
+ assertEquals("assets://index.android.bundle", selection.bundleUrl)
178
+ assertNull(selection.launchedBundleId)
179
+ assertFalse(selection.shouldRollbackOnCrash)
180
+ assertFalse(stagingDir.exists())
181
+ assertEquals("RECOVERED", report["status"])
182
+ assertEquals(stagingDir.name, report["crashedBundleId"])
183
+ }
184
+
185
+ private fun createService(
186
+ rootDir: File,
187
+ preferences: InMemoryPreferencesService = InMemoryPreferencesService(),
188
+ ): BundleFileStorageService =
189
+ BundleFileStorageService(
190
+ ContextWrapper(null),
191
+ TestFileSystemService(rootDir),
192
+ UnusedDownloadService,
193
+ DecompressService(),
194
+ preferences,
195
+ TEST_ISOLATION_KEY,
196
+ )
197
+
198
+ private fun createBundleDir(
199
+ rootDir: File,
200
+ bundleId: String,
201
+ ): File = File(bundleStoreDir(rootDir), bundleId).apply { mkdirs() }
202
+
203
+ private fun writeManifest(
204
+ bundleDir: File,
205
+ assetPaths: List<String>,
206
+ ) {
207
+ val assets =
208
+ JSONObject().apply {
209
+ assetPaths.forEach { assetPath ->
210
+ put(assetPath, JSONObject().put("fileHash", "$assetPath-hash"))
211
+ }
212
+ }
213
+
214
+ File(bundleDir, "manifest.json").writeText(
215
+ JSONObject()
216
+ .put("bundleId", bundleDir.name)
217
+ .put("assets", assets)
218
+ .toString(),
219
+ )
220
+ }
221
+
222
+ private fun writeMetadata(
223
+ rootDir: File,
224
+ metadata: BundleMetadata,
225
+ ) {
226
+ assertTrue(metadata.saveToFile(File(bundleStoreDir(rootDir), BundleMetadata.METADATA_FILENAME)))
227
+ }
228
+
229
+ private fun loadMetadata(rootDir: File): BundleMetadata? =
230
+ BundleMetadata.loadFromFile(
231
+ File(bundleStoreDir(rootDir), BundleMetadata.METADATA_FILENAME),
232
+ TEST_ISOLATION_KEY,
233
+ )
234
+
235
+ private fun writeFile(
236
+ rootDir: File,
237
+ relativePath: String,
238
+ content: String = "bundle-content",
239
+ ): File =
240
+ File(rootDir, relativePath).apply {
241
+ parentFile?.mkdirs()
242
+ writeText(content)
243
+ }
244
+
245
+ private fun bundleStoreDir(rootDir: File): File = File(rootDir, "bundle-store").apply { mkdirs() }
246
+
247
+ private fun invokeResolveBundleFile(
248
+ service: BundleFileStorageService,
249
+ bundleDir: File,
250
+ ): File? {
251
+ val method =
252
+ BundleFileStorageService::class.java.getDeclaredMethod(
253
+ "resolveBundleFile",
254
+ File::class.java,
255
+ )
256
+ method.isAccessible = true
257
+ return method.invoke(service, bundleDir) as File?
258
+ }
259
+
260
+ private fun assertResolvedBundlePath(
261
+ service: BundleFileStorageService,
262
+ bundleDir: File,
263
+ expected: File,
264
+ ) {
265
+ val resolved = invokeResolveBundleFile(service, bundleDir)
266
+
267
+ assertNotNull(resolved)
268
+ assertEquals(expected.canonicalFile.absolutePath, resolved?.canonicalFile?.absolutePath)
269
+ }
270
+
271
+ private class TestFileSystemService(
272
+ private val externalFilesDir: File,
273
+ ) : FileSystemService {
274
+ override fun fileExists(path: String): Boolean = File(path).exists()
275
+
276
+ override fun createDirectory(path: String): Boolean = File(path).mkdirs()
277
+
278
+ override fun removeItem(path: String): Boolean = File(path).deleteRecursively()
279
+
280
+ override fun moveItem(
281
+ sourcePath: String,
282
+ destinationPath: String,
283
+ ): Boolean = File(sourcePath).renameTo(File(destinationPath))
284
+
285
+ override fun copyItem(
286
+ sourcePath: String,
287
+ destinationPath: String,
288
+ ): Boolean =
289
+ try {
290
+ File(sourcePath).copyRecursively(File(destinationPath), overwrite = true)
291
+ } catch (_: Exception) {
292
+ false
293
+ }
294
+
295
+ override fun contentsOfDirectory(path: String): List<String> = File(path).list()?.toList() ?: emptyList()
296
+
297
+ override fun getExternalFilesDir(): File = externalFilesDir
298
+ }
299
+
300
+ private class InMemoryPreferencesService : PreferencesService {
301
+ private val values = mutableMapOf<String, String?>()
302
+
303
+ override fun getItem(key: String): String? = values[key]
304
+
305
+ override fun setItem(
306
+ key: String,
307
+ value: String?,
308
+ ) {
309
+ if (value == null) {
310
+ values.remove(key)
311
+ } else {
312
+ values[key] = value
313
+ }
314
+ }
315
+ }
316
+
317
+ private object UnusedDownloadService : DownloadService {
318
+ override suspend fun downloadFile(
319
+ fileUrl: URL,
320
+ destination: File,
321
+ fileSizeCallback: ((Long) -> Unit)?,
322
+ progressCallback: (Double) -> Unit,
323
+ ): DownloadResult = error("downloadFile should not be called in these tests")
324
+ }
325
+
326
+ companion object {
327
+ private const val TEST_ISOLATION_KEY = "test-isolation-key"
328
+ }
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "0.29.5",
3
+ "version": "0.29.6",
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.5"
123
+ "hot-updater": "0.29.6"
124
124
  },
125
125
  "dependencies": {
126
126
  "use-sync-external-store": "1.5.0",
127
- "@hot-updater/cli-tools": "0.29.5",
128
- "@hot-updater/js": "0.29.5",
129
- "@hot-updater/plugin-core": "0.29.5",
130
- "@hot-updater/core": "0.29.5"
127
+ "@hot-updater/cli-tools": "0.29.6",
128
+ "@hot-updater/plugin-core": "0.29.6",
129
+ "@hot-updater/js": "0.29.6",
130
+ "@hot-updater/core": "0.29.6"
131
131
  },
132
132
  "scripts": {
133
133
  "build": "bob build && tsc -p plugin/tsconfig.build.json",