@hot-updater/react-native 0.24.7 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +12 -0
  2. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +144 -20
  3. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +27 -2
  4. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +17 -2
  5. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +11 -38
  6. package/android/src/newarch/HotUpdaterModule.kt +5 -0
  7. package/android/src/oldarch/HotUpdaterModule.kt +6 -0
  8. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  9. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +168 -34
  10. package/ios/HotUpdater/Internal/BundleMetadata.swift +17 -1
  11. package/ios/HotUpdater/Internal/HotUpdater.mm +14 -0
  12. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +20 -3
  13. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +24 -40
  14. package/lib/commonjs/global.d.js +6 -0
  15. package/lib/commonjs/global.d.js.map +1 -0
  16. package/lib/commonjs/index.js +25 -0
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/native.js +17 -1
  19. package/lib/commonjs/native.js.map +1 -1
  20. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  21. package/lib/commonjs/wrap.js +1 -2
  22. package/lib/commonjs/wrap.js.map +1 -1
  23. package/lib/module/global.d.js +8 -0
  24. package/lib/module/global.d.js.map +1 -0
  25. package/lib/module/index.js +26 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/native.js +15 -0
  28. package/lib/module/native.js.map +1 -1
  29. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/native.d.ts +8 -0
  32. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -0
  34. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  35. package/lib/typescript/module/index.d.ts.map +1 -1
  36. package/lib/typescript/module/native.d.ts +8 -0
  37. package/lib/typescript/module/native.d.ts.map +1 -1
  38. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -0
  39. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  40. package/package.json +7 -7
  41. package/src/global.d.ts +23 -0
  42. package/src/index.ts +26 -0
  43. package/src/native.ts +15 -0
  44. package/src/specs/NativeHotUpdater.ts +9 -0
package/LICENSE CHANGED
@@ -19,3 +19,15 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ ADDITIONAL DISCLAIMER
26
+
27
+ The authors of this software shall not be held responsible for any legal issues,
28
+ regulatory violations, policy violations, or disputes arising from the use,
29
+ distribution, or integration of this software.
30
+
31
+ Users are solely responsible for ensuring compliance with all applicable laws,
32
+ regulations, and third-party policies (including but not limited to app store
33
+ policies).
@@ -70,6 +70,12 @@ interface BundleStorageService {
70
70
  * @return true if clearing was successful
71
71
  */
72
72
  fun clearCrashHistory(): Boolean
73
+
74
+ /**
75
+ * Gets the base URL for the current active bundle directory
76
+ * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
77
+ */
78
+ fun getBaseURL(): String
73
79
  }
74
80
 
