@hot-updater/react-native 1.1.1 → 1.1.3

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 (46) hide show
  1. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
  2. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +105 -83
  3. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
  4. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +27 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterRestartActivity.kt +39 -0
  6. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +2 -0
  7. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +9 -4
  8. package/android/src/main/java/com/hotupdater/PostCrashUpdateCheck.kt +195 -0
  9. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +21 -2
  10. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +125 -3
  11. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +90 -10
  12. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +2 -0
  13. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +15 -4
  14. package/ios/HotUpdater/Internal/PostCrashUpdateCheck.swift +248 -0
  15. package/ios/HotUpdater/Package.swift +1 -0
  16. package/lib/commonjs/checkForUpdate.js +10 -1
  17. package/lib/commonjs/checkForUpdate.js.map +1 -1
  18. package/lib/commonjs/httpClient.js +1 -0
  19. package/lib/commonjs/httpClient.js.map +1 -1
  20. package/lib/commonjs/recoveryCheckConfig.js +58 -0
  21. package/lib/commonjs/recoveryCheckConfig.js.map +1 -0
  22. package/lib/commonjs/sdkVersion.js +1 -1
  23. package/lib/module/checkForUpdate.js +10 -1
  24. package/lib/module/checkForUpdate.js.map +1 -1
  25. package/lib/module/httpClient.js +1 -0
  26. package/lib/module/httpClient.js.map +1 -1
  27. package/lib/module/recoveryCheckConfig.js +54 -0
  28. package/lib/module/recoveryCheckConfig.js.map +1 -0
  29. package/lib/module/sdkVersion.js +1 -1
  30. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/httpClient.d.ts +2 -0
  32. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts +28 -0
  34. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  36. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  37. package/lib/typescript/module/httpClient.d.ts +2 -0
  38. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  39. package/lib/typescript/module/recoveryCheckConfig.d.ts +28 -0
  40. package/lib/typescript/module/recoveryCheckConfig.d.ts.map +1 -0
  41. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  42. package/package.json +4 -4
  43. package/src/checkForUpdate.ts +10 -1
  44. package/src/httpClient.ts +3 -0
  45. package/src/recoveryCheckConfig.ts +68 -0
  46. package/src/sdkVersion.ts +1 -1
