@hot-updater/react-native 0.23.0 → 0.24.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.
Files changed (91) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +393 -49
  2. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +204 -0
  3. package/android/src/main/java/com/hotupdater/HotUpdater.kt +48 -36
  4. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +134 -0
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +168 -95
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +15 -3
  7. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +17 -12
  8. package/android/src/newarch/HotUpdaterModule.kt +88 -23
  9. package/android/src/oldarch/HotUpdaterModule.kt +89 -22
  10. package/android/src/oldarch/HotUpdaterSpec.kt +6 -0
  11. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +401 -77
  12. package/ios/HotUpdater/Internal/BundleMetadata.swift +177 -0
  13. package/ios/HotUpdater/Internal/HotUpdater.mm +213 -47
  14. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +96 -25
  15. package/ios/HotUpdater/Internal/SignatureVerifier.swift +35 -29
  16. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +2 -2
  17. package/ios/HotUpdater/Public/HotUpdater.h +8 -2
  18. package/lib/commonjs/checkForUpdate.js +31 -28
  19. package/lib/commonjs/checkForUpdate.js.map +1 -1
  20. package/lib/commonjs/error.js +45 -1
  21. package/lib/commonjs/error.js.map +1 -1
  22. package/lib/commonjs/fetchUpdateInfo.js +7 -45
  23. package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
  24. package/lib/commonjs/index.js +237 -208
  25. package/lib/commonjs/index.js.map +1 -1
  26. package/lib/commonjs/native.js +103 -3
  27. package/lib/commonjs/native.js.map +1 -1
  28. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  29. package/lib/commonjs/wrap.js +39 -1
  30. package/lib/commonjs/wrap.js.map +1 -1
  31. package/lib/module/checkForUpdate.js +32 -26
  32. package/lib/module/checkForUpdate.js.map +1 -1
  33. package/lib/module/error.js +45 -0
  34. package/lib/module/error.js.map +1 -1
  35. package/lib/module/fetchUpdateInfo.js +7 -45
  36. package/lib/module/fetchUpdateInfo.js.map +1 -1
  37. package/lib/module/index.js +238 -203
  38. package/lib/module/index.js.map +1 -1
  39. package/lib/module/native.js +87 -2
  40. package/lib/module/native.js.map +1 -1
  41. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  42. package/lib/module/wrap.js +40 -2
  43. package/lib/module/wrap.js.map +1 -1
  44. package/lib/typescript/commonjs/checkForUpdate.d.ts +11 -13
  45. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/error.d.ts +120 -0
  47. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts +3 -5
  49. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/index.d.ts +35 -41
  51. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/native.d.ts +58 -2
  53. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +62 -0
  55. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/wrap.d.ts +76 -5
  57. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  58. package/lib/typescript/module/checkForUpdate.d.ts +11 -13
  59. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  60. package/lib/typescript/module/error.d.ts +120 -0
  61. package/lib/typescript/module/error.d.ts.map +1 -1
  62. package/lib/typescript/module/fetchUpdateInfo.d.ts +3 -5
  63. package/lib/typescript/module/fetchUpdateInfo.d.ts.map +1 -1
  64. package/lib/typescript/module/index.d.ts +35 -41
  65. package/lib/typescript/module/index.d.ts.map +1 -1
  66. package/lib/typescript/module/native.d.ts +58 -2
  67. package/lib/typescript/module/native.d.ts.map +1 -1
  68. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +62 -0
  69. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  70. package/lib/typescript/module/wrap.d.ts +76 -5
  71. package/lib/typescript/module/wrap.d.ts.map +1 -1
  72. package/package.json +8 -7
  73. package/plugin/build/withHotUpdater.js +55 -4
  74. package/src/checkForUpdate.ts +51 -40
  75. package/src/error.ts +153 -0
  76. package/src/fetchUpdateInfo.ts +10 -58
  77. package/src/index.ts +283 -206
  78. package/src/native.ts +88 -2
  79. package/src/specs/NativeHotUpdater.ts +63 -0
  80. package/src/wrap.tsx +131 -9
  81. package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +0 -52
  82. package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +0 -24
  83. package/lib/commonjs/runUpdateProcess.js +0 -69
  84. package/lib/commonjs/runUpdateProcess.js.map +0 -1
  85. package/lib/module/runUpdateProcess.js +0 -64
  86. package/lib/module/runUpdateProcess.js.map +0 -1
  87. package/lib/typescript/commonjs/runUpdateProcess.d.ts +0 -49
  88. package/lib/typescript/commonjs/runUpdateProcess.d.ts.map +0 -1
  89. package/lib/typescript/module/runUpdateProcess.d.ts +0 -49
  90. package/lib/typescript/module/runUpdateProcess.d.ts.map +0 -1
  91. package/src/runUpdateProcess.ts +0 -80
