@hot-updater/react-native 1.0.0-rc.33 → 1.0.0-rc.35

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 (54) hide show
  1. package/android/build.gradle +13 -2
  2. package/android/proguard-rules.pro +0 -9
  3. package/android/src/bridge/BridgeReactInstance.kt +38 -0
  4. package/android/src/bridgeless/BridgeReactInstance.kt +24 -0
  5. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +94 -0
  6. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +119 -16
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +35 -0
  8. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +14 -10
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +38 -138
  10. package/android/src/newarch/JavaScriptExceptionHooks.kt +48 -0
  11. package/android/src/newarch/ReactIntegrationManager.kt +6 -32
  12. package/android/src/oldarch/JavaScriptExceptionHooks.kt +71 -0
  13. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +93 -15
  14. package/ios/HotUpdater/Internal/BundleMetadata.swift +53 -1
  15. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +39 -0
  16. package/lib/commonjs/appReady.js +3 -12
  17. package/lib/commonjs/appReady.js.map +1 -1
  18. package/lib/commonjs/checkForUpdate.js +0 -4
  19. package/lib/commonjs/checkForUpdate.js.map +1 -1
  20. package/lib/commonjs/native.js +0 -4
  21. package/lib/commonjs/native.js.map +1 -1
  22. package/lib/commonjs/sdkVersion.js +1 -1
  23. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  24. package/lib/module/appReady.js +2 -10
  25. package/lib/module/appReady.js.map +1 -1
  26. package/lib/module/checkForUpdate.js +1 -5
  27. package/lib/module/checkForUpdate.js.map +1 -1
  28. package/lib/module/native.js +0 -4
  29. package/lib/module/native.js.map +1 -1
  30. package/lib/module/sdkVersion.js +1 -1
  31. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  32. package/lib/typescript/commonjs/appReady.d.ts +0 -2
  33. package/lib/typescript/commonjs/appReady.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/native.d.ts +0 -2
  36. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  38. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +0 -7
  39. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  40. package/lib/typescript/module/appReady.d.ts +0 -2
  41. package/lib/typescript/module/appReady.d.ts.map +1 -1
  42. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  43. package/lib/typescript/module/native.d.ts +0 -2
  44. package/lib/typescript/module/native.d.ts.map +1 -1
  45. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  46. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +0 -7
  47. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  48. package/package.json +3 -3
  49. package/src/appReady.ts +0 -8
  50. package/src/checkForUpdate.ts +1 -7
  51. package/src/native.ts +0 -8
  52. package/src/sdkVersion.ts +1 -1
  53. package/src/specs/NativeHotUpdater.ts +0 -7
  54. package/android/src/main/java/com/hotupdater/PreviousProcessExit.kt +0 -89
@@ -20,8 +20,12 @@ def reactNativeArchitectures() {
20
20
  return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
21
21
  }
22
22
 
23
+ // The New Architecture is the default. React Native 0.82 and later run nothing
24
+ // else and set newArchEnabled to true on every library project, whatever the
25
+ // app's gradle.properties says, so only an older React Native app that sets
26
+ // newArchEnabled=false builds the old architecture.
23
27
  def isNewArchitectureEnabled() {
24
- return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
28
+ return !project.hasProperty("newArchEnabled") || project.property("newArchEnabled").toString().toBoolean()
25
29
  }
26
30
 
27
31
  apply plugin: "com.android.library"
@@ -115,7 +119,11 @@ android {
115
119
  "src/newarch",
116
120
  // Codegen specs
117
121
  "generated/java",
118
- "generated/jni"
122
+ "generated/jni",
123
+ // React Native 0.81 and older can run the New Architecture on the
124
+ // bridge. Later versions have none, so their builds never compile
125
+ // ReactNativeHost.
126
+ project.ext.reactNativeMinorVersion >= 82 ? "src/bridgeless" : "src/bridge"
119
127
  ]
120
128
  }
121
129
  }
@@ -146,6 +154,9 @@ dependencies {
146
154
  }
147
155
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
148
156
  implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
157
+ // ProcessLifecycleOwner, for an activity that started before Hot Updater was created.
158
+ // React Native's AppCompat already brings it at runtime.
159
+ implementation "androidx.lifecycle:lifecycle-process:2.6.1"
149
160
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
150
161
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
151
162
  implementation "org.apache.commons:commons-compress:1.27.1"
@@ -6,15 +6,6 @@
6
6
  }
7
7
 
8
8
  # New Architecture
