@hot-updater/react-native 0.27.1 → 0.29.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 (87) 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 +325 -210
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
  9. package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
  10. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +51 -13
  12. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  13. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  14. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
  15. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
  16. package/android/src/newarch/HotUpdaterModule.kt +16 -25
  17. package/android/src/oldarch/HotUpdaterModule.kt +20 -26
  18. package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +340 -232
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  21. package/ios/HotUpdater/Internal/CohortService.swift +63 -0
  22. package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
  23. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  24. package/ios/HotUpdater/Internal/HotUpdater.mm +376 -70
  25. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  26. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  27. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +321 -9
  28. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
  29. package/lib/commonjs/DefaultResolver.js +3 -5
  30. package/lib/commonjs/DefaultResolver.js.map +1 -1
  31. package/lib/commonjs/checkForUpdate.js +2 -0
  32. package/lib/commonjs/checkForUpdate.js.map +1 -1
  33. package/lib/commonjs/index.js +13 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/native.js +211 -39
  36. package/lib/commonjs/native.js.map +1 -1
  37. package/lib/commonjs/native.spec.js +443 -0
  38. package/lib/commonjs/native.spec.js.map +1 -0
  39. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  40. package/lib/commonjs/types.js.map +1 -1
  41. package/lib/commonjs/wrap.js +4 -5
  42. package/lib/commonjs/wrap.js.map +1 -1
  43. package/lib/module/DefaultResolver.js +3 -5
  44. package/lib/module/DefaultResolver.js.map +1 -1
  45. package/lib/module/checkForUpdate.js +3 -1
  46. package/lib/module/checkForUpdate.js.map +1 -1
  47. package/lib/module/index.js +14 -1
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/native.js +204 -34
  50. package/lib/module/native.js.map +1 -1
  51. package/lib/module/native.spec.js +442 -0
  52. package/lib/module/native.spec.js.map +1 -0
  53. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  54. package/lib/module/types.js.map +1 -1
  55. package/lib/module/wrap.js +5 -6
  56. package/lib/module/wrap.js.map +1 -1
  57. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/index.d.ts +14 -1
  59. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/native.d.ts +43 -23
  61. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +32 -8
  63. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/types.d.ts +6 -3
  65. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/wrap.d.ts +3 -6
  67. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  68. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  69. package/lib/typescript/module/index.d.ts +14 -1
  70. package/lib/typescript/module/index.d.ts.map +1 -1
  71. package/lib/typescript/module/native.d.ts +43 -23
  72. package/lib/typescript/module/native.d.ts.map +1 -1
  73. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +32 -8
  74. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  75. package/lib/typescript/module/types.d.ts +6 -3
  76. package/lib/typescript/module/types.d.ts.map +1 -1
  77. package/lib/typescript/module/wrap.d.ts +3 -6
  78. package/lib/typescript/module/wrap.d.ts.map +1 -1
  79. package/package.json +6 -6
  80. package/src/DefaultResolver.ts +4 -4
  81. package/src/checkForUpdate.ts +4 -0
  82. package/src/index.ts +21 -0
  83. package/src/native.spec.ts +480 -0
  84. package/src/native.ts +285 -39
  85. package/src/specs/NativeHotUpdater.ts +36 -6
  86. package/src/types.ts +7 -3
  87. package/src/wrap.tsx +8 -12
@@ -4,6 +4,7 @@ import android.os.StatFs
4
4
  import android.util.Log
5
5
  import kotlinx.coroutines.Dispatchers
6
6
  import kotlinx.coroutines.withContext
7
+ import org.json.JSONObject
7
8
  import java.io.File
8
9
  import java.net.URL
9
10
 