@@ -32,6 +32,7 @@ interface BundleStorageService {
32
32
 
33
33
  /**
34
34
  * Gets the URL to the bundle file (cached or fallback)
35
+ * With rollback support: checks for crashed staging bundles
35
36
  * @return The path to the bundle file
36
37
  */
37
38
  fun getBundleURL(): String
@@ -42,14 +43,33 @@ interface BundleStorageService {
42
43
  * @param fileUrl URL of the bundle file to download (or null to reset)
43
44
  * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
44
45
  * @param progressCallback Callback for download progress updates
45
- * @return true if the update was successful
46
+ * @throws HotUpdaterException if the update fails
46
47
  */
47
48
  suspend fun updateBundle(
48
49
  bundleId: String,
49
50
  fileUrl: String?,
50
51
  fileHash: String?,
51
52
  progressCallback: (Double) -> Unit,
52
- ): Boolean
53
+ )
54
+
55
+ /**
56
+ * Notifies that the app has started successfully with the current bundle
57
+ * @param currentBundleId The bundle ID that JS reports as currently loaded
58
+ * @return Map containing status and optional crashedBundleId
59
+ */
60
+ fun notifyAppReady(currentBundleId: String?): Map<String, Any?>
61
+
62
+ /**
63
+ * Gets the crashed bundle history
64
+ * @return CrashedHistory containing crashed bundles
65
+ */
66
+ fun getCrashHistory(): CrashedHistory
67
+
68
+ /**
69
+ * Clears the crashed bundle history
70
+ * @return true if clearing was successful
71
+ */
72
+ fun clearCrashHistory(): Boolean
53
73
  }
54
74
 
