@hot-updater/react-native 0.27.0 → 0.28.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 (62) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +3 -0
  3. package/android/src/main/AndroidManifestNew.xml +3 -0
  4. package/android/src/main/cpp/CMakeLists.txt +9 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +143 -0
  6. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +170 -204
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +39 -13
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  10. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  11. package/android/src/newarch/HotUpdaterModule.kt +2 -8
  12. package/android/src/oldarch/HotUpdaterModule.kt +2 -8
  13. package/android/src/oldarch/HotUpdaterSpec.kt +1 -1
  14. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +189 -203
  15. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  16. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +265 -11
  18. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  19. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  20. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +293 -9
  21. package/lib/commonjs/native.js +18 -21
  22. package/lib/commonjs/native.js.map +1 -1
  23. package/lib/commonjs/native.spec.js +86 -0
  24. package/lib/commonjs/native.spec.js.map +1 -0
  25. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  26. package/lib/commonjs/types.js.map +1 -1
  27. package/lib/commonjs/wrap.js +4 -5
  28. package/lib/commonjs/wrap.js.map +1 -1
  29. package/lib/module/native.js +17 -20
  30. package/lib/module/native.js.map +1 -1
  31. package/lib/module/native.spec.js +85 -0
  32. package/lib/module/native.spec.js.map +1 -0
  33. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  34. package/lib/module/types.js.map +1 -1
  35. package/lib/module/wrap.js +5 -6
  36. package/lib/module/wrap.js.map +1 -1
  37. package/lib/typescript/commonjs/native.d.ts +4 -15
  38. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/native.spec.d.ts +2 -0
  40. package/lib/typescript/commonjs/native.spec.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +4 -8
  42. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/types.d.ts +2 -3
  44. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/wrap.d.ts +2 -5
  46. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  47. package/lib/typescript/module/native.d.ts +4 -15
  48. package/lib/typescript/module/native.d.ts.map +1 -1
  49. package/lib/typescript/module/native.spec.d.ts +2 -0
  50. package/lib/typescript/module/native.spec.d.ts.map +1 -0
  51. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +4 -8
  52. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  53. package/lib/typescript/module/types.d.ts +2 -3
  54. package/lib/typescript/module/types.d.ts.map +1 -1
  55. package/lib/typescript/module/wrap.d.ts +2 -5
  56. package/lib/typescript/module/wrap.d.ts.map +1 -1
  57. package/package.json +6 -6
  58. package/src/native.spec.ts +84 -0
  59. package/src/native.ts +20 -19
  60. package/src/specs/NativeHotUpdater.ts +4 -6
  61. package/src/types.ts +2 -3
  62. package/src/wrap.tsx +7 -11
