@hot-updater/react-native 0.29.4 → 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.
- package/android/build.gradle +8 -4
- package/android/gradle.properties +2 -1
- package/android/react-native-helpers.gradle +4 -1
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +47 -14
- package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +329 -0
- package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
- package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +30 -10
- package/package.json +6 -6
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
136
|
-
implementation 'com.facebook.react:react-android:+'
|
|
139
|
+
implementation "com.facebook.react:react-android:${project.ext.reactNativeVersion}"
|
|
137
140
|
} else {
|
|
138
|
-
|
|
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()) {
|
|
@@ -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
|
|
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
|
|
641
|
-
if (
|
|
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(
|
|
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)
|
|
780
|
-
val
|
|
781
|
-
if (
|
|
782
|
-
Log.d("BundleStorage", "
|
|
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 =
|
|
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
|
|
819
|
-
val
|
|
820
|
-
if (
|
|
821
|
-
Log.d("BundleStorage", "
|
|
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 =
|
|
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
|
+
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
#import "React/RCTConstants.h"
|
|
8
8
|
#import "React/RCTRootView.h"
|
|
9
9
|
#import "React/RCTUtils.h" // Needed for RCTPromiseResolveBlock/RejectBlock in Swift
|
|
10
|
-
#import <SSZipArchive/SSZipArchive.h>
|
|
11
10
|
|
|
12
11
|
@interface HotUpdaterRecoverySignalBridge : NSObject
|
|
13
12
|
+ (void)installSignalHandlers:(NSString *)crashMarkerPath;
|
|
@@ -249,6 +249,7 @@ enum ZipArchiveExtractor {
|
|
|
249
249
|
extractionResult = try extractDeflatedEntry(
|
|
250
250
|
from: handle,
|
|
251
251
|
compressedSize: entry.compressedSize,
|
|
252
|
+
path: entry.path,
|
|
252
253
|
to: outputHandle
|
|
253
254
|
)
|
|
254
255
|
default:
|
|
@@ -300,6 +301,7 @@ enum ZipArchiveExtractor {
|
|
|
300
301
|
private static func extractDeflatedEntry(
|
|
301
302
|
from handle: FileHandle,
|
|
302
303
|
compressedSize: UInt64,
|
|
304
|
+
path: String,
|
|
303
305
|
to outputHandle: FileHandle
|
|
304
306
|
) throws -> (writtenSize: UInt64, checksum: UInt32) {
|
|
305
307
|
let outputBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: ArchiveExtractionUtilities.bufferSize)
|
|
@@ -345,22 +347,23 @@ enum ZipArchiveExtractor {
|
|
|
345
347
|
|
|
346
348
|
stream.next_in = UnsafeMutablePointer(mutating: baseAddress)
|
|
347
349
|
stream.avail_in = uInt(chunk.count)
|
|
350
|
+
var needsMoreInput = false
|
|
348
351
|
|
|
349
352
|
repeat {
|
|
350
353
|
stream.next_out = outputBuffer
|
|
351
354
|
stream.avail_out = uInt(ArchiveExtractionUtilities.bufferSize)
|
|
352
355
|
|
|
353
356
|
let status = inflate(&stream, Z_NO_FLUSH)
|
|
357
|
+
let producedBytes = ArchiveExtractionUtilities.bufferSize - Int(stream.avail_out)
|
|
358
|
+
if producedBytes > 0 {
|
|
359
|
+
let outputData = Data(bytes: outputBuffer, count: producedBytes)
|
|
360
|
+
outputHandle.write(outputData)
|
|
361
|
+
totalWritten += UInt64(producedBytes)
|
|
362
|
+
checksum = updateCRC32(checksum, with: outputData)
|
|
363
|
+
}
|
|
364
|
+
|
|
354
365
|
switch status {
|
|
355
366
|
case Z_OK, Z_STREAM_END:
|
|
356
|
-
let producedBytes = ArchiveExtractionUtilities.bufferSize - Int(stream.avail_out)
|
|
357
|
-
if producedBytes > 0 {
|
|
358
|
-
let outputData = Data(bytes: outputBuffer, count: producedBytes)
|
|
359
|
-
outputHandle.write(outputData)
|
|
360
|
-
totalWritten += UInt64(producedBytes)
|
|
361
|
-
checksum = updateCRC32(checksum, with: outputData)
|
|
362
|
-
}
|
|
363
|
-
|
|
364
367
|
if status == Z_STREAM_END {
|
|
365
368
|
guard stream.avail_in == 0, remainingBytes == 0 else {
|
|
366
369
|
throw NSError(
|
|
@@ -372,16 +375,33 @@ enum ZipArchiveExtractor {
|
|
|
372
375
|
|
|
373
376
|
reachedStreamEnd = true
|
|
374
377
|
}
|
|
378
|
+
case Z_BUF_ERROR:
|
|
379
|
+
guard stream.avail_in == 0, remainingBytes > 0 else {
|
|
380
|
+
let message = stream.msg.map { String(cString: $0) } ?? "Unknown zlib error"
|
|
381
|
+
throw NSError(
|
|
382
|
+
domain: "ZipArchiveExtractor",
|
|
383
|
+
code: 13,
|
|
384
|
+
userInfo: [
|
|
385
|
+
NSLocalizedDescriptionKey:
|
|
386
|
+
"ZIP deflate failed for \(path): status=\(status) totalIn=\(stream.total_in) totalOut=\(stream.total_out) \(message)"
|
|
387
|
+
]
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
needsMoreInput = true
|
|
375
392
|
|
|
376
393
|
default:
|
|
377
394
|
let message = stream.msg.map { String(cString: $0) } ?? "Unknown zlib error"
|
|
378
395
|
throw NSError(
|
|
379
396
|
domain: "ZipArchiveExtractor",
|
|
380
397
|
code: 13,
|
|
381
|
-
userInfo: [
|
|
398
|
+
userInfo: [
|
|
399
|
+
NSLocalizedDescriptionKey:
|
|
400
|
+
"ZIP deflate failed for \(path): status=\(status) totalIn=\(stream.total_in) totalOut=\(stream.total_out) \(message)"
|
|
401
|
+
]
|
|
382
402
|
)
|
|
383
403
|
}
|
|
384
|
-
} while stream.avail_in > 0 || stream.avail_out == 0
|
|
404
|
+
} while (stream.avail_in > 0 || stream.avail_out == 0) && !needsMoreInput && !reachedStreamEnd
|
|
385
405
|
}
|
|
386
406
|
|
|
387
407
|
if reachedStreamEnd {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/react-native",
|
|
3
|
-
"version": "0.29.
|
|
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.
|
|
123
|
+
"hot-updater": "0.29.6"
|
|
124
124
|
},
|
|
125
125
|
"dependencies": {
|
|
126
126
|
"use-sync-external-store": "1.5.0",
|
|
127
|
-
"@hot-updater/
|
|
128
|
-
"@hot-updater/
|
|
129
|
-
"@hot-updater/
|
|
130
|
-
"@hot-updater/core": "0.29.
|
|
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",
|