@@ -31,11 +32,11 @@ interface BundleStorageService {
31
32
  fun getFallbackBundleURL(): String
32
33
 
33
34
  /**
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
35
+ * Prepares the bundle launch for the current process.
36
+ * Applies any pending rollback decision from the previous launch and returns
37
+ * the bundle that should be loaded now.
37
38
  */
38
- fun getBundleURL(): String
39
+ fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection
39
40
 
40
41
  /**
41
42
  * Updates the bundle from the specified URL
@@ -53,11 +54,14 @@ interface BundleStorageService {
53
54
  )
54
55
 
55
56
  /**
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
57
+ * Marks the current launch as successful after the first content appeared.
59
58
  */
60
- fun notifyAppReady(currentBundleId: String?): Map<String, Any?>
59
+ fun markLaunchCompleted(currentBundleId: String?)
60
+
61
+ /**
62
+ * Returns the launch report for the current process.
63
+ */
64
+ fun notifyAppReady(): Map<String, Any?>
61
65
 
62
66
  /**
63
67
  * Gets the crashed bundle history
@@ -77,6 +81,18 @@ interface BundleStorageService {
77
81
  */
78
82
  fun getBaseURL(): String
79
83
 
84
+ /**
85
+ * Gets the current active bundle ID from bundle storage.
86
+ * Reads manifest.json first and falls back to older metadata when needed.
87
+ */
88
+ fun getBundleId(): String?
89
+
90
+ /**
91
+ * Gets the current manifest from bundle storage.
92
+ * Returns an empty map when manifest.json is missing or invalid.
93
+ */
94
+ fun getManifest(): Map<String, Any?>
95
+
80
96
  /**
81
97
  * Restores the original bundle and clears downloaded bundle state.
82
98
  * @return true if the reset was successful
@@ -99,6 +115,12 @@ class BundleFileStorageService(
99
115
  private const val TAG = "BundleStorage"
100
116
  }
101
117
 
118
+ private data class ActiveBundleMetadataSnapshot(
119
+ val activeBundleId: String,
120
+ val bundleId: String?,
121
+ val manifest: Map<String, Any?>,
122
+ )
123
+
102
124
  init {
103
125
  // Ensure bundle store directory exists
104
126
  getBundleStoreDir().mkdirs()
@@ -107,8 +129,11 @@ class BundleFileStorageService(
107
129
  checkAndCleanupIfIsolationKeyChanged()
108
130
  }
109
131
 
110
- // Session-only rollback tracking (in-memory)
111
- private var sessionRollbackBundleId: String? = null
132
+ private var currentLaunchReport: LaunchReport? = null
133
+
134
+ @Volatile
135
+ private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
136
+ private val activeBundleMetadataLock = Any()
112
137
 
113
138
  // MARK: - Bundle Store Directory
114
139
 
@@ -121,6 +146,8 @@ class BundleFileStorageService(
121
146
 
122
147
  private fun getCrashedHistoryFile(): File = File(getBundleStoreDir(), CrashedHistory.CRASHED_HISTORY_FILENAME)
123
148
 
149
+ private fun getLaunchReportFile(): File = File(getBundleStoreDir(), LaunchReport.LAUNCH_REPORT_FILENAME)
150
+
124
151
  // MARK: - Metadata Operations
125
152
 
126
153
  private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
@@ -130,14 +157,30 @@ class BundleFileStorageService(
130
157
  return updatedMetadata.saveToFile(getMetadataFile())
131
158
  }
132
159
 
160
+ private fun loadLaunchReport(): LaunchReport? =
161
+ currentLaunchReport ?: LaunchReport.loadFromFile(getLaunchReportFile())?.also {
162
+ currentLaunchReport = it
163
+ }
164
+
165
+ private fun saveLaunchReport(report: LaunchReport?) {
166
+ currentLaunchReport = report
167
+ val file = getLaunchReportFile()
168
+ if (report == null) {
169
+ if (file.exists()) {
170
+ file.delete()
171
+ }
172
+ return
173
+ }
174
+ report.saveToFile(file)
175
+ }
176
+
133
177
  private fun createInitialMetadata(): BundleMetadata {
134
178
  val currentBundleId = extractBundleIdFromCurrentURL()
135
- Log.d(TAG, "Creating initial metadata with stableBundleId: $currentBundleId")
179
+ Log.d(TAG, "Creating initial metadata with stagingBundleId: $currentBundleId")
136
180
  return BundleMetadata(
137
- stableBundleId = currentBundleId,
138
- stagingBundleId = null,
181
+ stableBundleId = null,
182
+ stagingBundleId = currentBundleId,
139
183
  verificationPending = false,
140
- verificationAttemptedAt = null,
141
184
  )
142
185
  }
143
186
 
@@ -148,6 +191,135 @@ class BundleFileStorageService(
148
191
  return regex.find(currentUrl)?.groupValues?.get(1)
149
192
  }
150
193
 
194
+ private fun findBundleFile(bundleId: String): File? {
195
+ val bundleDir = File(getBundleStoreDir(), bundleId)
196
+ return bundleDir.walk().find { it.name == "index.android.bundle" && it.exists() }
197
+ }
198
+
199
+ private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
200
+
201
+ private fun getCurrentVerifiedBundleId(metadata: BundleMetadata): String? =
202
+ when {
203
+ metadata.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
204
+ metadata.stableBundleId != null -> metadata.stableBundleId
205
+ else -> null
206
+ }
207
+
208
+ private fun getActiveBundleId(): String? {
209
+ val metadata = loadMetadataOrNull()
210
+ return when {
211
+ metadata?.stagingBundleId != null -> metadata.stagingBundleId
212
+ metadata?.stableBundleId != null -> metadata.stableBundleId
213
+ else -> extractBundleIdFromCurrentURL()
214
+ }
215
+ }
216
+
217
+ private fun getActiveBundleMetadataSnapshot(): ActiveBundleMetadataSnapshot? {
218
+ val activeBundleId =
219
+ getActiveBundleId() ?: run {
220
+ clearActiveBundleMetadataSnapshot()
221
+ return null
222
+ }
223
+
224
+ activeBundleMetadataSnapshot
225
+ ?.takeIf { it.activeBundleId == activeBundleId }
226
+ ?.let { return it }
227
+
228
+ synchronized(activeBundleMetadataLock) {
229
+ activeBundleMetadataSnapshot
230
+ ?.takeIf { it.activeBundleId == activeBundleId }
231
+ ?.let { return it }
232
+
233
+ val bundleDir = File(getBundleStoreDir(), activeBundleId)
234
+ if (!bundleDir.exists()) {
235
+ activeBundleMetadataSnapshot = null
236
+ return null
237
+ }
238
+
239
+ return resolveActiveBundleMetadataSnapshot(bundleDir).also {
240
+ activeBundleMetadataSnapshot = it
241
+ }
242
+ }
243
+ }
244
+
245
+ private fun clearActiveBundleMetadataSnapshot() {
246
+ synchronized(activeBundleMetadataLock) {
247
+ activeBundleMetadataSnapshot = null
248
+ }
249
+ }
250
+
251
+ private fun resolveActiveBundleMetadataSnapshot(bundleDir: File): ActiveBundleMetadataSnapshot {
252
+ val manifest = readManifestFromBundleDir(bundleDir) ?: emptyMap()
253
+ val manifestBundleId =
254
+ (manifest["bundleId"] as? String)
255
+ ?.trim()
256
+ ?.takeIf { it.isNotEmpty() }
257
+
258
+ return ActiveBundleMetadataSnapshot(
259
+ activeBundleId = bundleDir.name,
260
+ bundleId = manifestBundleId ?: readCompatibilityBundleIdFromBundleDir(bundleDir),
261
+ manifest = manifest,
262
+ )
263
+ }
264
+
265
+ private fun readCompatibilityBundleIdFromBundleDir(bundleDir: File): String? {
266
+ val compatibilityBundleIdFile = File(bundleDir, compatibilityBundleIdFilename())
267
+ if (!compatibilityBundleIdFile.exists()) {
268
+ return null
269
+ }
270
+
271
+ return try {
272
+ compatibilityBundleIdFile.readText().trim().takeIf { it.isNotEmpty() }
273
+ } catch (e: Exception) {
274
+ Log.w(
275
+ TAG,
276
+ "Failed to read compatibility bundle metadata from ${compatibilityBundleIdFile.absolutePath}: ${e.message}",
277
+ )
278
+ null
279
+ }
280
+ }
281
+
282
+ private fun compatibilityBundleIdFilename(): String = "BUNDLE_ID"
283
+
284
+ private fun readManifestFromBundleDir(bundleDir: File): Map<String, Any?>? {
285
+ val manifestFile = File(bundleDir, "manifest.json")
286
+ if (!manifestFile.exists()) {
287
+ return null
288
+ }
289
+
290
+ return try {
291
+ JSONObject(manifestFile.readText()).let(::jsonObjectToMap)
292
+ } catch (e: Exception) {
293
+ Log.w(TAG, "Failed to read manifest from ${manifestFile.absolutePath}: ${e.message}")
294
+ null
295
+ }
296
+ }
297
+
298
+ private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any?> {
299
+ val result = linkedMapOf<String, Any?>()
300
+ val keys = jsonObject.keys()
301
+
302
+ while (keys.hasNext()) {
303
+ val key = keys.next()
304
+ result[key] = jsonValueToKotlin(jsonObject.opt(key))
305
+ }
306
+
307
+ return result
308
+ }
309
+
310
+ private fun jsonArrayToList(jsonArray: org.json.JSONArray): List<Any?> =
311
+ List(jsonArray.length()) { index ->
312
+ jsonValueToKotlin(jsonArray.opt(index))
313
+ }
314
+
315
+ private fun jsonValueToKotlin(value: Any?): Any? =
316
+ when (value) {
317
+ JSONObject.NULL -> null
318
+ is JSONObject -> jsonObjectToMap(value)
319
+ is org.json.JSONArray -> jsonArrayToList(value)
320
+ else -> value
321
+ }
322
+
151
323
  /**
152
324
  * Checks if isolationKey has changed and cleans up old bundles if needed.
153
325
  * This handles migration when isolationKey format changes.
@@ -213,95 +385,110 @@ class BundleFileStorageService(
213
385
 
214
386
  private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
215
387
 
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}")
388
+ private fun prepareMetadataForNewStagingBundle(
389
+ metadata: BundleMetadata,
390
+ bundleId: String,
391
+ ): BundleMetadata {
392
+ val currentVerifiedBundleId =
393
+ getCurrentVerifiedBundleId(metadata)?.takeIf { it != bundleId }
394
+
395
+ return metadata.copy(
396
+ stableBundleId = currentVerifiedBundleId,
397
+ stagingBundleId = bundleId,
398
+ verificationPending = true,
399
+ updatedAt = System.currentTimeMillis(),
400
+ )
227
401
  }
228
402
 
229
- private fun promoteStagingToStable() {
230
- val metadata = loadMetadataOrNull() ?: return
231
- val stagingBundleId = metadata.stagingBundleId ?: return
403
+ private fun rollbackPendingBundle(stagingBundleId: String): Boolean {
404
+ val metadata = loadMetadataOrNull() ?: return false
405
+ if (metadata.stagingBundleId != stagingBundleId) {
406
+ return false
407
+ }
408
+
409
+ Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
410
+
411
+ val crashedHistory = loadCrashedHistory()
412
+ crashedHistory.addEntry(stagingBundleId)
413
+ saveCrashedHistory(crashedHistory)
232
414
 
233
- Log.d(TAG, "Promoting staging bundle $stagingBundleId to stable")
415
+ val fallbackBundleId =
416
+ metadata.stableBundleId?.takeIf { candidate ->
417
+ getBundleUrlForId(candidate) != null
418
+ }
234
419
 
235
420
  val updatedMetadata =
236
421
  metadata.copy(
237
- stableBundleId = stagingBundleId,
238
- stagingBundleId = null,
422
+ stableBundleId = null,
423
+ stagingBundleId = fallbackBundleId,
239
424
  verificationPending = false,
240
- verificationAttemptedAt = null,
241
425
  updatedAt = System.currentTimeMillis(),
242
426
  )
243
427
  saveMetadata(updatedMetadata)
244
428
 
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
- }
429
+ val fallbackBundleUrl = fallbackBundleId?.let { getBundleUrlForId(it) }
430
+ setBundleURL(fallbackBundleUrl)
252
431
 
253
- // Cleanup old bundles (keep only the new stable)
254
- cleanupOldBundles(bundleStoreDir, null, stagingBundleId)
432
+ File(getBundleStoreDir(), stagingBundleId).deleteRecursively()
433
+ saveLaunchReport(LaunchReport(status = "RECOVERED", crashedBundleId = stagingBundleId))
434
+ return true
255
435
  }
256
436
 
257
- private fun rollbackToStable() {
437
+ private fun applyPendingRecoveryIfNeeded(pendingRecovery: PendingCrashRecovery?) {
258
438
  val metadata = loadMetadataOrNull() ?: return
259
439
  val stagingBundleId = metadata.stagingBundleId ?: return
260
440
 
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
441
+ if (pendingRecovery?.shouldRollback == true &&
442
+ pendingRecovery.launchedBundleId == stagingBundleId &&
443
+ isVerificationPending(metadata)
444
+ ) {
445
+ rollbackPendingBundle(stagingBundleId)
446
+ }
447
+ }
270
448
 
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(),
449
+ private fun selectLaunch(): LaunchSelection {
450
+ val metadata = loadMetadataOrNull()
451
+ if (metadata == null) {
452
+ val cached = getCachedBundleURL()
453
+ return LaunchSelection(
454
+ bundleUrl = cached ?: getFallbackBundleURL(),
455
+ launchedBundleId = extractBundleIdFromCurrentURL(),
456
+ shouldRollbackOnCrash = false,
279
457
  )
280
- saveMetadata(updatedMetadata)
458
+ }
459
+
460
+ metadata.stagingBundleId?.let { stagingBundleId ->
461
+ val stagingBundleUrl = getBundleUrlForId(stagingBundleId)
462
+ if (stagingBundleUrl != null) {
463
+ return LaunchSelection(
464
+ bundleUrl = stagingBundleUrl,
465
+ launchedBundleId = stagingBundleId,
466
+ shouldRollbackOnCrash = metadata.verificationPending,
467
+ )
468
+ }
281
469
 
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")
470
+ if (metadata.verificationPending && rollbackPendingBundle(stagingBundleId)) {
471
+ return selectLaunch()
291
472
  }
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
473
  }
297
474
 
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")
475
+ metadata.stableBundleId?.let { stableBundleId ->
476
+ val stableBundleUrl = getBundleUrlForId(stableBundleId)
477
+ if (stableBundleUrl != null) {
478
+ return LaunchSelection(
479
+ bundleUrl = stableBundleUrl,
480
+ launchedBundleId = stableBundleId,
481
+ shouldRollbackOnCrash = false,
482
+ )
483
+ }
304
484
  }
485
+
486
+ val cached = getCachedBundleURL()
487
+ return LaunchSelection(
488
+ bundleUrl = cached ?: getFallbackBundleURL(),
489
+ launchedBundleId = extractBundleIdFromCurrentURL(),
490
+ shouldRollbackOnCrash = false,
491
+ )
305
492
  }
306
493
 
307
494
  // MARK: - Crashed History
@@ -321,41 +508,29 @@ class BundleFileStorageService(
321
508
  return true
322
509
  }
323
510
 
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
- )
511
+ override fun markLaunchCompleted(currentBundleId: String?) {
512
+ val metadata = loadMetadataOrNull() ?: return
513
+ val stagingBundleId = metadata.stagingBundleId ?: return
514
+ if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
515
+ return
341
516
  }
342
517
 
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
- }
518
+ saveMetadata(
519
+ metadata.copy(
520
+ verificationPending = false,
521
+ updatedAt = System.currentTimeMillis(),
522
+ ),
523
+ )
524
+ }
356
525
 
357
- // No changes
358
- return mapOf("status" to "STABLE")
526
+ // MARK: - notifyAppReady
527
+
528
+ override fun notifyAppReady(): Map<String, Any?> {
529
+ val report = loadLaunchReport() ?: return mapOf("status" to "STABLE")
530
+ return buildMap {
531
+ put("status", report.status)
532
+ report.crashedBundleId?.let { put("crashedBundleId", it) }
533
+ }
359
534
  }
360
535
 
361
536
  // MARK: - Bundle URL Operations
@@ -363,6 +538,7 @@ class BundleFileStorageService(
363
538
  override fun setBundleURL(localPath: String?): Boolean {
364
539
  Log.d(TAG, "setBundleURL: $localPath")
365
540
  preferences.setItem("HotUpdaterBundleURL", localPath)
541
+ clearActiveBundleMetadataSnapshot()
366
542
  return true
367
543
  }
368
544
 
@@ -387,73 +563,16 @@ class BundleFileStorageService(
387
563
 
388
564
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
389
565
 
390
- // Track if crash detection has already run in this process
391
- private var crashDetectionCompleted = false
392
-
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
- }
566
+ override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
567
+ saveLaunchReport(null)
568
+ applyPendingRecoveryIfNeeded(pendingRecovery)
418
569
 
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
570
+ val selection = selectLaunch()
571
+ Log.d(
572
+ TAG,
573
+ "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
574
+ )
575
+ return selection
457
576
  }
458
577
 
459
578
  override suspend fun updateBundle(
@@ -525,23 +644,16 @@ class BundleFileStorageService(
525
644
 
526
645
  // Update metadata: set as staging
527
646
  val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
528
- val updatedMetadata =
529
- currentMetadata.copy(
530
- stagingBundleId = bundleId,
531
- verificationPending = true,
532
- verificationAttemptedAt = null,
533
- updatedAt = System.currentTimeMillis(),
534
- )
647
+ val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
535
648
  saveMetadata(updatedMetadata)
536
649
 
537
650
  // Set bundle URL for backwards compatibility
538
651
  setBundleURL(existingIndexFile.absolutePath)
539
652
 
540
- // Keep both stable and staging bundles
541
- val stableBundleId = currentMetadata.stableBundleId
542
- cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
653
+ // Keep the current verified bundle as a fallback if one exists.
654
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
543
655
 
544
- Log.d(TAG, "Existing bundle set as staging, will be promoted after notifyAppReady")
656
+ Log.d(TAG, "Existing bundle set as staging bundle for next launch")
545
657
  return
546
658
  } else {
547
659
  // If index.android.bundle is missing, delete and re-download
@@ -721,13 +833,7 @@ class BundleFileStorageService(
721
833
 
722
834
  // Update metadata: set new bundle as staging
723
835
  val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
724
- val updatedMetadata =
725
- currentMetadata.copy(
726
- stagingBundleId = bundleId,
727
- verificationPending = true,
728
- verificationAttemptedAt = null,
729
- updatedAt = System.currentTimeMillis(),
730
- )
836
+ val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
731
837
  saveMetadata(updatedMetadata)
732
838
 
733
839
  // Also update HotUpdaterBundleURL for backwards compatibility
@@ -737,11 +843,10 @@ class BundleFileStorageService(
737
843
  // 11) Clean up temporary and download folders
738
844
  tempDir.deleteRecursively()
739
845
 
740
- // 12) Keep both stable and staging bundles
741
- val stableBundleId = currentMetadata.stableBundleId
742
- cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
846
+ // 12) Keep the fallback bundle and the new staging bundle.
847
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
743
848
 
744
- Log.d(TAG, "Downloaded and set bundle as staging successfully. Will be promoted after notifyAppReady.")
849
+ Log.d(TAG, "Downloaded and set bundle as staging successfully for the next launch.")
745
850
  // Progress already at 1.0 from unzip completion
746
851
  }
747
852
  }
@@ -813,14 +918,7 @@ class BundleFileStorageService(
813
918
  */
814
919
  override fun getBaseURL(): String {
815
920
  return try {
816
- val metadata = loadMetadataOrNull()
817
- val activeBundleId =
818
- when {
819
- metadata?.verificationPending == true && metadata.stagingBundleId != null ->
820
- metadata.stagingBundleId
821
- metadata?.stableBundleId != null -> metadata.stableBundleId
822
- else -> extractBundleIdFromCurrentURL()
823
- }
921
+ val activeBundleId = getActiveBundleId()
824
922
 
825
923
  if (activeBundleId != null) {
826
924
  val bundleDir = File(getBundleStoreDir(), activeBundleId)
@@ -836,6 +934,22 @@ class BundleFileStorageService(
836
934
  }
837
935
  }
838
936
 
937
+ override fun getBundleId(): String? =
938
+ try {
939
+ getActiveBundleMetadataSnapshot()?.bundleId
940
+ } catch (e: Exception) {
941
+ Log.e(TAG, "Error getting bundle ID: ${e.message}")
942
+ null
943
+ }
944
+
945
+ override fun getManifest(): Map<String, Any?> =
946
+ try {
947
+ getActiveBundleMetadataSnapshot()?.manifest ?: emptyMap()
948
+ } catch (e: Exception) {
949
+ Log.e(TAG, "Error getting manifest: ${e.message}")
950
+ emptyMap()
951
+ }
952
+
839
953
  override suspend fun resetChannel(): Boolean =
840
954
  withContext(Dispatchers.IO) {
841
955
  if (!setBundleURL(null)) {
@@ -848,18 +962,19 @@ class BundleFileStorageService(
848
962
  stableBundleId = null,
849
963
  stagingBundleId = null,
850
964
  verificationPending = false,
851
- verificationAttemptedAt = null,
852
- stagingExecutionCount = null,
853
965
  )
854
966
 
855
967
  if (!saveMetadata(clearedMetadata)) {
856
968
  return@withContext false
857
969
  }
858
970
 
971
+ saveLaunchReport(null)
972
+
859
973
  getBundleStoreDir().listFiles()?.forEach { file ->
860
974
  if (
861
975
  file.name == BundleMetadata.METADATA_FILENAME ||
862
- file.name == CrashedHistory.CRASHED_HISTORY_FILENAME
976
+ file.name == CrashedHistory.CRASHED_HISTORY_FILENAME ||
977
+ file.name == LaunchReport.LAUNCH_REPORT_FILENAME
863
978
  ) {
864
979
  return@forEach
865
980
  }