9
- # Crash recovery discovers these accessors through reflection.
10
- -keepclassmembers class * implements com.facebook.react.ReactApplication {
11
- public *** getReactHost();
12
- }
13
-
14
- -keepclassmembers class * implements com.facebook.react.ReactHost {
15
- public *** getCurrentReactContext();
16
- }
17
-
18
9
  # Keep fields accessed via reflection in ReactHost
19
10
  # Support both Java (mReactHostDelegate) and Kotlin (reactHostDelegate) field names
20
11
  -keepclassmembers class com.facebook.react.runtime.ReactHostImpl {
@@ -0,0 +1,38 @@
1
+ package com.hotupdater
2
+
3
+ import com.facebook.react.ReactApplication
4
+ import com.facebook.react.bridge.JSBundleLoader
5
+
6
+ /**
7
+ * React Native 0.81 and older can run the New Architecture on the bridge, which
8
+ * only ReactNativeHost reaches. Only builds for those versions compile this file.
9
+ */
10
+ internal object BridgeReactInstance {
11
+ fun setJSBundle(
12
+ application: ReactApplication,
13
+ bundleLoader: JSBundleLoader?,
14
+ ) {
15
+ @Suppress("DEPRECATION")
16
+ val instanceManager = application.reactNativeHost.reactInstanceManager
17
+ val bundleLoaderField = instanceManager::class.java.getDeclaredField("mBundleLoader")
18
+ bundleLoaderField.isAccessible = true
19
+ bundleLoaderField.set(instanceManager, bundleLoader)
20
+ }
21
+
22
+ fun reload(application: ReactApplication) {
23
+ @Suppress("DEPRECATION")
24
+ val reactNativeHost = application.reactNativeHost
25
+ try {
26
+ reactNativeHost.reactInstanceManager.recreateReactContextInBackground()
27
+ } catch (e: Exception) {
28
+ val currentActivity = reactNativeHost.reactInstanceManager.currentReactContext?.currentActivity
29
+ if (currentActivity == null) {
30
+ return
31
+ }
32
+
33
+ currentActivity.runOnUiThread {
34
+ currentActivity.recreate()
35
+ }
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,24 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.ReactApplication
5
+ import com.facebook.react.bridge.JSBundleLoader
6
+
7
+ /**
8
+ * React Native 0.82 and later have no bridge, so an app without a ReactHost has
9
+ * no instance to update. Builds for those versions compile this file instead of
10
+ * the bridge one, and never reference ReactNativeHost.
11
+ */
12
+ @Suppress("UNUSED_PARAMETER")
13
+ internal object BridgeReactInstance {
14
+ fun setJSBundle(
15
+ application: ReactApplication,
16
+ bundleLoader: JSBundleLoader?,
17
+ ) {
18
+ Log.d("HotUpdater", "No ReactHost to set the JS bundle on")
19
+ }
20
+
21
+ fun reload(application: ReactApplication) {
22
+ Log.d("HotUpdater", "No ReactHost to reload")
23
+ }
24
+ }
@@ -0,0 +1,94 @@
1
+ package com.hotupdater
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.os.Bundle
6
+ import androidx.lifecycle.Lifecycle
7
+ import androidx.lifecycle.ProcessLifecycleOwner
8
+
9
+ /**
10
+ * Runs an action once this process has a started activity.
11
+ *
12
+ * A headless JS task (HeadlessJsTaskService, which background push libraries
13
+ * start) loads the bundle with no activity and never renders. Waiting for an
14
+ * activity keeps that launch from counting as one that ended before its first
15
+ * render.
16
+ *
17
+ * Android does not replay lifecycle callbacks to a late registration. An
18
+ * activity that started before this gate existed, as when a brownfield app adds
19
+ * React Native to an activity already on screen, is read from
20
+ * [processHasStartedActivity] instead.
21
+ */
22
+ internal class ActivityStartGate(
23
+ private val application: Application?,
24
+ private val processHasStartedActivity: () -> Boolean = ::hasStartedActivity,
25
+ ) : Application.ActivityLifecycleCallbacks {
26
+ private var startedActivities = 0
27
+ private var pendingAction: (() -> Unit)? = null
28
+
29
+ init {
30
+ application?.registerActivityLifecycleCallbacks(this)
31
+ }
32
+
33
+ /**
34
+ * Runs [action] now when an activity is started, otherwise at the next
35
+ * activity start. It replaces an action still waiting; null cancels it.
36
+ * Without an application to observe, it runs [action] now.
37
+ */
38
+ fun runWhenActivityStarted(action: (() -> Unit)?) {
39
+ val runNow =
40
+ synchronized(this) {
41
+ val waits =
42
+ application != null &&
43
+ startedActivities == 0 &&
44
+ !processHasStartedActivity()
45
+ pendingAction = action.takeIf { waits }
46
+ action.takeUnless { waits }
47
+ }
48
+ runNow?.invoke()
49
+ }
50
+
51
+ override fun onActivityStarted(activity: Activity) {
52
+ val action =
53
+ synchronized(this) {
54
+ startedActivities += 1
55
+ pendingAction.also { pendingAction = null }
56
+ }
57
+ action?.invoke()
58
+ }
59
+
60
+ override fun onActivityStopped(activity: Activity) {
61
+ synchronized(this) {
62
+ // An activity started before this gate existed was never counted.
63
+ if (startedActivities > 0) startedActivities -= 1
64
+ }
65
+ }
66
+
67
+ override fun onActivityCreated(
68
+ activity: Activity,
69
+ savedInstanceState: Bundle?,
70
+ ) {}
71
+
72
+ override fun onActivityResumed(activity: Activity) {}
73
+
74
+ override fun onActivityPaused(activity: Activity) {}
75
+
76
+ override fun onActivitySaveInstanceState(
77
+ activity: Activity,
78
+ outState: Bundle,
79
+ ) {}
80
+
81
+ override fun onActivityDestroyed(activity: Activity) {}
82
+ }
83
+
84
+ // ProcessLifecycleOwner observes activities from process start, through the
85
+ // androidx.startup initializer that lifecycle-process registers. It is STARTED
86
+ // while an activity of this process is started, and stays at INITIALIZED when
87
+ // an app removes that initializer, so the gate then waits for the next start.
88
+ private fun hasStartedActivity(): Boolean =
89
+ runCatching {
90
+ ProcessLifecycleOwner
91
+ .get()
92
+ .lifecycle.currentState
93
+ .isAtLeast(Lifecycle.State.STARTED)
94
+ }.getOrDefault(false)
@@ -151,6 +151,12 @@ interface BundleStorageService {
151
151
 
152
152
  suspend fun commitReleaseSelection(selection: PersistedSelection): Boolean = false
153
153
 
154
+ /**
155
+ * Records that the current launch of a verification-pending bundle can show UI.
156
+ * The next process rolls the bundle back unless first content verified it.
157
+ */
158
+ fun markLaunchStarted(launchedBundleId: String?)
159
+
154
160
  /**
155
161
  * Marks the current launch as successful after the first content appeared.
156
162
  */
@@ -162,7 +168,8 @@ interface BundleStorageService {
162
168
  fun notifyAppReady(): Map<String, Any?>
163
169
 
164
170
  /**
165
- * Gets the crashed bundle history
171
+ * Gets the crashed bundle history, plus a bundle that waits for its retry after
172
+ * an unfinished launch while this process still refuses it (see [InterruptedLaunch])
166
173
  * @return CrashedHistory containing crashed bundles
167
174
  */
168
175
  fun getCrashHistory(): CrashedHistory
@@ -592,6 +599,16 @@ class BundleFileStorageService(
592
599
  private var hasPreparedLaunch = false
593
600
  private var currentLaunchReport: LaunchReport? = null
594
601
 
602
+ // The staged bundle this process launched before its first content. Only that
603
+ // launch is pending; a bundle staged later waits for the next launch.
604
+ private var pendingLaunchBundleId: String? = null
605
+
606
+ // Crash history is written at first content on the main thread while JS can read it.
607
+ private val crashedHistoryLock = Any()
608
+
609
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
610
+ private var retryHeldBundleId: String? = null
611
+
595
612
  @Volatile
596
613
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
597
614
  private val activeBundleMetadataLock = Any()
@@ -1092,7 +1109,14 @@ class BundleFileStorageService(
1092
1109
  )
1093
1110
  }
1094
1111
 
1095
- private fun rollbackPendingBundle(stagingBundleId: String): Boolean {
1112
+ /**
1113
+ * Rolls back the pending staging bundle. [unfinishedLaunch] means its launch
1114
+ * ended before first content without a crash marker.
1115
+ */
1116
+ private fun rollbackPendingBundle(
1117
+ stagingBundleId: String,
1118
+ unfinishedLaunch: Boolean = false,
1119
+ ): Boolean {
1096
1120
  val metadata = loadMetadataOrNull() ?: return false
1097
1121
  if (metadata.stagingBundleId != stagingBundleId) {
1098
1122
  return false
@@ -1100,9 +1124,7 @@ class BundleFileStorageService(
1100
1124
 
1101
1125
  Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
1102
1126
 
1103
- val crashedHistory = loadCrashedHistory()
1104
- crashedHistory.addEntry(stagingBundleId)
1105
- saveCrashedHistory(crashedHistory)
1127
+ recordFailedLaunch(stagingBundleId, unfinishedLaunch)
1106
1128
 
1107
1129
  val fallbackBundleId =
1108
1130
  metadata.stableBundleId?.takeIf { candidate ->
@@ -1369,18 +1391,98 @@ class BundleFileStorageService(
1369
1391
 
1370
1392
  private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
1371
1393
 
1372
- private fun isBundleInCrashedHistory(bundleId: String): Boolean = loadCrashedHistory().contains(bundleId)
1394
+ private fun isBundleInCrashedHistory(bundleId: String): Boolean = getCrashHistory().contains(bundleId)
1373
1395
 
1374
- override fun getCrashHistory(): CrashedHistory = loadCrashedHistory()
1396
+ /**
1397
+ * The bundles this device refuses to install: crash history, plus a bundle
1398
+ * waiting for its retry until a process that may retry it.
1399
+ */
1400
+ override fun getCrashHistory(): CrashedHistory =
1401
+ synchronized(crashedHistoryLock) {
1402
+ val history = loadCrashedHistory()
1403
+ val interrupted = history.interruptedLaunch
1404
+ if (interrupted != null &&
1405
+ (!interrupted.retryReady || interrupted.bundleId == retryHeldBundleId) &&
1406
+ !history.contains(interrupted.bundleId)
1407
+ ) {
1408
+ history.bundles.add(CrashedBundleEntry(interrupted.bundleId, crashedAt = System.currentTimeMillis()))
1409
+ }
1410
+ history
1411
+ }
1375
1412
 
1376
1413
  override fun clearCrashHistory(): Boolean {
1377
- val history = CrashedHistory()
1378
- saveCrashedHistory(history)
1414
+ synchronized(crashedHistoryLock) {
1415
+ saveCrashedHistory(CrashedHistory())
1416
+ retryHeldBundleId = null
1417
+ }
1379
1418
  Log.d(TAG, "Cleared crash history")
1380
1419
  return true
1381
1420
  }
1382
1421
 
1422
+ /**
1423
+ * A crash, or a second unfinished launch of the same bundle, adds the bundle to
1424
+ * crash history. A first unfinished launch cannot tell a hang from a user leaving
1425
+ * early, so it leaves the bundle one retry instead.
1426
+ */
1427
+ private fun recordFailedLaunch(
1428
+ bundleId: String,
1429
+ unfinishedLaunch: Boolean,
1430
+ ) {
1431
+ synchronized(crashedHistoryLock) {
1432
+ val history = loadCrashedHistory()
1433
+ val interrupted = history.interruptedLaunch
1434
+ if (unfinishedLaunch && interrupted?.bundleId != bundleId) {
1435
+ Log.w(TAG, "Launch of $bundleId ended before first content; it gets one retry")
1436
+ history.interruptedLaunch = InterruptedLaunch(bundleId)
1437
+ retryHeldBundleId = bundleId
1438
+ } else {
1439
+ history.addEntry(bundleId)
1440
+ if (interrupted?.bundleId == bundleId) {
1441
+ history.interruptedLaunch = null
1442
+ }
1443
+ }
1444
+ saveCrashedHistory(history)
1445
+ }
1446
+ }
1447
+
1448
+ /**
1449
+ * First content appeared, so the app works again. A bundle waiting for its retry
1450
+ * may be installed from the next process on. This process keeps refusing it, so
1451
+ * the session that recovered never reloads into it. Content from that bundle
1452
+ * verifies it.
1453
+ */
1454
+ private fun readyInterruptedLaunchRetry(currentBundleId: String?) {
1455
+ synchronized(crashedHistoryLock) {
1456
+ val history = loadCrashedHistory()
1457
+ val interrupted = history.interruptedLaunch ?: return
1458
+ if (interrupted.bundleId == currentBundleId) {
1459
+ history.interruptedLaunch = null
1460
+ } else if (interrupted.retryReady) {
1461
+ return
1462
+ } else {
1463
+ history.interruptedLaunch = interrupted.copy(retryReady = true)
1464
+ retryHeldBundleId = interrupted.bundleId
1465
+ }
1466
+ saveCrashedHistory(history)
1467
+ }
1468
+ }
1469
+
1470
+ override fun markLaunchStarted(launchedBundleId: String?) {
1471
+ // Unlike prepareLaunch, this can run while JS stages a newer bundle.
1472
+ synchronized(releaseStateLock) {
1473
+ val metadata = loadMetadataOrNull() ?: return
1474
+ if (!isVerificationPending(metadata) ||
1475
+ metadata.launchInProgress ||
1476
+ metadata.stagingBundleId != launchedBundleId
1477
+ ) {
1478
+ return
1479
+ }
1480
+ saveMetadata(metadata.copy(launchInProgress = true))
1481
+ }
1482
+ }
1483
+
1383
1484
  override fun markLaunchCompleted(currentBundleId: String?) {
1485
+ readyInterruptedLaunchRetry(currentBundleId)
1384
1486
  val metadata = loadMetadataOrNull() ?: return
1385
1487
  val stagingBundleId = metadata.stagingBundleId ?: return
1386
1488
  if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
@@ -1418,7 +1520,11 @@ class BundleFileStorageService(
1418
1520
  val report = loadLaunchReport()
1419
1521
  if (report == null) {
1420
1522
  val metadata = loadMetadataOrNull()
1421
- if (metadata?.verificationPending == true && metadata.stagingBundleId != null) {
1523
+ val launchedBundleId = pendingLaunchBundleId
1524
+ if (launchedBundleId != null &&
1525
+ metadata?.verificationPending == true &&
1526
+ metadata.stagingBundleId == launchedBundleId
1527
+ ) {
1422
1528
  return mapOf("status" to "PENDING")
1423
1529
  }
1424
1530
  return mapOf("status" to "UNCHANGED")
@@ -1483,17 +1589,14 @@ class BundleFileStorageService(
1483
1589
  if (!hasPreparedLaunch) {
1484
1590
  val metadata = loadMetadataOrNull()
1485
1591
  if (metadata?.verificationPending == true && metadata.launchInProgress) {
1486
- metadata.stagingBundleId?.let { rollbackPendingBundle(it) }
1592
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
1487
1593
  }
1488
1594
  }
1489
1595
  hasPreparedLaunch = true
1490
1596
 
1597
+ // A launch is recorded only once it can show UI (markLaunchStarted).
1491
1598
  val selection = selectLaunch()
1492
- if (selection.shouldRollbackOnCrash) {
1493
- loadMetadataOrNull()?.let { metadata ->
1494
- saveMetadata(metadata.copy(launchInProgress = true))
1495
- }
1496
- }
1599
+ pendingLaunchBundleId = selection.launchedBundleId.takeIf { selection.shouldRollbackOnCrash }
1497
1600
  Log.d(
1498
1601
  TAG,
1499
1602
  "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
@@ -296,12 +296,44 @@ data class CrashedBundleEntry(
296
296
  }
297
297
  }
298
298
 
299
+ /**
300
+ * A bundle whose launch ended before first content without a crash, waiting
301
+ * for its one retry. Such a launch cannot tell a hang from a user leaving
302
+ * early, so the bundle is offered again instead of entering crash history.
303
+ * A second unfinished launch of it adds it there.
304
+ *
305
+ * [retryReady] turns true once a later launch shows content. The retry
306
+ * starts with the next process, so the session that recovered never reloads
307
+ * into the same bundle.
308
+ */
309
+ data class InterruptedLaunch(
310
+ val bundleId: String,
311
+ val retryReady: Boolean = false,
312
+ ) {
313
+ companion object {
314
+ fun fromJson(json: JSONObject): InterruptedLaunch? =
315
+ json.optNullableString("bundleId")?.let { bundleId ->
316
+ InterruptedLaunch(
317
+ bundleId = bundleId,
318
+ retryReady = json.optBoolean("retryReady", false),
319
+ )
320
+ }
321
+ }
322
+
323
+ fun toJson(): JSONObject =
324
+ JSONObject().apply {
325
+ put("bundleId", bundleId)
326
+ put("retryReady", retryReady)
327
+ }
328
+ }
329
+
299
330
  /**
300
331
  * History of crashed bundles
301
332
  */
302
333
  data class CrashedHistory(
303
334
  val bundles: MutableList<CrashedBundleEntry> = mutableListOf(),
304
335
  val maxHistorySize: Int = DEFAULT_MAX_HISTORY_SIZE,
336
+ var interruptedLaunch: InterruptedLaunch? = null,
305
337
  ) {
306
338
  companion object {
307
339
  private const val TAG = "CrashedHistory"
@@ -317,6 +349,7 @@ data class CrashedHistory(
317
349
  return CrashedHistory(
318
350
  bundles = bundles,
319
351
  maxHistorySize = json.optInt("maxHistorySize", DEFAULT_MAX_HISTORY_SIZE),
352
+ interruptedLaunch = json.optJSONObject("interruptedLaunch")?.let(InterruptedLaunch::fromJson),
320
353
  )
321
354
  }
322
355
 
@@ -342,6 +375,7 @@ data class CrashedHistory(
342
375
  bundles.forEach { bundlesArray.put(it.toJson()) }
343
376
  put("bundles", bundlesArray)
344
377
  put("maxHistorySize", maxHistorySize)
378
+ interruptedLaunch?.let { put("interruptedLaunch", it.toJson()) }
345
379
  }
346
380
 
347
381
  fun saveToFile(file: File): Boolean =
@@ -389,6 +423,7 @@ data class CrashedHistory(
389
423
 
390
424
  fun clear() {
391
425
  bundles.clear()
426
+ interruptedLaunch = null
392
427
  }
393
428
  }
394
429
 
@@ -1,6 +1,7 @@
1
1
  package com.hotupdater
2
2
 
3
3
  import android.app.ActivityOptions
4
+ import android.app.Application
4
5
  import android.content.Context
5
6
  import android.content.Intent
6
7
  import android.os.Process
@@ -20,6 +21,7 @@ class HotUpdaterImpl {
20
21
  private val releaseCatalogCache: ReleaseCatalogCacheService
21
22
  private val installIdentity: InstallIdentityService
22
23
  private val keyValueStorage: KeyValueStorageService
24
+ private val activityStartGate: ActivityStartGate
23
25
  private var currentLaunchSelection: LaunchSelection? = null
24
26
 
25
27
  /**
@@ -33,6 +35,7 @@ class HotUpdaterImpl {
33
35
  this.context = context.applicationContext
34
36
  this.bundleStorage = bundleStorage
35
37
  this.preferences = preferences
38
+ this.activityStartGate = ActivityStartGate(this.context as? Application)
36
39
  this.recoveryManager = HotUpdaterRecoveryManager(this.context)
37
40
  this.releaseCatalogCache =
38
41
  ReleaseCatalogCacheService(
@@ -40,7 +43,6 @@ class HotUpdaterImpl {
40
43
  )
41
44
  this.installIdentity = InstallIdentityService.create(this.context)
42
45
  this.keyValueStorage = KeyValueStorageService.create(this.context)
43
- PreviousProcessExit.initialize(this.context)
44
46
  }
45
47
 
46
48
  /**
@@ -379,15 +381,7 @@ class HotUpdaterImpl {
379
381
  }
380
382
  }
381
383
 
382
- /**
383
- * The launch report for this process, with `previousProcessExit` when
384
- * Android reports why the previous main process exited.
385
- */
386
- fun notifyAppReady(): Map<String, Any?> {
387
- val report = bundleStorage.notifyAppReady()
388
- val previousProcessExit = PreviousProcessExit.get() ?: return report
389
- return report + ("previousProcessExit" to previousProcessExit)
390
- }
384
+ fun notifyAppReady(): Map<String, Any?> = bundleStorage.notifyAppReady()
391
385
 
392
386
  fun acceptReleaseCatalog(
393
387
  catalogId: String,
@@ -517,6 +511,16 @@ class HotUpdaterImpl {
517
511
 
518
512
  val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
519
513
  val selection = bundleStorage.prepareLaunch(pendingRecovery)
514
+ // A headless JS task, such as a background push, loads the bundle without
515
+ // an activity and never renders. Only a launch with an activity may leave
516
+ // an unfinished launch for the next process to roll back.
517
+ activityStartGate.runWhenActivityStarted(
518
+ if (selection.shouldRollbackOnCrash) {
519
+ { bundleStorage.markLaunchStarted(selection.launchedBundleId) }
520
+ } else {
521
+ null
522
+ },
523
+ )
520
524
  recoveryManager.startMonitoring(
521
525
  bundleId = selection.launchedBundleId,
522
526
  shouldRollback = selection.shouldRollbackOnCrash,