75
81
  /**
@@ -81,11 +87,20 @@ class BundleFileStorageService(
81
87
  private val downloadService: DownloadService,
82
88
  private val decompressService: DecompressService,
83
89
  private val preferences: PreferencesService,
90
+ private val isolationKey: String,
84
91
  ) : BundleStorageService {
85
92
  companion object {
86
93
  private const val TAG = "BundleStorage"
87
94
  }
88
95
 
96
+ init {
97
+ // Ensure bundle store directory exists
98
+ getBundleStoreDir().mkdirs()
99
+
100
+ // Clean up old bundles if isolationKey format changed
101
+ checkAndCleanupIfIsolationKeyChanged()
102
+ }
103
+
89
104
  // Session-only rollback tracking (in-memory)
90
105
  private var sessionRollbackBundleId: String? = null
91
106
 
@@ -102,9 +117,12 @@ class BundleFileStorageService(
102
117
 
103
118
  // MARK: - Metadata Operations
104
119
 
105
- private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile())
120
+ private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
106
121
 
107
- private fun saveMetadata(metadata: BundleMetadata): Boolean = metadata.saveToFile(getMetadataFile())
122
+ private fun saveMetadata(metadata: BundleMetadata): Boolean {
123
+ val updatedMetadata = metadata.copy(isolationKey = isolationKey)
124
+ return updatedMetadata.saveToFile(getMetadataFile())
125
+ }
108
126
 
109
127
  private fun createInitialMetadata(): BundleMetadata {
110
128
  val currentBundleId = extractBundleIdFromCurrentURL()
@@ -124,6 +142,67 @@ class BundleFileStorageService(
124
142
  return regex.find(currentUrl)?.groupValues?.get(1)
125
143
  }
126
144
 
145
+ /**
146
+ * Checks if isolationKey has changed and cleans up old bundles if needed.
147
+ * This handles migration when isolationKey format changes.
148
+ */
149
+ private fun checkAndCleanupIfIsolationKeyChanged() {
150
+ val metadataFile = getMetadataFile()
151
+
152
+ if (!metadataFile.exists()) {
153
+ // First launch - no cleanup needed
154
+ return
155
+ }
156
+
157
+ try {
158
+ // Read metadata without validation to get stored isolationKey
159
+ val jsonString = metadataFile.readText()
160
+ val json = org.json.JSONObject(jsonString)
161
+ val storedIsolationKey = json.optString("isolationKey", null)
162
+
163
+ if (storedIsolationKey != null && storedIsolationKey != isolationKey) {
164
+ // isolationKey changed - migration needed
165
+ Log.d(TAG, "isolationKey changed: $storedIsolationKey -> $isolationKey")
166
+ Log.d(TAG, "Cleaning up old bundles for migration")
167
+ cleanupAllBundlesForMigration()
168
+ }
169
+ } catch (e: Exception) {
170
+ Log.e(TAG, "Error checking isolationKey: ${e.message}")
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Removes all bundle directories during migration.
176
+ * Called when isolationKey format changes.
177
+ */
178
+ private fun cleanupAllBundlesForMigration() {
179
+ val bundleStoreDir = getBundleStoreDir()
180
+
181
+ if (!bundleStoreDir.exists()) {
182
+ return
183
+ }
184
+
185
+ try {
186
+ var cleanedCount = 0
187
+ bundleStoreDir.listFiles()?.forEach { file ->
188
+ if (file.isDirectory) {
189
+ try {
190
+ if (file.deleteRecursively()) {
191
+ cleanedCount++
192
+ Log.d(TAG, "Migration: removed old bundle ${file.name}")
193
+ }
194
+ } catch (e: Exception) {
195
+ Log.e(TAG, "Error removing bundle ${file.name}: ${e.message}")
196
+ }
197
+ }
198
+ }
199
+
200
+ Log.d(TAG, "Migration cleanup complete: removed $cleanedCount bundles")
201
+ } catch (e: Exception) {
202
+ Log.e(TAG, "Error during migration cleanup: ${e.message}")
203
+ }
204
+ }
205
+
127
206
  // MARK: - State Machine
128
207
 
129
208
  private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
@@ -483,34 +562,48 @@ class BundleFileStorageService(
483
562
  }
484
563
  val tempBundleFile = File(tempDir, bundleFileName)
485
564
 
486
- // Check file size before downloading
487
- val fileSize = downloadService.getFileSize(downloadUrl)
488
- if (fileSize > 0 && baseDir != null) {
489
- // Check available disk space
490
- val stat = StatFs(baseDir.absolutePath)
491
- val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
492
- val requiredSpace = fileSize * 2 // ZIP + extracted files
493
-
494
- Log.d("BundleStorage", "File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes")
495
-
496
- if (availableBytes < requiredSpace) {
497
- Log.d("BundleStorage", "Insufficient disk space: need $requiredSpace bytes, available $availableBytes bytes")
498
- throw HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
499
- }
500
- } else {
501
- Log.d("BundleStorage", "Unable to determine file size, proceeding with download")
502
- }
503
-
504
565
  // Download the file (0% - 80%)
566
+ // Disk space check will be performed in fileSizeCallback
567
+ var diskSpaceError: HotUpdaterException? = null
568
+
505
569
  val downloadResult =
506
570
  downloadService.downloadFile(
507
571
  downloadUrl,
508
572
  tempBundleFile,
573
+ fileSizeCallback = { fileSize ->
574
+ // Perform disk space check when file size is known
575
+ if (baseDir != null) {
576
+ val stat = StatFs(baseDir.absolutePath)
577
+ val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
578
+ val requiredSpace = fileSize * 2 // ZIP + extracted files
579
+
580
+ Log.d(
581
+ "BundleStorage",
582
+ "File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes",
583
+ )
584
+
585
+ if (availableBytes < requiredSpace) {
586
+ Log.d(
587
+ TAG,
588
+ "Insufficient disk space detected: need $requiredSpace bytes, available $availableBytes bytes",
589
+ )
590
+ // Store error to be thrown after download completes/cancels
591
+ diskSpaceError = HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
592
+ }
593
+ }
594
+ },
509
595
  ) { downloadProgress ->
510
596
  // Map download progress to 0.0 - 0.8
511
597
  progressCallback(downloadProgress * 0.8)
512
598
  }
513
599
 
600
+ // Check for disk space error first before processing download result
601
+ diskSpaceError?.let {
602
+ Log.d(TAG, "Throwing disk space error")
603
+ tempDir.deleteRecursively()
604
+ throw it
605
+ }
606
+
514
607
  when (downloadResult) {
515
608
  is DownloadResult.Error -> {
516
609
  Log.d("BundleStorage", "Download failed: ${downloadResult.exception.message}")
@@ -705,4 +798,35 @@ class BundleFileStorageService(
705
798
  Log.e(TAG, "Error during cleanup: ${e.message}")
706
799
  }
707
800
  }
801
+
802
+ /**
803
+ * Gets the base URL for the current active bundle directory.
804
+ * Returns the file:// URL to the bundle directory without trailing slash.
805
+ * This is used for Expo DOM components to construct full asset paths.
806
+ * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
807
+ */
808
+ override fun getBaseURL(): String {
809
+ return try {
810
+ val metadata = loadMetadataOrNull()
811
+ val activeBundleId =
812
+ when {
813
+ metadata?.verificationPending == true && metadata.stagingBundleId != null ->
814
+ metadata.stagingBundleId
815
+ metadata?.stableBundleId != null -> metadata.stableBundleId
816
+ else -> extractBundleIdFromCurrentURL()
817
+ }
818
+
819
+ if (activeBundleId != null) {
820
+ val bundleDir = File(getBundleStoreDir(), activeBundleId)
821
+ if (bundleDir.exists()) {
822
+ return "file://${bundleDir.absolutePath}"
823
+ }
824
+ }
825
+
826
+ ""
827
+ } catch (e: Exception) {
828
+ Log.e(TAG, "Error getting base URL: ${e.message}")
829
+ ""
830
+ }
831
+ }
708
832
  }
@@ -10,6 +10,7 @@ import java.io.File
10
10
  */
11
11
  data class BundleMetadata(
12
12
  val schema: String = SCHEMA_VERSION,
13
+ val isolationKey: String? = null,
13
14
  val stableBundleId: String? = null,
14
15
  val stagingBundleId: String? = null,
15
16
  val verificationPending: Boolean = false,
@@ -25,6 +26,12 @@ data class BundleMetadata(
25
26
  fun fromJson(json: JSONObject): BundleMetadata =
26
27
  BundleMetadata(
27
28
  schema = json.optString("schema", SCHEMA_VERSION),
29
+ isolationKey =
30
+ if (json.has("isolationKey") && !json.isNull("isolationKey")) {
31
+ json.getString("isolationKey").takeIf { it.isNotEmpty() }
32
+ } else {
33
+ null
34
+ },
28
35
  stableBundleId =
29
36
  if (json.has("stableBundleId") && !json.isNull("stableBundleId")) {
30
37
  json.getString("stableBundleId").takeIf { it.isNotEmpty() }
@@ -53,7 +60,10 @@ data class BundleMetadata(
53
60
  updatedAt = json.optLong("updatedAt", System.currentTimeMillis()),
54
61
  )
55
62
 
56
- fun loadFromFile(file: File): BundleMetadata? {
63
+ fun loadFromFile(
64
+ file: File,
65
+ expectedIsolationKey: String,
66
+ ): BundleMetadata? {
57
67
  return try {
58
68
  if (!file.exists()) {
59
69
  Log.d(TAG, "Metadata file does not exist: ${file.absolutePath}")
@@ -61,7 +71,21 @@ data class BundleMetadata(
61
71
  }
62
72
  val jsonString = file.readText()
63
73
  val json = JSONObject(jsonString)
64
- fromJson(json)
74
+ val metadata = fromJson(json)
75
+
76
+ // Validate isolation key
77
+ val metadataKey = metadata.isolationKey
78
+ if (metadataKey != null) {
79
+ if (metadataKey != expectedIsolationKey) {
80
+ Log.d(TAG, "Isolation key mismatch: expected=$expectedIsolationKey, got=$metadataKey")
81
+ return null
82
+ }
83
+ } else {
84
+ Log.d(TAG, "Missing isolation key in metadata, treating as invalid")
85
+ return null
86
+ }
87
+
88
+ metadata
65
89
  } catch (e: Exception) {
66
90
  Log.e(TAG, "Failed to load metadata from file", e)
67
91
  null
@@ -72,6 +96,7 @@ data class BundleMetadata(
72
96
  fun toJson(): JSONObject =
73
97
  JSONObject().apply {
74
98
  put("schema", schema)
99
+ put("isolationKey", isolationKey ?: JSONObject.NULL)
75
100
  put("stableBundleId", stableBundleId ?: JSONObject.NULL)
76
101
  put("stagingBundleId", stagingBundleId ?: JSONObject.NULL)
77
102
  put("verificationPending", verificationPending)
@@ -72,6 +72,7 @@ class HotUpdaterImpl {
72
72
  val preferences = createPreferences(appContext)
73
73
  val downloadService = OkHttpDownloadService()
74
74
  val decompressService = DecompressService()
75
+ val isolationKey = getIsolationKey(appContext)
75
76
 
76
77
  return BundleFileStorageService(
77
78
  appContext,
@@ -79,6 +80,7 @@ class HotUpdaterImpl {
79
80
  downloadService,
80
81
  decompressService,
81
82
  preferences,
83
+ isolationKey,
82
84
  )
83
85
  }
84
86
 
@@ -110,8 +112,13 @@ class HotUpdaterImpl {
110
112
  val appVersion = getAppVersion(context) ?: "unknown"
111
113
  val appChannel = getChannel(context)
112
114
 
113
- // Use fingerprint if available, otherwise use app version
114
- val baseKey = if (!fingerprintHash.isNullOrEmpty()) fingerprintHash else appVersion
115
+ // Include both fingerprint hash and app version for complete isolation
116
+ val baseKey =
117
+ if (!fingerprintHash.isNullOrEmpty()) {
118
+ "${fingerprintHash}_$appVersion"
119
+ } else {
120
+ appVersion
121
+ }
115
122
 
116
123
  return "HotUpdaterPrefs_${baseKey}_$appChannel"
117
124
  }
@@ -290,4 +297,12 @@ class HotUpdaterImpl {
290
297
  * @return true if clearing was successful
291
298
  */
292
299
  fun clearCrashHistory(): Boolean = bundleStorage.clearCrashHistory()
300
+
301
+ /**
302
+ * Gets the base URL for the current active bundle directory.
303
+ * Returns the file:// URL to the bundle directory with trailing slash.
304
+ * This is used for Expo DOM components to construct full asset paths.
305
+ * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123/") or empty string
306
+ */
307
+ fun getBaseURL(): String = bundleStorage.getBaseURL()
293
308
  }
@@ -45,23 +45,18 @@ sealed class DownloadResult {
45
45
  * Interface for download operations
46
46
  */
47
47
  interface DownloadService {
48
- /**
49
- * Gets the file size from the URL without downloading
50
- * @param fileUrl The URL to check
51
- * @return File size in bytes, or -1 if unavailable
52
- */
53
- suspend fun getFileSize(fileUrl: URL): Long
54
-
55
48
  /**
56
49
  * Downloads a file from a URL
57
50
  * @param fileUrl The URL to download from
58
51
  * @param destination The local file to save to
52
+ * @param fileSizeCallback Optional callback called when file size is known
59
53
  * @param progressCallback Callback for download progress updates
60
54
  * @return Result indicating success or failure
61
55
  */
62
56
  suspend fun downloadFile(
63
57
  fileUrl: URL,
64
58
  destination: File,
59
+ fileSizeCallback: ((Long) -> Unit)? = null,
65
60
  progressCallback: (Double) -> Unit,
66
61
  ): DownloadResult
67
62
  }
@@ -128,34 +123,10 @@ class OkHttpDownloadService : DownloadService {
128
123
  .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
129
124
  .build()
130
125
 
131
- override suspend fun getFileSize(fileUrl: URL): Long =
132
- withContext(Dispatchers.IO) {
133
- try {
134
- val request =
135
- Request
136
- .Builder()
137
- .url(fileUrl)
138
- .head()
139
- .build()
140
- client.newCall(request).execute().use { response ->
141
- if (response.isSuccessful) {
142
- val contentLength = response.header("Content-Length")?.toLongOrNull() ?: -1L
143
- Log.d(TAG, "File size from HEAD request: $contentLength bytes")
144
- contentLength
145
- } else {
146
- Log.d(TAG, "HEAD request failed: ${response.code}")
147
- -1L
148
- }
149
- }
150
- } catch (e: Exception) {
151
- Log.d(TAG, "Failed to get file size: ${e.message}")
152
- -1L
153
- }
154
- }
155
-
156
126
  override suspend fun downloadFile(
157
127
  fileUrl: URL,
158
128
  destination: File,
129
+ fileSizeCallback: ((Long) -> Unit)?,
159
130
  progressCallback: (Double) -> Unit,
160
131
  ): DownloadResult =
161
132
  withContext(Dispatchers.IO) {
@@ -167,6 +138,7 @@ class OkHttpDownloadService : DownloadService {
167
138
  return@withContext attemptDownload(
168
139
  fileUrl,
169
140
  destination,
141
+ fileSizeCallback,
170
142
  progressCallback,
171
143
  )
172
144
  } catch (e: Exception) {
@@ -193,6 +165,7 @@ class OkHttpDownloadService : DownloadService {
193
165
  private suspend fun attemptDownload(
194
166
  fileUrl: URL,
195
167
  destination: File,
168
+ fileSizeCallback: ((Long) -> Unit)?,
196
169
  progressCallback: (Double) -> Unit,
197
170
  ): DownloadResult =
198
171
  withContext(Dispatchers.IO) {
@@ -231,14 +204,14 @@ class OkHttpDownloadService : DownloadService {
231
204
  // Get total file size
232
205
  val totalSize = body.contentLength()
233
206
 
234
- if (totalSize <= 0) {
235
- Log.d(TAG, "Invalid content length: $totalSize")
236
- response.close()
237
- return@withContext DownloadResult.Error(Exception("Invalid content length: $totalSize"))
207
+ if (totalSize > 0) {
208
+ // Notify file size to caller for disk space check
209
+ fileSizeCallback?.invoke(totalSize)
210
+ Log.d(TAG, "Starting download: $totalSize bytes")
211
+ } else {
212
+ Log.d(TAG, "Content-Length not available ($totalSize), proceeding without disk space check")
238
213
  }
239
214
 
240
- Log.d(TAG, "Starting download: $totalSize bytes")
241
-
242
215
  try {
243
216
  // Wrap response body with progress tracking
244
217
  val progressBody =
@@ -162,6 +162,11 @@ class HotUpdaterModule internal constructor(
162
162
  return impl.clearCrashHistory()
163
163
  }
164
164
 
165
+ override fun getBaseURL(): String {
166
+ val impl = getInstance()
167
+ return impl.getBaseURL()
168
+ }
169
+
165
170
  companion object {
166
171
  const val NAME = "HotUpdater"
167
172
  }
@@ -170,6 +170,12 @@ class HotUpdaterModule internal constructor(
170
170
  return impl.clearCrashHistory()
171
171
  }
172
172
 
173
+ @ReactMethod(isBlockingSynchronousMethod = true)
174
+ override fun getBaseURL(): String {
175
+ val impl = getInstance()
176
+ return impl.getBaseURL()
177
+ }
178
+
173
179
  companion object {
174
180
  const val NAME = "HotUpdater"
175
181
  }
@@ -20,4 +20,6 @@ abstract class HotUpdaterSpec internal constructor(
20
20
  abstract fun getCrashHistory(): String
21
21
 
22
22
  abstract fun clearCrashHistory(): Boolean
23
+
24
+ abstract fun getBaseURL(): String
23
25
  }