55
75
  /**
@@ -62,20 +82,219 @@ class BundleFileStorageService(
62
82
  private val decompressService: DecompressService,
63
83
  private val preferences: PreferencesService,
64
84
  ) : BundleStorageService {
85
+ companion object {
86
+ private const val TAG = "BundleStorage"
87
+ }
88
+
89
+ // Session-only rollback tracking (in-memory)
90
+ private var sessionRollbackBundleId: String? = null
91
+
92
+ // MARK: - Bundle Store Directory
93
+
94
+ private fun getBundleStoreDir(): File {
95
+ val baseDir = fileSystem.getExternalFilesDir()
96
+ return File(baseDir, "bundle-store")
97
+ }
98
+
99
+ private fun getMetadataFile(): File = File(getBundleStoreDir(), BundleMetadata.METADATA_FILENAME)
100
+
101
+ private fun getCrashedHistoryFile(): File = File(getBundleStoreDir(), CrashedHistory.CRASHED_HISTORY_FILENAME)
102
+
103
+ // MARK: - Metadata Operations
104
+
105
+ private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile())
106
+
107
+ private fun saveMetadata(metadata: BundleMetadata): Boolean = metadata.saveToFile(getMetadataFile())
108
+
109
+ private fun createInitialMetadata(): BundleMetadata {
110
+ val currentBundleId = extractBundleIdFromCurrentURL()
111
+ Log.d(TAG, "Creating initial metadata with stableBundleId: $currentBundleId")
112
+ return BundleMetadata(
113
+ stableBundleId = currentBundleId,
114
+ stagingBundleId = null,
115
+ verificationPending = false,
116
+ verificationAttemptedAt = null,
117
+ )
118
+ }
119
+
120
+ private fun extractBundleIdFromCurrentURL(): String? {
121
+ val currentUrl = preferences.getItem("HotUpdaterBundleURL") ?: return null
122
+ // "bundle-store/abc123/index.android.bundle" -> "abc123"
123
+ val regex = Regex("bundle-store/([^/]+)/")
124
+ return regex.find(currentUrl)?.groupValues?.get(1)
125
+ }
126
+
127
+ // MARK: - State Machine
128
+
129
+ private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
130
+
131
+ private fun wasVerificationAttempted(metadata: BundleMetadata): Boolean = metadata.verificationAttemptedAt != null
132
+
133
+ private fun markVerificationAttempted() {
134
+ val metadata = loadMetadataOrNull() ?: return
135
+ val updatedMetadata =
136
+ metadata.copy(
137
+ verificationAttemptedAt = System.currentTimeMillis(),
138
+ updatedAt = System.currentTimeMillis(),
139
+ )
140
+ saveMetadata(updatedMetadata)
141
+ Log.d(TAG, "Marked verification attempted at ${updatedMetadata.verificationAttemptedAt}")
142
+ }
143
+
144
+ private fun promoteStagingToStable() {
145
+ val metadata = loadMetadataOrNull() ?: return
146
+ val stagingBundleId = metadata.stagingBundleId ?: return
147
+
148
+ Log.d(TAG, "Promoting staging bundle $stagingBundleId to stable")
149
+
150
+ val updatedMetadata =
151
+ metadata.copy(
152
+ stableBundleId = stagingBundleId,
153
+ stagingBundleId = null,
154
+ verificationPending = false,
155
+ verificationAttemptedAt = null,
156
+ updatedAt = System.currentTimeMillis(),
157
+ )
158
+ saveMetadata(updatedMetadata)
159
+
160
+ // Update HotUpdaterBundleURL preference to point to stable bundle
161
+ val bundleStoreDir = getBundleStoreDir()
162
+ val stableBundleDir = File(bundleStoreDir, stagingBundleId)
163
+ val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
164
+ if (bundleFile != null) {
165
+ preferences.setItem("HotUpdaterBundleURL", bundleFile.absolutePath)
166
+ }
167
+
168
+ // Cleanup old bundles (keep only the new stable)
169
+ cleanupOldBundles(bundleStoreDir, null, stagingBundleId)
170
+ }
171
+
172
+ private fun rollbackToStable() {
173
+ val metadata = loadMetadataOrNull() ?: return
174
+ val stagingBundleId = metadata.stagingBundleId ?: return
175
+
176
+ Log.w(TAG, "Rolling back: adding $stagingBundleId to crashed history")
177
+
178
+ // Add to crashed history
179
+ val crashedHistory = loadCrashedHistory()
180
+ crashedHistory.addEntry(stagingBundleId)
181
+ saveCrashedHistory(crashedHistory)
182
+
183
+ // Save rollback info to session variable (memory only)
184
+ sessionRollbackBundleId = stagingBundleId
185
+
186
+ // Clear staging pointer
187
+ val updatedMetadata =
188
+ metadata.copy(
189
+ stagingBundleId = null,
190
+ verificationPending = false,
191
+ verificationAttemptedAt = null,
192
+ stagingExecutionCount = null,
193
+ updatedAt = System.currentTimeMillis(),
194
+ )
195
+ saveMetadata(updatedMetadata)
196
+
197
+ // Update bundle URL to point to stable bundle
198
+ val stableBundleId = updatedMetadata.stableBundleId
199
+ if (stableBundleId != null) {
200
+ val bundleStoreDir = getBundleStoreDir()
201
+ val stableBundleDir = File(bundleStoreDir, stableBundleId)
202
+ val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
203
+ if (bundleFile != null && bundleFile.exists()) {
204
+ setBundleURL(bundleFile.absolutePath)
205
+ Log.d(TAG, "Updated bundle URL to stable: $stableBundleId")
206
+ }
207
+ } else {
208
+ // No stable bundle available, clear bundle URL (fallback to assets)
209
+ setBundleURL(null)
210
+ Log.d(TAG, "Cleared bundle URL (no stable bundle)")
211
+ }
212
+
213
+ // Remove staging bundle directory
214
+ val bundleStoreDir = getBundleStoreDir()
215
+ val stagingBundleDir = File(bundleStoreDir, stagingBundleId)
216
+ if (stagingBundleDir.exists()) {
217
+ stagingBundleDir.deleteRecursively()
218
+ Log.d(TAG, "Deleted crashed staging bundle directory: $stagingBundleId")
219
+ }
220
+ }
221
+
222
+ // MARK: - Crashed History
223
+
224
+ private fun loadCrashedHistory(): CrashedHistory = CrashedHistory.loadFromFile(getCrashedHistoryFile())
225
+
226
+ private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
227
+
228
+ private fun isBundleInCrashedHistory(bundleId: String): Boolean = loadCrashedHistory().contains(bundleId)
229
+
230
+ override fun getCrashHistory(): CrashedHistory = loadCrashedHistory()
231
+
232
+ override fun clearCrashHistory(): Boolean {
233
+ val history = CrashedHistory()
234
+ saveCrashedHistory(history)
235
+ Log.d(TAG, "Cleared crash history")
236
+ return true
237
+ }
238
+
239
+ // MARK: - notifyAppReady
240
+
241
+ override fun notifyAppReady(currentBundleId: String?): Map<String, Any?> {
242
+ val metadata =
243
+ loadMetadataOrNull()
244
+ ?: return mapOf("status" to "STABLE")
245
+
246
+ // Check if there was a recent rollback (session variable)
247
+ sessionRollbackBundleId?.let { crashedBundleId ->
248
+ // Clear rollback info (one-time read)
249
+ sessionRollbackBundleId = null
250
+
251
+ Log.d(TAG, "notifyAppReady: recovered from rollback (crashed bundle: $crashedBundleId)")
252
+ return mapOf(
253
+ "status" to "RECOVERED",
254
+ "crashedBundleId" to crashedBundleId,
255
+ )
256
+ }
257
+
258
+ // Check for promotion
259
+ if (isVerificationPending(metadata)) {
260
+ val stagingBundleId = metadata.stagingBundleId
261
+ if (stagingBundleId != null && stagingBundleId == currentBundleId) {
262
+ Log.d(TAG, "App started successfully with staging bundle $currentBundleId, promoting to stable")
263
+ promoteStagingToStable()
264
+ return mapOf("status" to "PROMOTED")
265
+ } else {
266
+ Log.d(TAG, "notifyAppReady: bundleId mismatch (staging=$stagingBundleId, current=$currentBundleId)")
267
+ }
268
+ } else {
269
+ Log.d(TAG, "notifyAppReady: no verification pending")
270
+ }
271
+
272
+ // No changes
273
+ return mapOf("status" to "STABLE")
274
+ }
275
+
276
+ // MARK: - Bundle URL Operations
277
+
65
278
  override fun setBundleURL(localPath: String?): Boolean {
279
+ Log.d(TAG, "setBundleURL: $localPath")
66
280
  preferences.setItem("HotUpdaterBundleURL", localPath)
67
281
  return true
68
282
  }
69
283
 
70
284
  override fun getCachedBundleURL(): String? {
71
285
  val urlString = preferences.getItem("HotUpdaterBundleURL")
286
+ Log.d(TAG, "getCachedBundleURL: read from prefs = $urlString")
72
287
  if (urlString.isNullOrEmpty()) {
288
+ Log.d(TAG, "getCachedBundleURL: urlString is null or empty")
73
289
  return null
74
290
  }
75
291
 
76
292
  val file = File(urlString)
77
- if (!file.exists()) {
293
+ val exists = file.exists()
294
+ Log.d(TAG, "getCachedBundleURL: file exists = $exists at path: $urlString")
295
+ if (!exists) {
78
296
  preferences.setItem("HotUpdaterBundleURL", null)
297
+ Log.d(TAG, "getCachedBundleURL: file doesn't exist, cleared preference")
79
298
  return null
80
299
  }
81
300
  return urlString
@@ -83,63 +302,154 @@ class BundleFileStorageService(
83
302
 
84
303
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
85
304
 
86
- override fun getBundleURL(): String = getCachedBundleURL() ?: getFallbackBundleURL()
305
+ // Track if crash detection has already run in this process
306
+ private var crashDetectionCompleted = false
307
+
308
+ override fun getBundleURL(): String {
309
+ val metadata = loadMetadataOrNull()
310
+
311
+ if (metadata == null) {
312
+ // Legacy mode: no metadata.json exists, use existing behavior
313
+ val cached = getCachedBundleURL()
314
+ val result = cached ?: getFallbackBundleURL()
315
+ Log.d(TAG, "getBundleURL (legacy): returning $result")
316
+ return result
317
+ }
318
+
319
+ // New rollback-aware mode - only run crash detection ONCE per process
320
+ if (isVerificationPending(metadata) && !crashDetectionCompleted) {
321
+ crashDetectionCompleted = true
322
+
323
+ if (wasVerificationAttempted(metadata)) {
324
+ // Already executed once but didn't call notifyAppReady → crash!
325
+ Log.w(TAG, "Crash detected: staging bundle executed but didn't call notifyAppReady")
326
+ rollbackToStable()
327
+ } else {
328
+ // First execution - mark verification attempted and give it a chance
329
+ Log.d(TAG, "First execution of staging bundle, marking verification attempted")
330
+ markVerificationAttempted()
331
+ }
332
+ }
333
+
334
+ // Reload metadata after potential rollback
335
+ val currentMetadata = loadMetadataOrNull()
336
+
337
+ // Return staging bundle if verification pending
338
+ if (currentMetadata != null && isVerificationPending(currentMetadata)) {
339
+ val stagingId = currentMetadata.stagingBundleId
340
+ if (stagingId != null) {
341
+ val bundleStoreDir = getBundleStoreDir()
342
+ val stagingBundleDir = File(bundleStoreDir, stagingId)
343
+ val bundleFile = stagingBundleDir.walk().find { it.name == "index.android.bundle" }
344
+ if (bundleFile != null && bundleFile.exists()) {
345
+ Log.d(TAG, "getBundleURL: returning STAGING bundle $stagingId")
346
+ return bundleFile.absolutePath
347
+ } else {
348
+ Log.w(TAG, "getBundleURL: staging bundle file not found for $stagingId")
349
+ // Staging bundle file missing, rollback to stable
350
+ rollbackToStable()
351
+ }
352
+ }
353
+ }
354
+
355
+ // Return stable bundle URL
356
+ val stableBundleId = currentMetadata?.stableBundleId
357
+ if (stableBundleId != null) {
358
+ val bundleStoreDir = getBundleStoreDir()
359
+ val stableBundleDir = File(bundleStoreDir, stableBundleId)
360
+ val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
361
+ if (bundleFile != null && bundleFile.exists()) {
362
+ Log.d(TAG, "getBundleURL: returning stable bundle $stableBundleId")
363
+ return bundleFile.absolutePath
364
+ }
365
+ }
366
+
367
+ // Fallback
368
+ val cached = getCachedBundleURL()
369
+ val result = cached ?: getFallbackBundleURL()
370
+ Log.d(TAG, "getBundleURL: returning $result (cached=$cached)")
371
+ return result
372
+ }
87
373
 
88
374
  override suspend fun updateBundle(
89
375
  bundleId: String,
90
376
  fileUrl: String?,
91
377
  fileHash: String?,
92
378
  progressCallback: (Double) -> Unit,
93
- ): Boolean {
379
+ ) {
94
380
  Log.d(
95
- "BundleStorage",
381
+ TAG,
96
382
  "updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash",
97
383
  )
98
384
 
99
385
  // If no URL is provided, reset to fallback
100
386
  if (fileUrl.isNullOrEmpty()) {
101
387
  setBundleURL(null)
102
- return true
388
+ return
389
+ }
390
+
391
+ // Check if bundle is in crashed history
392
+ if (isBundleInCrashedHistory(bundleId)) {
393
+ Log.w(TAG, "Bundle $bundleId is in crashed history, rejecting update")
394
+ throw HotUpdaterException.bundleInCrashedHistory(bundleId)
103
395
  }
104
396
 
397
+ // Initialize metadata if it doesn't exist (lazy initialization)
398
+ val existingMetadata = loadMetadataOrNull()
399
+ val metadata =
400
+ existingMetadata ?: createInitialMetadata().also {
401
+ saveMetadata(it)
402
+ Log.d(TAG, "Created initial metadata during updateBundle")
403
+ }
404
+
105
405
  val baseDir = fileSystem.getExternalFilesDir()
106
- val bundleStoreDir = File(baseDir, "bundle-store")
406
+ val bundleStoreDir = getBundleStoreDir()
107
407
  if (!bundleStoreDir.exists()) {
108
408
  bundleStoreDir.mkdirs()
109
409
  }
110
410
 
111
- val currentBundleId =
112
- getCachedBundleURL()?.let { cachedUrl ->
113
- // Only consider cached bundles, not fallback bundles
114
- if (!cachedUrl.startsWith("assets://")) {
115
- File(cachedUrl).parentFile?.name
116
- } else {
117
- null
118
- }
119
- }
120
411
  val finalBundleDir = File(bundleStoreDir, bundleId)
121
412
  if (finalBundleDir.exists()) {
122
- Log.d("BundleStorage", "Bundle for bundleId $bundleId already exists. Using cached bundle.")
413
+ Log.d(TAG, "Bundle for bundleId $bundleId already exists. Using cached bundle.")
123
414
  val existingIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
124
415
  if (existingIndexFile != null) {
125
- // Update last modified time and set the cached bundle URL
416
+ // Update last modified time
126
417
  finalBundleDir.setLastModified(System.currentTimeMillis())
418
+
419
+ // Update metadata: set as staging
420
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
421
+ val updatedMetadata =
422
+ currentMetadata.copy(
423
+ stagingBundleId = bundleId,
424
+ verificationPending = true,
425
+ verificationAttemptedAt = null,
426
+ updatedAt = System.currentTimeMillis(),
427
+ )
428
+ saveMetadata(updatedMetadata)
429
+
430
+ // Set bundle URL for backwards compatibility
127
431
  setBundleURL(existingIndexFile.absolutePath)
128
- cleanupOldBundles(bundleStoreDir, currentBundleId, bundleId)
129
- return true
432
+
433
+ // Keep both stable and staging bundles
434
+ val stableBundleId = currentMetadata.stableBundleId
435
+ cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
436
+
437
+ Log.d(TAG, "Existing bundle set as staging, will be promoted after notifyAppReady")
438
+ return
130
439
  } else {
131
440
  // If index.android.bundle is missing, delete and re-download
132
441
  finalBundleDir.deleteRecursively()
133
442
  }
134
443
  }
135
444
 
136
- val tempDir = File(baseDir, "bundle-temp")
445
+ val tempDirName = "bundle-temp"
446
+ val tempDir = File(baseDir, tempDirName)
137
447
  if (tempDir.exists()) {
138
448
  tempDir.deleteRecursively()
139
449
  }
140
450
  tempDir.mkdirs()
141
451
 
142
- return withContext(Dispatchers.IO) {
452
+ withContext(Dispatchers.IO) {
143
453
  val downloadUrl = URL(fileUrl)
144
454
 
145
455
  // Determine bundle filename from URL
@@ -162,9 +472,8 @@ class BundleFileStorageService(
162
472
  Log.d("BundleStorage", "File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes")
163
473
 
164
474
  if (availableBytes < requiredSpace) {
165
- val errorMsg = "Insufficient disk space: need $requiredSpace bytes, available $availableBytes bytes"
166
- Log.d("BundleStorage", errorMsg)
167
- return@withContext false
475
+ Log.d("BundleStorage", "Insufficient disk space: need $requiredSpace bytes, available $availableBytes bytes")
476
+ throw HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
168
477
  }
169
478
  } else {
170
479
  Log.d("BundleStorage", "Unable to determine file size, proceeding with download")
@@ -184,7 +493,17 @@ class BundleFileStorageService(
184
493
  is DownloadResult.Error -> {
185
494
  Log.d("BundleStorage", "Download failed: ${downloadResult.exception.message}")
186
495
  tempDir.deleteRecursively()
187
- return@withContext false
496
+
497
+ // Check if this is an incomplete download error
498
+ if (downloadResult.exception is IncompleteDownloadException) {
499
+ val incompleteEx = downloadResult.exception as IncompleteDownloadException
500
+ throw HotUpdaterException.incompleteDownload(
501
+ incompleteEx.expectedSize,
502
+ incompleteEx.actualSize,
503
+ )
504
+ } else {
505
+ throw HotUpdaterException.downloadFailed(downloadResult.exception)
506
+ }
188
507
  }
189
508
 
190
509
  is DownloadResult.Success -> {
@@ -198,7 +517,7 @@ class BundleFileStorageService(
198
517
  Log.e("BundleStorage", "Bundle verification failed", e)
199
518
  tempDir.deleteRecursively()
200
519
  tempBundleFile.delete()
201
- return@withContext false
520
+ throw HotUpdaterException.signatureVerificationFailed(e)
202
521
  }
203
522
 
204
523
  // 2) Create a .tmp directory under bundle-store (to avoid colliding with an existing bundleId folder)
@@ -221,7 +540,7 @@ class BundleFileStorageService(
221
540
  Log.d("BundleStorage", "Failed to extract archive into tmpDir.")
222
541
  tempDir.deleteRecursively()
223
542
  tmpDir.deleteRecursively()
224
- return@withContext false
543
+ throw HotUpdaterException.extractionFormatError()
225
544
  }
226
545
 
227
546
  // 4) Find index.android.bundle inside tmpDir
@@ -230,7 +549,7 @@ class BundleFileStorageService(
230
549
  Log.d("BundleStorage", "index.android.bundle not found in tmpDir.")
231
550
  tempDir.deleteRecursively()
232
551
  tmpDir.deleteRecursively()
233
- return@withContext false
552
+ throw HotUpdaterException.invalidBundle()
234
553
  }
235
554
 
236
555
  // 5) Log extracted bundle file size
@@ -245,9 +564,20 @@ class BundleFileStorageService(
245
564
  // 7) Attempt to rename tmpDir → finalBundleDir (atomic within the same parent folder)
246
565
  val renamed = tmpDir.renameTo(finalBundleDir)
247
566
  if (!renamed) {
248
- // If rename fails, use moveItem or copyItem
567
+ // If rename fails, use moveItem as fallback
249
568
  if (!fileSystem.moveItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
250
- fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)
569
+ // If move also fails, try copy + delete as last resort
570
+ if (!fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
571
+ // All strategies failed
572
+ Log.e(
573
+ "BundleStorage",
574
+ "Failed to move bundle from tmpDir to finalBundleDir (rename, move, and copy all failed)",
575
+ )
576
+ tempDir.deleteRecursively()
577
+ tmpDir.deleteRecursively()
578
+ throw HotUpdaterException.moveOperationFailed()
579
+ }
580
+ // Copy succeeded, clean up tmpDir
251
581
  tmpDir.deleteRecursively()
252
582
  }
253
583
  }
@@ -258,26 +588,40 @@ class BundleFileStorageService(
258
588
  Log.d("BundleStorage", "index.android.bundle not found in realDir.")
259
589
  tempDir.deleteRecursively()
260
590
  finalBundleDir.deleteRecursively()
261
- return@withContext false
591
+ throw HotUpdaterException.invalidBundle()
262
592
  }
263
593
 
264
594
  // 9) Update finalBundleDir's last modified time
265
595
  finalBundleDir.setLastModified(System.currentTimeMillis())
266
596
 
267
- // 10) Save the new bundle path in Preferences
597
+ // 10) Save the new bundle as STAGING with verification pending
268
598
  val bundlePath = finalIndexFile.absolutePath
269
- Log.d("BundleStorage", "Setting bundle URL: $bundlePath")
599
+ Log.d(TAG, "Setting bundle as staging: $bundlePath")
600
+
601
+ // Update metadata: set new bundle as staging
602
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
603
+ val updatedMetadata =
604
+ currentMetadata.copy(
605
+ stagingBundleId = bundleId,
606
+ verificationPending = true,
607
+ verificationAttemptedAt = null,
608
+ updatedAt = System.currentTimeMillis(),
609
+ )
610
+ saveMetadata(updatedMetadata)
611
+
612
+ // Also update HotUpdaterBundleURL for backwards compatibility
613
+ // This will point to the staging bundle that will be loaded
270
614
  setBundleURL(bundlePath)
271
615
 
272
616
  // 11) Clean up temporary and download folders
273
617
  tempDir.deleteRecursively()
274
618
 
275
- // 12) Remove old bundles
276
- cleanupOldBundles(bundleStoreDir, currentBundleId, bundleId)
619
+ // 12) Keep both stable and staging bundles
620
+ val stableBundleId = currentMetadata.stableBundleId
621
+ cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
277
622
 
278
- Log.d("BundleStorage", "Downloaded and activated bundle successfully.")
623
+ Log.d(TAG, "Downloaded and set bundle as staging successfully. Will be promoted after notifyAppReady.")
279
624
  // Progress already at 1.0 from unzip completion
280
- return@withContext true
281
625
  }
282
626
  }
283
627
  }
@@ -305,17 +649,17 @@ class BundleFileStorageService(
305
649
  bundles.forEach { bundle ->
306
650
  try {
307
651
  if (bundle.name !in bundleIdsToKeep) {
308
- Log.d("BundleStorage", "Removing old bundle: ${bundle.name}")
652
+ Log.d(TAG, "Removing old bundle: ${bundle.name}")
309
653
  if (bundle.deleteRecursively()) {
310
- Log.d("BundleStorage", "Successfully removed old bundle: ${bundle.name}")
654
+ Log.d(TAG, "Successfully removed old bundle: ${bundle.name}")
311
655
  } else {
312
- Log.w("BundleStorage", "Failed to remove old bundle: ${bundle.name}")
656
+ Log.w(TAG, "Failed to remove old bundle: ${bundle.name}")
313
657
  }
314
658
  } else {
315
- Log.d("BundleStorage", "Keeping bundle: ${bundle.name}")
659
+ Log.d(TAG, "Keeping bundle: ${bundle.name}")
316
660
  }
317
661
  } catch (e: Exception) {
318
- Log.e("BundleStorage", "Error removing bundle ${bundle.name}: ${e.message}")
662
+ Log.e(TAG, "Error removing bundle ${bundle.name}: ${e.message}")
319
663
  }
320
664
  }
321
665
 
@@ -325,18 +669,18 @@ class BundleFileStorageService(
325
669
  file.isDirectory && file.name.endsWith(".tmp")
326
670
  }?.forEach { staleTmp ->
327
671
  try {
328
- Log.d("BundleStorage", "Removing stale tmp directory: ${staleTmp.name}")
672
+ Log.d(TAG, "Removing stale tmp directory: ${staleTmp.name}")
329
673
  if (staleTmp.deleteRecursively()) {
330
- Log.d("BundleStorage", "Successfully removed tmp directory: ${staleTmp.name}")
674
+ Log.d(TAG, "Successfully removed tmp directory: ${staleTmp.name}")
331
675
  } else {
332
- Log.w("BundleStorage", "Failed to remove tmp directory: ${staleTmp.name}")
676
+ Log.w(TAG, "Failed to remove tmp directory: ${staleTmp.name}")
333
677
  }
334
678
  } catch (e: Exception) {
335
- Log.e("BundleStorage", "Error removing tmp directory ${staleTmp.name}: ${e.message}")
679
+ Log.e(TAG, "Error removing tmp directory ${staleTmp.name}: ${e.message}")
336
680
  }
337
681
  }
338
682
  } catch (e: Exception) {
339
- Log.e("BundleStorage", "Error during cleanup: ${e.message}")
683
+ Log.e(TAG, "Error during cleanup: ${e.message}")
340
684
  }
341
685
  }
342
686
  }