@@ -200,7 +200,7 @@ internal class ActivityStartGate(
200
200
  // androidx.startup initializer that lifecycle-process registers. It is STARTED
201
201
  // while an activity of this process is started, and stays at INITIALIZED when
202
202
  // an app removes that initializer, so the gate then waits for the next start.
203
- private fun hasStartedActivity(): Boolean =
203
+ internal fun hasStartedActivity(): Boolean =
204
204
  runCatching {
205
205
  ProcessLifecycleOwner
206
206
  .get()
@@ -1183,6 +1183,14 @@ class BundleFileStorageService(
1183
1183
  private fun rollbackPendingBundle(
1184
1184
  stagingBundleId: String,
1185
1185
  unfinishedLaunch: Boolean = false,
1186
+ ): Boolean =
1187
+ synchronized(releaseStateLock) {
1188
+ rollbackPendingBundleLocked(stagingBundleId, unfinishedLaunch)
1189
+ }
1190
+
1191
+ private fun rollbackPendingBundleLocked(
1192
+ stagingBundleId: String,
1193
+ unfinishedLaunch: Boolean,
1186
1194
  ): Boolean {
1187
1195
  val metadata = loadMetadataOrNull() ?: return false
1188
1196
  if (metadata.stagingBundleId != stagingBundleId) {
@@ -1231,18 +1239,22 @@ class BundleFileStorageService(
1231
1239
  }
1232
1240
 
1233
1241
  private fun applyPendingRecoveryIfNeeded(pendingRecovery: PendingCrashRecovery?) {
1234
- val metadata = loadMetadataOrNull() ?: return
1235
- val stagingBundleId = metadata.stagingBundleId ?: return
1242
+ synchronized(releaseStateLock) {
1243
+ val metadata = loadMetadataOrNull() ?: return
1244
+ val stagingBundleId = metadata.stagingBundleId ?: return
1236
1245
 
1237
- if (pendingRecovery?.shouldRollback == true &&
1238
- pendingRecovery.launchedBundleId == stagingBundleId &&
1239
- isVerificationPending(metadata)
1240
- ) {
1241
- rollbackPendingBundle(stagingBundleId)
1246
+ if (pendingRecovery?.shouldRollback == true &&
1247
+ pendingRecovery.launchedBundleId == stagingBundleId &&
1248
+ isVerificationPending(metadata)
1249
+ ) {
1250
+ rollbackPendingBundle(stagingBundleId)
1251
+ }
1242
1252
  }
1243
1253
  }
1244
1254
 
1245
- private fun selectLaunch(): LaunchSelection {
1255
+ private fun selectLaunch(): LaunchSelection = synchronized(releaseStateLock) { selectLaunchLocked() }
1256
+
1257
+ private fun selectLaunchLocked(): LaunchSelection {
1246
1258
  val metadata = loadMetadataOrNull()
1247
1259
  if (metadata == null) {
1248
1260
  return LaunchSelection(
@@ -1263,7 +1275,7 @@ class BundleFileStorageService(
1263
1275
  }
1264
1276
 
1265
1277
  if (metadata.verificationPending && rollbackPendingBundle(stagingBundleId)) {
1266
- return selectLaunch()
1278
+ return selectLaunchLocked()
1267
1279
  }
1268
1280
  }
1269
1281
 
@@ -1583,35 +1595,39 @@ class BundleFileStorageService(
1583
1595
 
1584
1596
  override fun markLaunchCompleted(currentBundleId: String?) {
1585
1597
  readyInterruptedLaunchRetry(currentBundleId)
1586
- val metadata = loadMetadataOrNull() ?: return
1587
- val stagingBundleId = metadata.stagingBundleId ?: return
1588
- if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
1589
- return
1590
- }
1598
+ // A Release selection can commit at the same time, from a native update
1599
+ // check or JS; neither may save over the other's read.
1600
+ synchronized(releaseStateLock) {
1601
+ val metadata = loadMetadataOrNull() ?: return
1602
+ val stagingBundleId = metadata.stagingBundleId ?: return
1603
+ if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
1604
+ return
1605
+ }
1591
1606
 
1592
- val transition = metadata.pendingTransition
1593
- val fromBundleId = transition?.fromBundleId ?: getKnownLaunchBundleId(metadata)
1594
- saveMetadata(
1595
- metadata.copy(
1596
- stableBundleId = null,
1597
- stableSelection = null,
1598
- pendingUpdateStrategy = null,
1599
- pendingTransition = null,
1600
- verificationPending = false,
1601
- launchInProgress = false,
1602
- updatedAt = System.currentTimeMillis(),
1603
- ),
1604
- )
1605
- saveLaunchReport(
1606
- LaunchReport(
1607
- status = "UPDATE_APPLIED",
1608
- fromReleaseId = transition?.fromReleaseId,
1609
- fromBundleId = fromBundleId,
1610
- toReleaseId = transition?.toReleaseId,
1611
- toBundleId = stagingBundleId,
1612
- updateStrategy = metadata.pendingUpdateStrategy ?: resolveUpdateStrategy(),
1613
- ),
1614
- )
1607
+ val transition = metadata.pendingTransition
1608
+ val fromBundleId = transition?.fromBundleId ?: getKnownLaunchBundleId(metadata)
1609
+ saveMetadata(
1610
+ metadata.copy(
1611
+ stableBundleId = null,
1612
+ stableSelection = null,
1613
+ pendingUpdateStrategy = null,
1614
+ pendingTransition = null,
1615
+ verificationPending = false,
1616
+ launchInProgress = false,
1617
+ updatedAt = System.currentTimeMillis(),
1618
+ ),
1619
+ )
1620
+ saveLaunchReport(
1621
+ LaunchReport(
1622
+ status = "UPDATE_APPLIED",
1623
+ fromReleaseId = transition?.fromReleaseId,
1624
+ fromBundleId = fromBundleId,
1625
+ toReleaseId = transition?.toReleaseId,
1626
+ toBundleId = stagingBundleId,
1627
+ updateStrategy = metadata.pendingUpdateStrategy ?: resolveUpdateStrategy(),
1628
+ ),
1629
+ )
1630
+ }
1615
1631
  }
1616
1632
 
1617
1633
  // MARK: - notifyAppReady
@@ -1688,17 +1704,19 @@ class BundleFileStorageService(
1688
1704
  // Cleared before the rollbacks below, so the launch reports their RECOVERED.
1689
1705
  saveLaunchReport(null)
1690
1706
  applyPendingRecoveryIfNeeded(pendingRecovery())
1691
- // Only a new storage instance consumes an unfinished launch. Repeated bundle
1692
- // lookups or bridge reloads in this process must not reject its own launch.
1693
- val metadata = loadMetadataOrNull()
1694
- if (metadata?.verificationPending == true && metadata.launchInProgress) {
1695
- metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
1696
- }
1697
- // A pending bundle whose file is gone rolls back, as selectLaunch does.
1698
- val settled = loadMetadataOrNull()
1699
- settled?.stagingBundleId?.let { stagingBundleId ->
1700
- if (settled.verificationPending && getBundleUrlForId(stagingBundleId) == null) {
1701
- rollbackPendingBundle(stagingBundleId)
1707
+ synchronized(releaseStateLock) {
1708
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1709
+ // lookups or bridge reloads in this process must not reject its own launch.
1710
+ val metadata = loadMetadataOrNull()
1711
+ if (metadata?.verificationPending == true && metadata.launchInProgress) {
1712
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
1713
+ }
1714
+ // A pending bundle whose file is gone rolls back, as selectLaunch does.
1715
+ val settled = loadMetadataOrNull()
1716
+ settled?.stagingBundleId?.let { stagingBundleId ->
1717
+ if (settled.verificationPending && getBundleUrlForId(stagingBundleId) == null) {
1718
+ rollbackPendingBundle(stagingBundleId)
1719
+ }
1702
1720
  }
1703
1721
  }
1704
1722
  }
@@ -2708,47 +2726,51 @@ class BundleFileStorageService(
2708
2726
 
2709
2727
  override suspend fun resetChannel(): Boolean =
2710
2728
  withContext(Dispatchers.IO) {
2711
- if (!setBundleURL(null)) {
2712
- return@withContext false
2713
- }
2729
+ synchronized(releaseStateLock) { resetChannelLocked() }
2730
+ }
2714
2731
 
2715
- val clearedMetadata =
2716
- (loadMetadataOrNull() ?: BundleMetadata()).copy(
2717
- isolationKey = isolationKey,
2718
- stableBundleId = null,
2719
- stagingBundleId = null,
2720
- stableSelection = null,
2721
- stagingSelection = null,
2722
- pendingUpdateStrategy = null,
2723
- pendingTransition = null,
2724
- verificationPending = false,
2725
- launchInProgress = false,
2726
- // Selections made before the reset no longer stage; the
2727
- // high-water marks stay, so an older catalog is still refused.
2728
- currentSelectionContexts = emptyMap(),
2729
- updatedAt = System.currentTimeMillis(),
2730
- )
2732
+ private fun resetChannelLocked(): Boolean {
2733
+ if (!setBundleURL(null)) {
2734
+ return false
2735
+ }
2731
2736
 
2732
- if (!saveMetadata(clearedMetadata)) {
2733
- return@withContext false
2734
- }
2737
+ val clearedMetadata =
2738
+ (loadMetadataOrNull() ?: BundleMetadata()).copy(
2739
+ isolationKey = isolationKey,
2740
+ stableBundleId = null,
2741
+ stagingBundleId = null,
2742
+ stableSelection = null,
2743
+ stagingSelection = null,
2744
+ pendingUpdateStrategy = null,
2745
+ pendingTransition = null,
2746
+ verificationPending = false,
2747
+ launchInProgress = false,
2748
+ // Selections made before the reset no longer stage; the
2749
+ // high-water marks stay, so an older catalog is still refused.
2750
+ currentSelectionContexts = emptyMap(),
2751
+ updatedAt = System.currentTimeMillis(),
2752
+ )
2735
2753
 
2736
- saveLaunchReport(null)
2754
+ if (!saveMetadata(clearedMetadata)) {
2755
+ return false
2756
+ }
2737
2757
 
2738
- getBundleStoreDir().listFiles()?.forEach { file ->
2739
- if (
2740
- file.name == BundleMetadata.METADATA_FILENAME ||
2741
- file.name == CrashedHistory.CRASHED_HISTORY_FILENAME ||
2742
- file.name == LaunchReport.LAUNCH_REPORT_FILENAME
2743
- ) {
2744
- return@forEach
2745
- }
2758
+ saveLaunchReport(null)
2746
2759
 
2747
- if (file.isDirectory) {
2748
- file.deleteRecursively()
2749
- }
2760
+ getBundleStoreDir().listFiles()?.forEach { file ->
2761
+ if (
2762
+ file.name == BundleMetadata.METADATA_FILENAME ||
2763
+ file.name == CrashedHistory.CRASHED_HISTORY_FILENAME ||
2764
+ file.name == LaunchReport.LAUNCH_REPORT_FILENAME
2765
+ ) {
2766
+ return@forEach
2750
2767
  }
2751
2768
 
2752
- true
2769
+ if (file.isDirectory) {
2770
+ file.deleteRecursively()
2771
+ }
2753
2772
  }
2773
+
2774
+ return true
2775
+ }
2754
2776
  }
@@ -29,6 +29,9 @@ class HotUpdaterImpl {
29
29
  private val installIdentity: InstallIdentityService
30
30
  private val keyValueStorage: KeyValueStorageService
31
31
 
32
+ // Main's update check at the launch after a crash; null for a named instance.
33
+ private val postCrashUpdateCheck: PostCrashUpdateCheck?
34
+
32
35
  // Read by native update checks on other threads while React Native sets it.
33
36
  @Volatile
34
37
  private var currentLaunchSelection: LaunchSelection? = null
@@ -70,6 +73,11 @@ class HotUpdaterImpl {
70
73
  /**
71
74
  * @param activityStartGate the gate that arms this instance's launch;
72
75
  * by default one that waits for a started activity showing one of its views
76
+ * @param postCrashUpdateCheck main's check after a crash, over its
77
+ * key-value store; by default one with main's native checker
78
+ * @param isDevelopmentBuild a debug build, where a crash is a RedBox
79
+ * during development, never a reason to check for a fix: main records no
80
+ * crash after its grace period and has no check after a crash
73
81
  */
74
82
  internal constructor(
75
83
  context: Context,
@@ -77,6 +85,8 @@ class HotUpdaterImpl {
77
85
  preferences: PreferencesService,
78
86
  environment: HotUpdaterInstanceEnvironment,
79
87
  activityStartGate: ActivityStartGate? = null,
88
+ postCrashUpdateCheck: ((KeyValueStorageService) -> PostCrashUpdateCheck)? = null,
89
+ isDevelopmentBuild: Boolean = BuildConfig.DEBUG,
80
90
  ) {
81
91
  this.context = context.applicationContext
82
92
  // An activity started before this process followed activities is
@@ -85,12 +95,19 @@ class HotUpdaterImpl {
85
95
  this.environment = environment
86
96
  this.bundleStorage = bundleStorage
87
97
  this.preferences = preferences
88
- this.recoveryManager = HotUpdaterRecoveryManager(this.context, environment)
98
+ this.recoveryManager =
99
+ HotUpdaterRecoveryManager(this.context, environment, recordsCrashesAfterContent = !isDevelopmentBuild)
89
100
  this.activityStartGate = activityStartGate ?: createActivityStartGate(this.context, environment)
90
101
  this.releaseCatalogCache =
91
102
  ReleaseCatalogCacheService(environment.releaseCatalogCacheDirectory(this.context))
92
103
  this.installIdentity = InstallIdentityService.create(this.context)
93
104
  this.keyValueStorage = KeyValueStorageService(environment.keyValueStorageFile(this.context))
105
+ this.postCrashUpdateCheck =
106
+ if (environment.isMain && !isDevelopmentBuild) {
107
+ (postCrashUpdateCheck ?: ::createPostCrashUpdateCheck)(keyValueStorage)
108
+ } else {
109
+ null
110
+ }
94
111
  }
95
112
 
96
113
  /**
@@ -103,14 +120,36 @@ class HotUpdaterImpl {
103
120
  context: Context,
104
121
  environment: HotUpdaterInstanceEnvironment,
105
122
  activityStartGate: ActivityStartGate? = null,
123
+ postCrashUpdateCheck: ((KeyValueStorageService) -> PostCrashUpdateCheck)? = null,
124
+ isDevelopmentBuild: Boolean = BuildConfig.DEBUG,
106
125
  ) : this(
107
126
  context = context,
108
127
  bundleStorage = createBundleStorage(context, environment),
109
128
  preferences = createPreferences(context, environment),
110
129
  environment = environment,
111
130
  activityStartGate = activityStartGate,
131
+ postCrashUpdateCheck = postCrashUpdateCheck,
132
+ isDevelopmentBuild = isDevelopmentBuild,
112
133
  )
113
134
 
135
+ /**
136
+ * Checks with main's native checker, the one its native API and resetChannel
137
+ * share, once a started activity of any kind shows UI: a headless launch,
138
+ * such as a background push, leaves the check for a launch the user sees.
139
+ * Its gate is created only when a check is due, so a process without one
140
+ * follows no activities for it ([postCrashUiGate]).
141
+ */
142
+ private fun createPostCrashUpdateCheck(storage: KeyValueStorageService): PostCrashUpdateCheck =
143
+ PostCrashUpdateCheck(
144
+ getItem = storage::getItem,
145
+ setItem = storage::setItem,
146
+ whenUiShown = { action -> postCrashUiGate(context as? Application).runWhenActivityStarted(action) },
147
+ check = { request ->
148
+ NativeUpdateCheck.checker(context, HotUpdaterInstanceEnvironment.MAIN_NAME) { this }.checkForUpdate(request)
149
+ },
150
+ isRestartProcess = { HotUpdaterProcess.isRestartProcess(context) },
151
+ )
152
+
114
153
  /**
115
154
  * Gets the app version
116
155
  * @param context Application context
@@ -190,6 +229,26 @@ class HotUpdaterImpl {
190
229
  )
191
230
  }
192
231
 
232
+ /**
233
+ * The gate of the check after a crash: any started activity counts.
234
+ * Created that late, it misses the start of an activity already
235
+ * shown, so it also counts the ones the process has seen started;
236
+ * an app without the ProcessLifecycleOwner initializer would
237
+ * otherwise wait for another activity to start.
238
+ */
239
+ internal fun postCrashUiGate(
240
+ application: Application?,
241
+ attribution: HotUpdaterRecoveryAttribution = HotUpdaterRecoveryAttribution.shared,
242
+ processLifecycleStarted: () -> Boolean = ::hasStartedActivity,
243
+ mainThread: HotUpdaterMainThread = HotUpdaterMainThread.android,
244
+ ): ActivityStartGate =
245
+ ActivityStartGate(
246
+ application,
247
+ processHasStartedActivity = { processLifecycleStarted() || attribution.startedActivities().isNotEmpty() },
248
+ showsReactView = { true },
249
+ mainThread = mainThread,
250
+ )
251
+
193
252
  /**
194
253
  * The activities already started that a gate created now checks for
195
254
  * the instance's view: a named instance's, while main counts any
@@ -303,7 +362,10 @@ class HotUpdaterImpl {
303
362
  if (HotUpdaterProcess.isRestartProcess(context)) {
304
363
  return bundleStorage.getFallbackBundleURL()
305
364
  }
306
- return prepareLaunchIfNeeded().bundleUrl
365
+ val bundleUrl = prepareLaunchIfNeeded().bundleUrl
366
+ // Outside the launch's locks, after any rollback its preparation made.
367
+ postCrashUpdateCheck?.startIfPending()
368
+ return bundleUrl
307
369
  }
308
370
 
309
371
  /**
@@ -546,7 +608,7 @@ class HotUpdaterImpl {
546
608
  return@synchronized launchSelection.launchedBundleId
547
609
  }
548
610
 
549
- bundleStorage.settlePreviousLaunch { recoveryManager.consumePendingCrashRecovery() }
611
+ bundleStorage.settlePreviousLaunch { consumePendingCrashRecovery() }
550
612
  bundleStorage.peekLaunchBundleId()
551
613
  }
552
614
 
@@ -627,8 +689,33 @@ class HotUpdaterImpl {
627
689
  current.bundleUrl == next.bundleUrl &&
628
690
  (current.shouldRollbackOnCrash || !next.shouldRollbackOnCrash)
629
691
 
692
+ /**
693
+ * Keeps a native check's request the server answered for main's check
694
+ * after a crash, as JavaScript's checks do; an app that checks only
695
+ * natively has no other. Nothing for a named instance or in a debug
696
+ * build, which have no such check.
697
+ */
698
+ internal fun recordRecoveryRequest(request: NativeCheckRequest) {
699
+ if (postCrashUpdateCheck == null) return
700
+ keyValueStorage.setItem(RecoveryCheckConfig.KEY, RecoveryCheckConfig.of(request).toJson())
701
+ }
702
+
703
+ /**
704
+ * Reads and deletes the crash marker the previous launch left. One that
705
+ * doesn't ask to roll back means the bundle that crashed runs again, so
706
+ * main checks for a fix natively once a launch shows UI
707
+ * ([PostCrashUpdateCheck]); recorded before this launch goes on, as the
708
+ * marker is already gone. One that asks to roll back is settled locally,
709
+ * with any check an earlier crash left pending: the restored bundle
710
+ * passed its own first content.
711
+ */
712
+ private fun consumePendingCrashRecovery(): PendingCrashRecovery? =
713
+ recoveryManager.consumePendingCrashRecovery()?.also {
714
+ if (it.shouldRollback) postCrashUpdateCheck?.clearPending() else postCrashUpdateCheck?.markPending()
715
+ }
716
+
630
717
  private fun selectAndMonitorLaunchLocked(): PreparedLaunch {
631
- val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
718
+ val pendingRecovery = consumePendingCrashRecovery()
632
719
  val selection = bundleStorage.prepareLaunch(pendingRecovery)
633
720
  monitoredLaunch?.let { (current, launch) ->
634
721
  if (!environment.isMain && continuesLaunch(current, selection) && recoveryManager.monitorsLaunch(launch)) {
@@ -38,6 +38,9 @@ internal class HotUpdaterRecoveryManager(
38
38
  context: Context,
39
39
  environment: HotUpdaterInstanceEnvironment = HotUpdaterInstanceEnvironment.main,
40
40
  private val mainThread: HotUpdaterMainThread = HotUpdaterMainThread.android,
41
+ // Main keeps recording crashes after its grace period ([stopMonitoring]);
42
+ // a debug build doesn't, as a RedBox is no reason to check for a fix.
43
+ private val recordsCrashesAfterContent: Boolean = true,
41
44
  ) {
42
45
  private val appContext = context.applicationContext
43
46
  private val name = environment.name
@@ -253,19 +256,32 @@ internal class HotUpdaterRecoveryManager(
253
256
  }
254
257
  }
255
258
 
256
- /** Ends the grace period of the launch of [generation], unless a newer launch started. */
259
+ /**
260
+ * Ends the grace period of the launch of [generation], unless a newer
261
+ * launch started. Main's verified launch stays live for the rest of the
262
+ * process: a later crash still writes its marker, which never asks to
263
+ * roll back but tells the next launch to check for a fix natively, since
264
+ * the bundle's own update check may be what crashes.
265
+ */
257
266
  private fun stopMonitoring(generation: Long) {
258
267
  synchronized(lock) {
259
268
  if (generation != this.generation) return
260
269
  stopTask = null
261
270
  releaseRecoveryWatchdog()
262
- Log.d(TAG, "Stopping crash monitoring for current launch of $name")
263
- launchState.stop()
264
271
  contentAppearedCallback = null
265
272
  crashOfAnotherInstanceCallback = null
266
273
  recoveryRestartCallback = null
267
274
  runtimeActivityCallback = null
268
275
  chargedCallback = null
276
+ if (isMain && recordsCrashesAfterContent && launchState.hasContentAppeared()) {
277
+ // The hooks installed on main's host stay, and the native
278
+ // slot keeps the verified bundle.
279
+ Log.d(TAG, "Stopping crash monitoring for current launch of $name; later crashes are still recorded")
280
+ updateNativeLaunchStateLocked()
281
+ return
282
+ }
283
+ Log.d(TAG, "Stopping crash monitoring for current launch of $name")
284
+ launchState.stop()
269
285
  liveManagers.remove(name, this)
270
286
  updateNativeLaunchStateLocked()
271
287
  }
@@ -543,7 +559,9 @@ internal class HotUpdaterRecoveryManager(
543
559
  crashMarkerFile.absolutePath,
544
560
  launch?.bundleId,
545
561
  launch?.unverified == true || (charged && launch?.shouldRollback == true),
546
- isMain,
562
+ // A debug build's main records no crash after its grace period,
563
+ // a signal included.
564
+ isMain && recordsCrashesAfterContent,
547
565
  )
548
566
  }
549
567
 
@@ -689,7 +707,11 @@ internal class HotUpdaterRecoveryManager(
689
707
  private val attribution: HotUpdaterRecoveryAttribution
690
708
  get() = HotUpdaterRecoveryAttribution.shared
691
709
 
692
- /** The manager of each instance whose launch is monitored. */
710
+ /**
711
+ * The manager of each instance whose launch is monitored, and main's
712
+ * once its launch is verified, which records crashes until the
713
+ * process ends ([stopMonitoring]).
714
+ */
693
715
  private val liveManagers = ConcurrentHashMap<String, HotUpdaterRecoveryManager>()
694
716
 
695
717
  private val signalHandlerLock = Any()
@@ -1,12 +1,15 @@
1
1
  package com.hotupdater
2
2
 
3
3
  import android.app.Activity
4
+ import android.app.ActivityManager
4
5
  import android.app.ActivityOptions
6
+ import android.content.Context
5
7
  import android.content.Intent
6
8
  import android.os.Bundle
7
9
  import android.os.Handler
8
10
  import android.os.Looper
9
11
  import android.os.Process
12
+ import android.os.SystemClock
10
13
  import android.util.Log
11
14
 
12
15
  class HotUpdaterRestartActivity : Activity() {
@@ -28,6 +31,17 @@ class HotUpdaterRestartActivity : Activity() {
28
31
  Process.killProcess(targetPid)
29
32
  }
30
33
 
34
+ // The kill is asynchronous. An activity started while the system still
35
+ // has the old process registered is handed to it and lost with it
36
+ // ("removed with pending start"), which leaves the app closed. The wait
37
+ // stays in onCreate: a Theme.NoDisplay activity must finish before
38
+ // onResume, and once finished this process may no longer start one.
39
+ awaitProcessExit(
40
+ isAlive = { targetPid > 0 && isProcessRegistered(targetPid) },
41
+ sleep = { Thread.sleep(it) },
42
+ now = SystemClock::uptimeMillis,
43
+ )
44
+
31
45
  val options = ActivityOptions.makeCustomAnimation(this, 0, 0)
32
46
  startActivity(launchIntent, options.toBundle())
33
47
  finishWithoutAnimation()
@@ -37,6 +51,11 @@ class HotUpdaterRestartActivity : Activity() {
37
51
  Handler(Looper.getMainLooper()).postDelayed({ killCurrentProcess() }, PROCESS_KILL_DELAY_MS)
38
52
  }
39
53
 
54
+ private fun isProcessRegistered(pid: Int): Boolean {
55
+ val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return false
56
+ return activityManager.runningAppProcesses.orEmpty().any { it.pid == pid }
57
+ }
58
+
40
59
  private fun finishWithoutAnimation() {
41
60
  finish()
42
61
  }
@@ -58,7 +77,27 @@ class HotUpdaterRestartActivity : Activity() {
58
77
  companion object {
59
78
  private const val TAG = "HotUpdaterRestart"
60
79
  private const val PROCESS_KILL_DELAY_MS = 100L
80
+ private const val PROCESS_EXIT_POLL_MS = 25L
81
+
82
+ /** How long the relaunch waits for the old process; it starts anyway after. */
83
+ internal const val PROCESS_EXIT_TIMEOUT_MS = 2_000L
61
84
  const val EXTRA_PACKAGE_NAME = "hot_updater.package_name"
62
85
  const val EXTRA_TARGET_PID = "hot_updater.target_pid"
86
+
87
+ /**
88
+ * Returns once [isAlive] is false, checking every
89
+ * [PROCESS_EXIT_POLL_MS], and at the latest once
90
+ * [PROCESS_EXIT_TIMEOUT_MS] passed. At once when it is already gone.
91
+ */
92
+ internal fun awaitProcessExit(
93
+ isAlive: () -> Boolean,
94
+ sleep: (Long) -> Unit,
95
+ now: () -> Long,
96
+ ) {
97
+ val deadline = now() + PROCESS_EXIT_TIMEOUT_MS
98
+ while (isAlive() && now() < deadline) {
99
+ sleep(PROCESS_EXIT_POLL_MS)
100
+ }
101
+ }
63
102
  }
64
103
  }
@@ -23,6 +23,8 @@ internal class HotUpdaterImplUpdateCheckState(
23
23
 
24
24
  override fun getFingerprintHash(): String? = impl.getFingerprintHash()
25
25
 
26
+ override fun recordRecoveryRequest(request: NativeCheckRequest) = impl.recordRecoveryRequest(request)
27
+
26
28
  override fun getCrashHistory(): List<String> = impl.getCrashHistory()
27
29
 
28
30
  override fun getActiveUpdateState(): ActiveSelections {
@@ -124,6 +124,9 @@ internal interface UpdateCheckState {
124
124
 
125
125
  fun removeReleaseCatalogCache(partition: String): Boolean
126
126
 
127
+ /** The server answered [request]: main keeps it for its check after a crash, as JavaScript's checks do. */
128
+ fun recordRecoveryRequest(request: NativeCheckRequest) {}
129
+
127
130
  fun getStorageItem(key: String): String?
128
131
 
129
132
  fun setStorageItem(
@@ -562,7 +565,7 @@ internal class NativeUpdateChecker(
562
565
  ReleaseCatalogScope.Fingerprint(platform = PLATFORM, channelKey = channelKey, fingerprintHash = strategyValue)
563
566
  }
564
567
  val strategyPath = if (strategy == ReleaseStrategy.APP_VERSION) "app-version" else "fingerprint"
565
- val catalog =
568
+ val answer =
566
569
  catalogClient.fetchReleaseCatalog(
567
570
  baseURL = input.baseURL,
568
571
  url =
@@ -572,11 +575,13 @@ internal class NativeUpdateChecker(
572
575
  requestHeaders = options.requestHeaders,
573
576
  requestTimeoutMillis = options.requestTimeoutMillis,
574
577
  )
575
- // The server says this scope has no catalog yet: no update, not a failure.
576
- ?: return null
577
- if (!ReleaseCatalogs.hasExpectedScope(catalog.scopeKey, expectedScope)) {
578
+ if (answer != null && !ReleaseCatalogs.hasExpectedScope(answer.scopeKey, expectedScope)) {
578
579
  throw InvalidUpdateResponseException("Received an invalid Release catalog")
579
580
  }
581
+ // The server answered with a valid catalog, or with none yet.
582
+ state.recordRecoveryRequest(options)
583
+ // The server says this scope has no catalog yet: no update, not a failure.
584
+ val catalog = answer ?: return null
580
585
  val catalogId = catalog.catalogId
581
586
  val scopeKey = catalog.scopeKey
582
587