@@ -31,11 +31,11 @@ interface BundleStorageService {
31
31
  fun getFallbackBundleURL(): String
32
32
 
33
33
  /**
34
- * Gets the URL to the bundle file (cached or fallback)
35
- * With rollback support: checks for crashed staging bundles
36
- * @return The path to the bundle file
34
+ * Prepares the bundle launch for the current process.
35
+ * Applies any pending rollback decision from the previous launch and returns
36
+ * the bundle that should be loaded now.
37
37
  */
38
- fun getBundleURL(): String
38
+ fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection
39
39
 
40
40
  /**
41
41
  * Updates the bundle from the specified URL
@@ -53,11 +53,14 @@ interface BundleStorageService {
53
53
  )
54
54
 
55
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
56
+ * Marks the current launch as successful after the first content appeared.
59
57
  */
60
- fun notifyAppReady(currentBundleId: String?): Map<String, Any?>
58
+ fun markLaunchCompleted(currentBundleId: String?)
59
+
60
+ /**
61
+ * Returns the launch report for the current process.
62
+ */
63
+ fun notifyAppReady(): Map<String, Any?>
61
64
 
62
65
  /**
63
66
  * Gets the crashed bundle history
@@ -107,8 +110,7 @@ class BundleFileStorageService(
107
110
  checkAndCleanupIfIsolationKeyChanged()
108
111
  }
109
112
 
110
- // Session-only rollback tracking (in-memory)
111
- private var sessionRollbackBundleId: String? = null
113
+ private var currentLaunchReport: LaunchReport? = null
112
114
 
113
115
  // MARK: - Bundle Store Directory
114
116
 
@@ -121,6 +123,8 @@ class BundleFileStorageService(
121
123
 
122
124
  private fun getCrashedHistoryFile(): File = File(getBundleStoreDir(), CrashedHistory.CRASHED_HISTORY_FILENAME)
123
125
 
126
+ private fun getLaunchReportFile(): File = File(getBundleStoreDir(), LaunchReport.LAUNCH_REPORT_FILENAME)
127
+
124
128
  // MARK: - Metadata Operations
125
129
 
126
130
  private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
@@ -130,14 +134,30 @@ class BundleFileStorageService(
130
134
  return updatedMetadata.saveToFile(getMetadataFile())
131
135
  }
132
136
 
137
+ private fun loadLaunchReport(): LaunchReport? =
138
+ currentLaunchReport ?: LaunchReport.loadFromFile(getLaunchReportFile())?.also {
139
+ currentLaunchReport = it
140
+ }
141
+
142
+ private fun saveLaunchReport(report: LaunchReport?) {
143
+ currentLaunchReport = report
144
+ val file = getLaunchReportFile()
145
+ if (report == null) {
146
+ if (file.exists()) {
147
+ file.delete()
148
+ }
149
+ return
150
+ }
151
+ report.saveToFile(file)
152
+ }
153
+
133
154
  private fun createInitialMetadata(): BundleMetadata {
134
155
  val currentBundleId = extractBundleIdFromCurrentURL()
135
- Log.d(TAG, "Creating initial metadata with stableBundleId: $currentBundleId")
156
+ Log.d(TAG, "Creating initial metadata with stagingBundleId: $currentBundleId")
136
157
  return BundleMetadata(
137
- stableBundleId = currentBundleId,
138
- stagingBundleId = null,
158
+ stableBundleId = null,
159
+ stagingBundleId = currentBundleId,
139
160
  verificationPending = false,
140
- verificationAttemptedAt = null,
141
161
  )
142
162
  }
143
163
 
@@ -148,6 +168,20 @@ class BundleFileStorageService(
148
168
  return regex.find(currentUrl)?.groupValues?.get(1)
149
169
  }
150
170
 
171
+ private fun findBundleFile(bundleId: String): File? {
172
+ val bundleDir = File(getBundleStoreDir(), bundleId)
173
+ return bundleDir.walk().find { it.name == "index.android.bundle" && it.exists() }
174
+ }
175
+
176
+ private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
177
+
178
+ private fun getCurrentVerifiedBundleId(metadata: BundleMetadata): String? =
179
+ when {
180
+ metadata.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
181
+ metadata.stableBundleId != null -> metadata.stableBundleId
182
+ else -> null
183
+ }
184
+
151
185
  /**
152
186
  * Checks if isolationKey has changed and cleans up old bundles if needed.
153
187
  * This handles migration when isolationKey format changes.
@@ -213,95 +247,110 @@ class BundleFileStorageService(
213
247
 
214
248
  private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
215
249
 
216
- private fun wasVerificationAttempted(metadata: BundleMetadata): Boolean = metadata.verificationAttemptedAt != null
217
-
218
- private fun markVerificationAttempted() {
219
- val metadata = loadMetadataOrNull() ?: return
220
- val updatedMetadata =
221
- metadata.copy(
222
- verificationAttemptedAt = System.currentTimeMillis(),
223
- updatedAt = System.currentTimeMillis(),
224
- )
225
- saveMetadata(updatedMetadata)
226
- Log.d(TAG, "Marked verification attempted at ${updatedMetadata.verificationAttemptedAt}")
250
+ private fun prepareMetadataForNewStagingBundle(
251
+ metadata: BundleMetadata,
252
+ bundleId: String,
253
+ ): BundleMetadata {
254
+ val currentVerifiedBundleId =
255
+ getCurrentVerifiedBundleId(metadata)?.takeIf { it != bundleId }
256
+
257
+ return metadata.copy(
258
+ stableBundleId = currentVerifiedBundleId,
259
+ stagingBundleId = bundleId,
260
+ verificationPending = true,
261
+ updatedAt = System.currentTimeMillis(),
262
+ )
227
263
  }
228
264
 
229
- private fun promoteStagingToStable() {
230
- val metadata = loadMetadataOrNull() ?: return
231
- val stagingBundleId = metadata.stagingBundleId ?: return
265
+ private fun rollbackPendingBundle(stagingBundleId: String): Boolean {
266
+ val metadata = loadMetadataOrNull() ?: return false
267
+ if (metadata.stagingBundleId != stagingBundleId) {
268
+ return false
269
+ }
270
+
271
+ Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
232
272
 
233
- Log.d(TAG, "Promoting staging bundle $stagingBundleId to stable")
273
+ val crashedHistory = loadCrashedHistory()
274
+ crashedHistory.addEntry(stagingBundleId)
275
+ saveCrashedHistory(crashedHistory)
276
+
277
+ val fallbackBundleId =
278
+ metadata.stableBundleId?.takeIf { candidate ->
279
+ getBundleUrlForId(candidate) != null
280
+ }
234
281
 
235
282
  val updatedMetadata =
236
283
  metadata.copy(
237
- stableBundleId = stagingBundleId,
238
- stagingBundleId = null,
284
+ stableBundleId = null,
285
+ stagingBundleId = fallbackBundleId,
239
286
  verificationPending = false,
240
- verificationAttemptedAt = null,
241
287
  updatedAt = System.currentTimeMillis(),
242
288
  )
243
289
  saveMetadata(updatedMetadata)
244
290
 
245
- // Update HotUpdaterBundleURL preference to point to stable bundle
246
- val bundleStoreDir = getBundleStoreDir()
247
- val stableBundleDir = File(bundleStoreDir, stagingBundleId)
248
- val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
249
- if (bundleFile != null) {
250
- preferences.setItem("HotUpdaterBundleURL", bundleFile.absolutePath)
251
- }
291
+ val fallbackBundleUrl = fallbackBundleId?.let { getBundleUrlForId(it) }
292
+ setBundleURL(fallbackBundleUrl)
252
293
 
253
- // Cleanup old bundles (keep only the new stable)
254
- cleanupOldBundles(bundleStoreDir, null, stagingBundleId)
294
+ File(getBundleStoreDir(), stagingBundleId).deleteRecursively()
295
+ saveLaunchReport(LaunchReport(status = "RECOVERED", crashedBundleId = stagingBundleId))
296
+ return true
255
297
  }
256
298
 
257
- private fun rollbackToStable() {
299
+ private fun applyPendingRecoveryIfNeeded(pendingRecovery: PendingCrashRecovery?) {
258
300
  val metadata = loadMetadataOrNull() ?: return
259
301
  val stagingBundleId = metadata.stagingBundleId ?: return
260
302
 
261
- Log.w(TAG, "Rolling back: adding $stagingBundleId to crashed history")
262
-
263
- // Add to crashed history
264
- val crashedHistory = loadCrashedHistory()
265
- crashedHistory.addEntry(stagingBundleId)
266
- saveCrashedHistory(crashedHistory)
267
-
268
- // Save rollback info to session variable (memory only)
269
- sessionRollbackBundleId = stagingBundleId
303
+ if (pendingRecovery?.shouldRollback == true &&
304
+ pendingRecovery.launchedBundleId == stagingBundleId &&
305
+ isVerificationPending(metadata)
306
+ ) {
307
+ rollbackPendingBundle(stagingBundleId)
308
+ }
309
+ }
270
310
 
271
- // Clear staging pointer
272
- val updatedMetadata =
273
- metadata.copy(
274
- stagingBundleId = null,
275
- verificationPending = false,
276
- verificationAttemptedAt = null,
277
- stagingExecutionCount = null,
278
- updatedAt = System.currentTimeMillis(),
311
+ private fun selectLaunch(): LaunchSelection {
312
+ val metadata = loadMetadataOrNull()
313
+ if (metadata == null) {
314
+ val cached = getCachedBundleURL()
315
+ return LaunchSelection(
316
+ bundleUrl = cached ?: getFallbackBundleURL(),
317
+ launchedBundleId = extractBundleIdFromCurrentURL(),
318
+ shouldRollbackOnCrash = false,
279
319
  )
280
- saveMetadata(updatedMetadata)
320
+ }
281
321
 
282
- // Update bundle URL to point to stable bundle
283
- val stableBundleId = updatedMetadata.stableBundleId
284
- if (stableBundleId != null) {
285
- val bundleStoreDir = getBundleStoreDir()
286
- val stableBundleDir = File(bundleStoreDir, stableBundleId)
287
- val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
288
- if (bundleFile != null && bundleFile.exists()) {
289
- setBundleURL(bundleFile.absolutePath)
290
- Log.d(TAG, "Updated bundle URL to stable: $stableBundleId")
322
+ metadata.stagingBundleId?.let { stagingBundleId ->
323
+ val stagingBundleUrl = getBundleUrlForId(stagingBundleId)
324
+ if (stagingBundleUrl != null) {
325
+ return LaunchSelection(
326
+ bundleUrl = stagingBundleUrl,
327
+ launchedBundleId = stagingBundleId,
328
+ shouldRollbackOnCrash = metadata.verificationPending,
329
+ )
330
+ }
331
+
332
+ if (metadata.verificationPending && rollbackPendingBundle(stagingBundleId)) {
333
+ return selectLaunch()
291
334
  }
292
- } else {
293
- // No stable bundle available, clear bundle URL (fallback to assets)
294
- setBundleURL(null)
295
- Log.d(TAG, "Cleared bundle URL (no stable bundle)")
296
335
  }
297
336
 
298
- // Remove staging bundle directory
299
- val bundleStoreDir = getBundleStoreDir()
300
- val stagingBundleDir = File(bundleStoreDir, stagingBundleId)
301
- if (stagingBundleDir.exists()) {
302
- stagingBundleDir.deleteRecursively()
303
- Log.d(TAG, "Deleted crashed staging bundle directory: $stagingBundleId")
337
+ metadata.stableBundleId?.let { stableBundleId ->
338
+ val stableBundleUrl = getBundleUrlForId(stableBundleId)
339
+ if (stableBundleUrl != null) {
340
+ return LaunchSelection(
341
+ bundleUrl = stableBundleUrl,
342
+ launchedBundleId = stableBundleId,
343
+ shouldRollbackOnCrash = false,
344
+ )
345
+ }
304
346
  }
347
+
348
+ val cached = getCachedBundleURL()
349
+ return LaunchSelection(
350
+ bundleUrl = cached ?: getFallbackBundleURL(),
351
+ launchedBundleId = extractBundleIdFromCurrentURL(),
352
+ shouldRollbackOnCrash = false,
353
+ )
305
354
  }
306
355
 
307
356
  // MARK: - Crashed History
@@ -321,41 +370,29 @@ class BundleFileStorageService(
321
370
  return true
322
371
  }
323
372
 
324
- // MARK: - notifyAppReady
325
-
326
- override fun notifyAppReady(currentBundleId: String?): Map<String, Any?> {
327
- val metadata =
328
- loadMetadataOrNull()
329
- ?: return mapOf("status" to "STABLE")
330
-
331
- // Check if there was a recent rollback (session variable)
332
- sessionRollbackBundleId?.let { crashedBundleId ->
333
- // Clear rollback info (one-time read)
334
- sessionRollbackBundleId = null
335
-
336
- Log.d(TAG, "notifyAppReady: recovered from rollback (crashed bundle: $crashedBundleId)")
337
- return mapOf(
338
- "status" to "RECOVERED",
339
- "crashedBundleId" to crashedBundleId,
340
- )
373
+ override fun markLaunchCompleted(currentBundleId: String?) {
374
+ val metadata = loadMetadataOrNull() ?: return
375
+ val stagingBundleId = metadata.stagingBundleId ?: return
376
+ if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
377
+ return
341
378
  }
342
379
 
343
- // Check for promotion
344
- if (isVerificationPending(metadata)) {
345
- val stagingBundleId = metadata.stagingBundleId
346
- if (stagingBundleId != null && stagingBundleId == currentBundleId) {
347
- Log.d(TAG, "App started successfully with staging bundle $currentBundleId, promoting to stable")
348
- promoteStagingToStable()
349
- return mapOf("status" to "PROMOTED")
350
- } else {
351
- Log.d(TAG, "notifyAppReady: bundleId mismatch (staging=$stagingBundleId, current=$currentBundleId)")
352
- }
353
- } else {
354
- Log.d(TAG, "notifyAppReady: no verification pending")
355
- }
380
+ saveMetadata(
381
+ metadata.copy(
382
+ verificationPending = false,
383
+ updatedAt = System.currentTimeMillis(),
384
+ ),
385
+ )
386
+ }
387
+
388
+ // MARK: - notifyAppReady
356
389
 
357
- // No changes
358
- return mapOf("status" to "STABLE")
390
+ override fun notifyAppReady(): Map<String, Any?> {
391
+ val report = loadLaunchReport() ?: return mapOf("status" to "STABLE")
392
+ return buildMap {
393
+ put("status", report.status)
394
+ report.crashedBundleId?.let { put("crashedBundleId", it) }
395
+ }
359
396
  }
360
397
 
361
398
  // MARK: - Bundle URL Operations
@@ -387,73 +424,16 @@ class BundleFileStorageService(
387
424
 
388
425
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
389
426
 
390
- // Track if crash detection has already run in this process
391
- private var crashDetectionCompleted = false
427
+ override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
428
+ saveLaunchReport(null)
429
+ applyPendingRecoveryIfNeeded(pendingRecovery)
392
430
 
393
- override fun getBundleURL(): String {
394
- val metadata = loadMetadataOrNull()
395
-
396
- if (metadata == null) {
397
- // Legacy mode: no metadata.json exists, use existing behavior
398
- val cached = getCachedBundleURL()
399
- val result = cached ?: getFallbackBundleURL()
400
- Log.d(TAG, "getBundleURL (legacy): returning $result")
401
- return result
402
- }
403
-
404
- // New rollback-aware mode - only run crash detection ONCE per process
405
- if (isVerificationPending(metadata) && !crashDetectionCompleted) {
406
- crashDetectionCompleted = true
407
-
408
- if (wasVerificationAttempted(metadata)) {
409
- // Already executed once but didn't call notifyAppReady → crash!
410
- Log.w(TAG, "Crash detected: staging bundle executed but didn't call notifyAppReady")
411
- rollbackToStable()
412
- } else {
413
- // First execution - mark verification attempted and give it a chance
414
- Log.d(TAG, "First execution of staging bundle, marking verification attempted")
415
- markVerificationAttempted()
416
- }
417
- }
418
-
419
- // Reload metadata after potential rollback
420
- val currentMetadata = loadMetadataOrNull()
421
-
422
- // Return staging bundle if verification pending
423
- if (currentMetadata != null && isVerificationPending(currentMetadata)) {
424
- val stagingId = currentMetadata.stagingBundleId
425
- if (stagingId != null) {
426
- val bundleStoreDir = getBundleStoreDir()
427
- val stagingBundleDir = File(bundleStoreDir, stagingId)
428
- val bundleFile = stagingBundleDir.walk().find { it.name == "index.android.bundle" }
429
- if (bundleFile != null && bundleFile.exists()) {
430
- Log.d(TAG, "getBundleURL: returning STAGING bundle $stagingId")
431
- return bundleFile.absolutePath
432
- } else {
433
- Log.w(TAG, "getBundleURL: staging bundle file not found for $stagingId")
434
- // Staging bundle file missing, rollback to stable
435
- rollbackToStable()
436
- }
437
- }
438
- }
439
-
440
- // Return stable bundle URL
441
- val stableBundleId = currentMetadata?.stableBundleId
442
- if (stableBundleId != null) {
443
- val bundleStoreDir = getBundleStoreDir()
444
- val stableBundleDir = File(bundleStoreDir, stableBundleId)
445
- val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
446
- if (bundleFile != null && bundleFile.exists()) {
447
- Log.d(TAG, "getBundleURL: returning stable bundle $stableBundleId")
448
- return bundleFile.absolutePath
449
- }
450
- }
451
-
452
- // Fallback
453
- val cached = getCachedBundleURL()
454
- val result = cached ?: getFallbackBundleURL()
455
- Log.d(TAG, "getBundleURL: returning $result (cached=$cached)")
456
- return result
431
+ val selection = selectLaunch()
432
+ Log.d(
433
+ TAG,
434
+ "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
435
+ )
436
+ return selection
457
437
  }
458
438
 
459
439
  override suspend fun updateBundle(
@@ -525,23 +505,16 @@ class BundleFileStorageService(
525
505
 
526
506
  // Update metadata: set as staging
527
507
  val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
528
- val updatedMetadata =
529
- currentMetadata.copy(
530
- stagingBundleId = bundleId,
531
- verificationPending = true,
532
- verificationAttemptedAt = null,
533
- updatedAt = System.currentTimeMillis(),
534
- )
508
+ val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
535
509
  saveMetadata(updatedMetadata)
536
510
 
537
511
  // Set bundle URL for backwards compatibility
538
512
  setBundleURL(existingIndexFile.absolutePath)
539
513
 
540
- // Keep both stable and staging bundles
541
- val stableBundleId = currentMetadata.stableBundleId
542
- cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
514
+ // Keep the current verified bundle as a fallback if one exists.
515
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
543
516
 
544
- Log.d(TAG, "Existing bundle set as staging, will be promoted after notifyAppReady")
517
+ Log.d(TAG, "Existing bundle set as staging bundle for next launch")
545
518
  return
546
519
  } else {
547
520
  // If index.android.bundle is missing, delete and re-download
@@ -721,13 +694,7 @@ class BundleFileStorageService(
721
694
 
722
695
  // Update metadata: set new bundle as staging
723
696
  val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
724
- val updatedMetadata =
725
- currentMetadata.copy(
726
- stagingBundleId = bundleId,
727
- verificationPending = true,
728
- verificationAttemptedAt = null,
729
- updatedAt = System.currentTimeMillis(),
730
- )
697
+ val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
731
698
  saveMetadata(updatedMetadata)
732
699
 
733
700
  // Also update HotUpdaterBundleURL for backwards compatibility
@@ -737,11 +704,10 @@ class BundleFileStorageService(
737
704
  // 11) Clean up temporary and download folders
738
705
  tempDir.deleteRecursively()
739
706
 
740
- // 12) Keep both stable and staging bundles
741
- val stableBundleId = currentMetadata.stableBundleId
742
- cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
707
+ // 12) Keep the fallback bundle and the new staging bundle.
708
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
743
709
 
744
- Log.d(TAG, "Downloaded and set bundle as staging successfully. Will be promoted after notifyAppReady.")
710
+ Log.d(TAG, "Downloaded and set bundle as staging successfully for the next launch.")
745
711
  // Progress already at 1.0 from unzip completion
746
712
  }
747
713
  }
@@ -816,8 +782,7 @@ class BundleFileStorageService(
816
782
  val metadata = loadMetadataOrNull()
817
783
  val activeBundleId =
818
784
  when {
819
- metadata?.verificationPending == true && metadata.stagingBundleId != null ->
820
- metadata.stagingBundleId
785
+ metadata?.stagingBundleId != null -> metadata.stagingBundleId
821
786
  metadata?.stableBundleId != null -> metadata.stableBundleId
822
787
  else -> extractBundleIdFromCurrentURL()
823
788
  }
@@ -848,18 +813,19 @@ class BundleFileStorageService(
848
813
  stableBundleId = null,
849
814
  stagingBundleId = null,
850
815
  verificationPending = false,
851
- verificationAttemptedAt = null,
852
- stagingExecutionCount = null,
853
816
  )
854
817
 
855
818
  if (!saveMetadata(clearedMetadata)) {
856
819
  return@withContext false
857
820
  }
858
821
 
822
+ saveLaunchReport(null)
823
+
859
824
  getBundleStoreDir().listFiles()?.forEach { file ->
860
825
  if (
861
826
  file.name == BundleMetadata.METADATA_FILENAME ||
862
- file.name == CrashedHistory.CRASHED_HISTORY_FILENAME
827
+ file.name == CrashedHistory.CRASHED_HISTORY_FILENAME ||
828
+ file.name == LaunchReport.LAUNCH_REPORT_FILENAME
863
829
  ) {
864
830
  return@forEach
865
831
  }
@@ -14,8 +14,6 @@ data class BundleMetadata(
14
14
  val stableBundleId: String? = null,
15
15
  val stagingBundleId: String? = null,
16
16
  val verificationPending: Boolean = false,
17
- val verificationAttemptedAt: Long? = null,
18
- val stagingExecutionCount: Int? = null,
19
17
  val updatedAt: Long = System.currentTimeMillis(),
20
18
  ) {
21
19
  companion object {
@@ -45,18 +43,6 @@ data class BundleMetadata(
45
43
  null
46
44
  },
47
45
  verificationPending = json.optBoolean("verificationPending", false),
48
- verificationAttemptedAt =
49
- if (json.has("verificationAttemptedAt") && !json.isNull("verificationAttemptedAt")) {
50
- json.getLong("verificationAttemptedAt")
51
- } else {
52
- null
53
- },
54
- stagingExecutionCount =
55
- if (json.has("stagingExecutionCount") && !json.isNull("stagingExecutionCount")) {
56
- json.getInt("stagingExecutionCount")
57
- } else {
58
- null
59
- },
60
46
  updatedAt = json.optLong("updatedAt", System.currentTimeMillis()),
61
47
  )
62
48
 
@@ -100,8 +86,6 @@ data class BundleMetadata(
100
86
  put("stableBundleId", stableBundleId ?: JSONObject.NULL)
101
87
  put("stagingBundleId", stagingBundleId ?: JSONObject.NULL)
102
88
  put("verificationPending", verificationPending)
103
- put("verificationAttemptedAt", verificationAttemptedAt ?: JSONObject.NULL)
104
- put("stagingExecutionCount", stagingExecutionCount ?: JSONObject.NULL)
105
89
  put("updatedAt", updatedAt)
106
90
  }
107
91
 
@@ -237,3 +221,76 @@ data class CrashedHistory(
237
221
  bundles.clear()
238
222
  }
239
223
  }
224
+
225
+ data class PendingCrashRecovery(
226
+ val launchedBundleId: String?,
227
+ val shouldRollback: Boolean,
228
+ ) {
229
+ companion object {
230
+ fun fromJson(json: JSONObject): PendingCrashRecovery =
231
+ PendingCrashRecovery(
232
+ launchedBundleId =
233
+ if (json.has("bundleId") && !json.isNull("bundleId")) {
234
+ json.getString("bundleId").takeIf { it.isNotEmpty() }
235
+ } else {
236
+ null
237
+ },
238
+ shouldRollback = json.optBoolean("shouldRollback", false),
239
+ )
240
+ }
241
+ }
242
+
243
+ data class LaunchSelection(
244
+ val bundleUrl: String,
245
+ val launchedBundleId: String?,
246
+ val shouldRollbackOnCrash: Boolean,
247
+ )
248
+
249
+ data class LaunchReport(
250
+ val status: String = "STABLE",
251
+ val crashedBundleId: String? = null,
252
+ ) {
253
+ companion object {
254
+ private const val TAG = "LaunchReport"
255
+ const val LAUNCH_REPORT_FILENAME = "launch-report.json"
256
+
257
+ fun fromJson(json: JSONObject): LaunchReport =
258
+ LaunchReport(
259
+ status = json.optString("status", "STABLE"),
260
+ crashedBundleId =
261
+ if (json.has("crashedBundleId") && !json.isNull("crashedBundleId")) {
262
+ json.getString("crashedBundleId").takeIf { it.isNotEmpty() }
263
+ } else {
264
+ null
265
+ },
266
+ )
267
+
268
+ fun loadFromFile(file: File): LaunchReport? =
269
+ try {
270
+ if (!file.exists()) {
271
+ null
272
+ } else {
273
+ fromJson(JSONObject(file.readText()))
274
+ }
275
+ } catch (e: Exception) {
276
+ Log.e(TAG, "Failed to load launch report", e)
277
+ null
278
+ }
279
+ }
280
+
281
+ fun toJson(): JSONObject =
282
+ JSONObject().apply {
283
+ put("status", status)
284
+ put("crashedBundleId", crashedBundleId ?: JSONObject.NULL)
285
+ }
286
+
287
+ fun saveToFile(file: File): Boolean =
288
+ try {
289
+ file.parentFile?.mkdirs()
290
+ file.writeText(toJson().toString(2))
291
+ true
292
+ } catch (e: Exception) {
293
+ Log.e(TAG, "Failed to save launch report", e)
294
+ false
295
+ }
296
+ }