@hot-updater/react-native 1.0.0-rc.34 → 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.
@@ -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(
@@ -508,6 +511,16 @@ class HotUpdaterImpl {
508
511
 
509
512
  val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
510
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
+ )
511
524
  recoveryManager.startMonitoring(
512
525
  bundleId = selection.launchedBundleId,
513
526
  shouldRollback = selection.shouldRollbackOnCrash,
@@ -234,141 +234,11 @@ internal class HotUpdaterRecoveryManager(
234
234
  }
235
235
  }
236
236
 
237
+ // Each architecture's source set reaches its own React instance, so the old
238
+ // architecture's ReactNativeHost is compiled only into old architecture builds.
237
239
  private fun installJavaScriptExceptionHooks() {
238
240
  val application = appContext as? ReactApplication ?: return
239
-
240
- if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
241
- installReactHostExceptionHook(application)
242
- } else {
243
- installLegacyExceptionHook(application)
244
- }
245
- }
246
-
247
- private fun installReactHostExceptionHook(application: ReactApplication) {
248
- val reactHost = getReactHost(application) ?: return
249
- val reactHostDelegate =
250
- findField(reactHost.javaClass, "mReactHostDelegate")?.let { field ->
251
- field.isAccessible = true
252
- field.get(reactHost)
253
- }
254
- ?: findField(reactHost.javaClass, "reactHostDelegate")?.let { field ->
255
- field.isAccessible = true
256
- field.get(reactHost)
257
- }
258
- ?: return
259
-
260
- val delegateIdentity = System.identityHashCode(reactHostDelegate)
261
- if (patchedReactHostDelegateIds.add(delegateIdentity)) {
262
- val exceptionHandlerField = findField(reactHostDelegate.javaClass, "exceptionHandler")
263
- if (exceptionHandlerField == null) {
264
- patchedReactHostDelegateIds.remove(delegateIdentity)
265
- return
266
- }
267
-
268
- exceptionHandlerField.isAccessible = true
269
- @Suppress("UNCHECKED_CAST")
270
- val previousHandler =
271
- exceptionHandlerField.get(reactHostDelegate) as? (Exception) -> Unit
272
-
273
- exceptionHandlerField.set(reactHostDelegate) { exception: Exception ->
274
- if (activeManager?.handleJavaScriptException(exception) != true) {
275
- previousHandler?.invoke(exception) ?: throw exception
276
- }
277
- }
278
- }
279
-
280
- val reactContext =
281
- findMethod(reactHost.javaClass, "getCurrentReactContext")?.invoke(reactHost) as? ReactContext
282
- reactContext?.let { patchReactContextExceptionHandler(it) }
283
- }
284
-
285
- private fun installLegacyExceptionHook(application: ReactApplication) {
286
- val instanceManager = application.reactNativeHost.reactInstanceManager
287
- val managerIdentity = System.identityHashCode(instanceManager)
288
- if (patchedInstanceManagerIds.add(managerIdentity)) {
289
- val exceptionHandlerField = findField(instanceManager.javaClass, "mJSExceptionHandler")
290
- if (exceptionHandlerField == null) {
291
- patchedInstanceManagerIds.remove(managerIdentity)
292
- return
293
- }
294
-
295
- exceptionHandlerField.isAccessible = true
296
- val previousHandler = exceptionHandlerField.get(instanceManager) as? JSExceptionHandler
297
- exceptionHandlerField.set(instanceManager, RecoveryJSExceptionHandler(previousHandler))
298
- }
299
-
300
- instanceManager.currentReactContext?.let { reactContext ->
301
- patchReactContextExceptionHandler(reactContext)
302
- patchCatalystInstanceExceptionHandler(reactContext)
303
- }
304
- }
305
-
306
- private fun patchReactContextExceptionHandler(reactContext: ReactContext) {
307
- val contextIdentity = System.identityHashCode(reactContext)
308
- if (!patchedReactContextIds.add(contextIdentity)) {
309
- return
310
- }
311
-
312
- val previousHandler = reactContext.jsExceptionHandler
313
- if (previousHandler is RecoveryJSExceptionHandler) {
314
- return
315
- }
316
-
317
- reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler))
318
- }
319
-
320
- private fun patchCatalystInstanceExceptionHandler(reactContext: ReactContext) {
321
- val catalystInstance =
322
- try {
323
- reactContext.catalystInstance
324
- } catch (_: Exception) {
325
- null
326
- } ?: return
327
-
328
- val catalystIdentity = System.identityHashCode(catalystInstance)
329
- if (!patchedCatalystInstanceIds.add(catalystIdentity)) {
330
- return
331
- }
332
-
333
- val exceptionHandlerField = findField(catalystInstance.javaClass, "mJSExceptionHandler")
334
- if (exceptionHandlerField == null) {
335
- patchedCatalystInstanceIds.remove(catalystIdentity)
336
- return
337
- }
338
-
339
- exceptionHandlerField.isAccessible = true
340
- val previousHandler = exceptionHandlerField.get(catalystInstance) as? JSExceptionHandler
341
- if (previousHandler is RecoveryJSExceptionHandler) {
342
- return
343
- }
344
- exceptionHandlerField.set(catalystInstance, RecoveryJSExceptionHandler(previousHandler))
345
- }
346
-
347
- private fun getReactHost(application: ReactApplication): Any? =
348
- try {
349
- findMethod(application.javaClass, "getReactHost")?.invoke(application)
350
- } catch (_: Exception) {
351
- null
352
- }
353
-
354
- private fun findMethod(
355
- clazz: Class<*>,
356
- name: String,
357
- ) = runCatching { clazz.getMethod(name) }.getOrNull()
358
-
359
- private fun findField(
360
- clazz: Class<*>,
361
- name: String,
362
- ): Field? {
363
- var current: Class<*>? = clazz
364
- while (true) {
365
- val currentClass = current ?: break
366
- runCatching { currentClass.getDeclaredField(name) }
367
- .getOrNull()
368
- ?.let { return it }
369
- current = currentClass.superclass
370
- }
371
- return null
241
+ JavaScriptExceptionHooks.install(application)
372
242
  }
373
243
 
374
244
  private fun loadNativeLibrary(): Boolean {
@@ -395,11 +265,11 @@ internal class HotUpdaterRecoveryManager(
395
265
  shouldRollback: Boolean,
396
266
  )
397
267
 
398
- private class RecoveryJSExceptionHandler(
268
+ internal class RecoveryJSExceptionHandler(
399
269
  private val previousHandler: JSExceptionHandler?,
400
270
  ) : JSExceptionHandler {
401
271
  override fun handleException(e: Exception) {
402
- if (activeManager?.handleJavaScriptException(e) != true) {
272
+ if (!handleJavaScriptExceptionForRecovery(e)) {
403
273
  previousHandler?.handleException(e) ?: throw e
404
274
  }
405
275
  }
@@ -432,10 +302,40 @@ internal class HotUpdaterRecoveryManager(
432
302
  @Volatile
433
303
  private var activeManager: HotUpdaterRecoveryManager? = null
434
304
 
435
- private val patchedInstanceManagerIds = mutableSetOf<Int>()
436
- private val patchedCatalystInstanceIds = mutableSetOf<Int>()
437
305
  private val patchedReactContextIds = mutableSetOf<Int>()
438
- private val patchedReactHostDelegateIds = mutableSetOf<Int>()
306
+
307
+ /** Hands a JavaScript error to the monitored launch; false when it does not recover it. */
308
+ internal fun handleJavaScriptExceptionForRecovery(exception: Exception): Boolean =
309
+ activeManager?.handleJavaScriptException(exception) == true
310
+
311
+ internal fun patchReactContextExceptionHandler(reactContext: ReactContext) {
312
+ val contextIdentity = System.identityHashCode(reactContext)
313
+ if (!patchedReactContextIds.add(contextIdentity)) {
314
+ return
315
+ }
316
+
317
+ val previousHandler = reactContext.jsExceptionHandler
318
+ if (previousHandler is RecoveryJSExceptionHandler) {
319
+ return
320
+ }
321
+
322
+ reactContext.setJSExceptionHandler(RecoveryJSExceptionHandler(previousHandler))
323
+ }
324
+
325
+ internal fun findField(
326
+ clazz: Class<*>,
327
+ name: String,
328
+ ): Field? {
329
+ var current: Class<*>? = clazz
330
+ while (true) {
331
+ val currentClass = current ?: break
332
+ runCatching { currentClass.getDeclaredField(name) }
333
+ .getOrNull()
334
+ ?.let { return it }
335
+ current = currentClass.superclass
336
+ }
337
+ return null
338
+ }
439
339
 
440
340
  @JvmStatic
441
341
  fun handleRecoveryWatchdog(context: Context) {
@@ -0,0 +1,48 @@
1
+ package com.hotupdater
2
+
3
+ import com.facebook.react.ReactApplication
4
+
5
+ /**
6
+ * Routes JavaScript errors of the running React instance to crash recovery.
7
+ * The New Architecture reaches the instance through ReactHost.
8
+ */
9
+ internal object JavaScriptExceptionHooks {
10
+ private val patchedReactHostDelegateIds = mutableSetOf<Int>()
11
+
12
+ fun install(application: ReactApplication) {
13
+ val reactHost = application.reactHost ?: return
14
+ val reactHostDelegate =
15
+ HotUpdaterRecoveryManager.findField(reactHost.javaClass, "mReactHostDelegate")?.let { field ->
16
+ field.isAccessible = true
17
+ field.get(reactHost)
18
+ }
19
+ ?: HotUpdaterRecoveryManager.findField(reactHost.javaClass, "reactHostDelegate")?.let { field ->
20
+ field.isAccessible = true
21
+ field.get(reactHost)
22
+ }
23
+ ?: return
24
+
25
+ val delegateIdentity = System.identityHashCode(reactHostDelegate)
26
+ if (patchedReactHostDelegateIds.add(delegateIdentity)) {
27
+ val exceptionHandlerField =
28
+ HotUpdaterRecoveryManager.findField(reactHostDelegate.javaClass, "exceptionHandler")
29
+ if (exceptionHandlerField == null) {
30
+ patchedReactHostDelegateIds.remove(delegateIdentity)
31
+ return
32
+ }
33
+
34
+ exceptionHandlerField.isAccessible = true
35
+ @Suppress("UNCHECKED_CAST")
36
+ val previousHandler =
37
+ exceptionHandlerField.get(reactHostDelegate) as? (Exception) -> Unit
38
+
39
+ exceptionHandlerField.set(reactHostDelegate) { exception: Exception ->
40
+ if (!HotUpdaterRecoveryManager.handleJavaScriptExceptionForRecovery(exception)) {
41
+ previousHandler?.invoke(exception) ?: throw exception
42
+ }
43
+ }
44
+ }
45
+
46
+ reactHost.currentReactContext?.let(HotUpdaterRecoveryManager::patchReactContextExceptionHandler)
47
+ }
48
+ }
@@ -5,12 +5,10 @@ import android.util.Log
5
5
  import com.facebook.react.ReactApplication
6
6
  import com.facebook.react.ReactHost
7
7
  import com.facebook.react.ReactInstanceEventListener
8
- import com.facebook.react.bridge.JSBundleLoader
9
8
  import com.facebook.react.bridge.ReactApplicationContext
10
9
  import com.facebook.react.bridge.ReactContext
11
10
  import com.facebook.react.common.LifecycleState
12
11
  import kotlinx.coroutines.suspendCancellableCoroutine
13
- import java.lang.reflect.Field
14
12
  import kotlin.coroutines.resume
15
13
 
16
14
  class ReactIntegrationManager(
@@ -89,7 +87,7 @@ class ReactIntegrationManager(
89
87
 
90
88
  /**
91
89
  * Sets the JS bundle.
92
- * Priority: ReactHostHolder (if set) > application.reactHost > reactNativeHost
90
+ * Priority: ReactHostHolder (if set) > application.reactHost > the bridge (React Native 0.81 and older)
93
91
  * @param bundleURL The bundle URL to set
94
92
  */
95
93
  public fun setJSBundle(bundleURL: String) {
@@ -115,19 +113,8 @@ class ReactIntegrationManager(
115
113
  return
116
114
  }
117
115
 
118
- // 4. Fallback to old architecture (reactNativeHost)
119
- @Suppress("DEPRECATION")
120
- val instanceManager = application.reactNativeHost.reactInstanceManager
121
- val bundleLoader: JSBundleLoader? = this.getJSBundlerLoader(bundleURL)
122
- val bundleLoaderField: Field =
123
- instanceManager::class.java.getDeclaredField("mBundleLoader")
124
- bundleLoaderField.isAccessible = true
125
-
126
- if (bundleLoader != null) {
127
- bundleLoaderField.set(instanceManager, bundleLoader)
128
- } else {
129
- bundleLoaderField.set(instanceManager, null)
130
- }
116
+ // 4. Fall back to the bridge, which React Native 0.81 and older can run
117
+ BridgeReactInstance.setJSBundle(application, getJSBundlerLoader(bundleURL))
131
118
  } catch (e: Exception) {
132
119
  Log.d("HotUpdater", "Failed to setJSBundle: ${e.message}")
133
120
  }
@@ -135,7 +122,7 @@ class ReactIntegrationManager(
135
122
 
136
123
  /**
137
124
  * Reload the React Native application.
138
- * Priority: ReactHostHolder (if set) > application.reactHost > reactNativeHost
125
+ * Priority: ReactHostHolder (if set) > application.reactHost > the bridge (React Native 0.81 and older)
139
126
  */
140
127
  public suspend fun reload() {
141
128
  try {
@@ -160,21 +147,8 @@ class ReactIntegrationManager(
160
147
  return
161
148
  }
162
149
 
163
- // 4. Fallback to old architecture (reactNativeHost)
164
- @Suppress("DEPRECATION")
165
- val reactNativeHost = application.reactNativeHost
166
- try {
167
- reactNativeHost.reactInstanceManager.recreateReactContextInBackground()
168
- } catch (e: Exception) {
169
- val currentActivity = reactNativeHost.reactInstanceManager.currentReactContext?.currentActivity
170
- if (currentActivity == null) {
171
- return
172
- }
173
-
174
- currentActivity.runOnUiThread {
175
- currentActivity.recreate()
176
- }
177
- }
150
+ // 4. Fall back to the bridge, which React Native 0.81 and older can run
151
+ BridgeReactInstance.reload(application)
178
152
  } catch (e: Exception) {
179
153
  Log.d("HotUpdater", "Failed to reload: ${e.message}")
180
154
  }
@@ -0,0 +1,71 @@
1
+ package com.hotupdater
2
+
3
+ import com.facebook.react.ReactApplication
4
+ import com.facebook.react.bridge.JSExceptionHandler
5
+ import com.facebook.react.bridge.ReactContext
6
+
7
+ /**
8
+ * Routes JavaScript errors of the running React instance to crash recovery.
9
+ * The old architecture reaches the instance through ReactNativeHost, which only
10
+ * an old architecture build compiles.
11
+ */
12
+ internal object JavaScriptExceptionHooks {
13
+ private val patchedInstanceManagerIds = mutableSetOf<Int>()
14
+ private val patchedCatalystInstanceIds = mutableSetOf<Int>()
15
+
16
+ fun install(application: ReactApplication) {
17
+ val instanceManager = application.reactNativeHost.reactInstanceManager
18
+ val managerIdentity = System.identityHashCode(instanceManager)
19
+ if (patchedInstanceManagerIds.add(managerIdentity)) {
20
+ val exceptionHandlerField =
21
+ HotUpdaterRecoveryManager.findField(instanceManager.javaClass, "mJSExceptionHandler")
22
+ if (exceptionHandlerField == null) {
23
+ patchedInstanceManagerIds.remove(managerIdentity)
24
+ return
25
+ }
26
+
27
+ exceptionHandlerField.isAccessible = true
28
+ val previousHandler = exceptionHandlerField.get(instanceManager) as? JSExceptionHandler
29
+ exceptionHandlerField.set(
30
+ instanceManager,
31
+ HotUpdaterRecoveryManager.RecoveryJSExceptionHandler(previousHandler),
32
+ )
33
+ }
34
+
35
+ instanceManager.currentReactContext?.let { reactContext ->
36
+ HotUpdaterRecoveryManager.patchReactContextExceptionHandler(reactContext)
37
+ patchCatalystInstanceExceptionHandler(reactContext)
38
+ }
39
+ }
40
+
41
+ private fun patchCatalystInstanceExceptionHandler(reactContext: ReactContext) {
42
+ val catalystInstance =
43
+ try {
44
+ reactContext.catalystInstance
45
+ } catch (_: Exception) {
46
+ null
47
+ } ?: return
48
+
49
+ val catalystIdentity = System.identityHashCode(catalystInstance)
50
+ if (!patchedCatalystInstanceIds.add(catalystIdentity)) {
51
+ return
52
+ }
53
+
54
+ val exceptionHandlerField =
55
+ HotUpdaterRecoveryManager.findField(catalystInstance.javaClass, "mJSExceptionHandler")
56
+ if (exceptionHandlerField == null) {
57
+ patchedCatalystInstanceIds.remove(catalystIdentity)
58
+ return
59
+ }
60
+
61
+ exceptionHandlerField.isAccessible = true
62
+ val previousHandler = exceptionHandlerField.get(catalystInstance) as? JSExceptionHandler
63
+ if (previousHandler is HotUpdaterRecoveryManager.RecoveryJSExceptionHandler) {
64
+ return
65
+ }
66
+ exceptionHandlerField.set(
67
+ catalystInstance,
68
+ HotUpdaterRecoveryManager.RecoveryJSExceptionHandler(previousHandler),
69
+ )
70
+ }
71
+ }
@@ -293,6 +293,7 @@ public protocol BundleStorageService {
293
293
  func commitReleaseSelection(_ selection: PersistedSelection) -> Bool
294
294
 
295
295
  // Rollback support
296
+ func markLaunchStarted(bundleId: String?)
296
297
  func markLaunchCompleted(bundleId: String?)
297
298
  func notifyAppReady() -> [String: Any]
298
299
  func getCrashHistory() -> CrashedHistory
@@ -373,6 +374,13 @@ class BundleFileStorageService: BundleStorageService {
373
374
 
374
375
  private var hasPreparedLaunch = false
375
376
  private var currentLaunchReport: LaunchReport?
377
+ // The staged bundle this process launched before its first content. Only that
378
+ // launch is pending; a bundle staged later waits for the next launch.
379
+ private var pendingLaunchBundleId: String?
380
+ // Crash history is written at first content on the main thread while JavaScript can read it.
381
+ private let crashedHistoryLock = NSLock()
382
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
383
+ private var retryHeldBundleId: String?
376
384
  private let activeBundleMetadataLock = NSLock()
377
385
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
378
386
  private let builtInBundleIdProvider: () -> String
@@ -1308,8 +1316,10 @@ class BundleFileStorageService: BundleStorageService {
1308
1316
  )
1309
1317
  }
1310
1318
 
1319
+ /// Rolls back the pending staging bundle. `unfinishedLaunch` means its launch
1320
+ /// ended before first content without a crash marker.
1311
1321
  @discardableResult
1312
- private func rollbackPendingBundle(_ stagingId: String) -> Bool {
1322
+ private func rollbackPendingBundle(_ stagingId: String, unfinishedLaunch: Bool = false) -> Bool {
1313
1323
  guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
1314
1324
  return false
1315
1325
  }
@@ -1318,9 +1328,7 @@ class BundleFileStorageService: BundleStorageService {
1318
1328
  let pendingSelectionTransition = metadata.pendingSelectionTransition
1319
1329
  let stableSelection = metadata.stableSelection
1320
1330
 
1321
- var crashedHistory = loadCrashedHistory()
1322
- crashedHistory.addEntry(stagingId)
1323
- let _ = saveCrashedHistory(crashedHistory)
1331
+ recordFailedLaunch(stagingId, unfinishedLaunch: unfinishedLaunch)
1324
1332
 
1325
1333
  let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
1326
1334
  if case .success(let storeDir) = bundleStoreDir() {
@@ -1988,14 +1996,12 @@ class BundleFileStorageService: BundleStorageService {
1988
1996
  metadata.verificationPending,
1989
1997
  metadata.launchInProgress,
1990
1998
  let stagingBundleId = metadata.stagingBundleId {
1991
- rollbackPendingBundle(stagingBundleId)
1999
+ rollbackPendingBundle(stagingBundleId, unfinishedLaunch: true)
1992
2000
  }
1993
2001
  hasPreparedLaunch = true
2002
+ // A launch is recorded only once it can show UI (markLaunchStarted).
1994
2003
  let selection = selectLaunch(bundle: bundle)
1995
- if selection.shouldRollbackOnCrash, var metadata = loadMetadataOrNull() {
1996
- metadata.launchInProgress = true
1997
- _ = saveMetadata(metadata)
1998
- }
2004
+ pendingLaunchBundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
1999
2005
  return selection
2000
2006
  }
2001
2007
 
@@ -2011,8 +2017,7 @@ class BundleFileStorageService: BundleStorageService {
2011
2017
  */
2012
2018
  func updateBundle(bundleId: String, manifestUrl: URL, manifestFileHash: String, archiveUrl: URL? = nil, assets: [String: ChangedAssetDescriptor], progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<UpdateDelivery, Error>) -> Void) {
2013
2019
  // Check if bundle is in crashed history
2014
- let crashedHistory = loadCrashedHistory()
2015
- if crashedHistory.contains(bundleId) {
2020
+ if getCrashHistory().contains(bundleId) {
2016
2021
  NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
2017
2022
  completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
2018
2023
  return
@@ -2786,7 +2791,24 @@ class BundleFileStorageService: BundleStorageService {
2786
2791
  }
2787
2792
  }
2788
2793
 
2794
+ func markLaunchStarted(bundleId: String?) {
2795
+ // Unlike prepareLaunch, this can run while JavaScript stages a newer bundle.
2796
+ releaseStateLock.lock()
2797
+ defer { releaseStateLock.unlock() }
2798
+ guard let bundleId,
2799
+ var metadata = loadMetadataOrNull(),
2800
+ metadata.verificationPending,
2801
+ !metadata.launchInProgress,
2802
+ metadata.stagingBundleId == bundleId else {
2803
+ return
2804
+ }
2805
+ metadata.launchInProgress = true
2806
+ _ = saveMetadata(metadata)
2807
+ }
2808
+
2789
2809
  func markLaunchCompleted(bundleId: String?) {
2810
+ // Before the guard: content from the built-in bundle (nil) also counts.
2811
+ readyInterruptedLaunchRetry(bundleId)
2790
2812
  guard let bundleId,
2791
2813
  var metadata = loadMetadataOrNull(),
2792
2814
  metadata.verificationPending,
@@ -2818,9 +2840,10 @@ class BundleFileStorageService: BundleStorageService {
2818
2840
 
2819
2841
  func notifyAppReady() -> [String: Any] {
2820
2842
  guard let report = loadLaunchReport() else {
2821
- if let metadata = loadMetadataOrNull(),
2843
+ if let launchedBundleId = pendingLaunchBundleId,
2844
+ let metadata = loadMetadataOrNull(),
2822
2845
  metadata.verificationPending,
2823
- metadata.stagingBundleId != nil {
2846
+ metadata.stagingBundleId == launchedBundleId {
2824
2847
  return ["status": "PENDING"]
2825
2848
  }
2826
2849
  return ["status": LaunchReportStatus.unchanged.rawValue]
@@ -2846,11 +2869,20 @@ class BundleFileStorageService: BundleStorageService {
2846
2869
  }
2847
2870
 
2848
2871
  /**
2849
- * Returns the crashed bundle history.
2872
+ * Returns the bundles this device refuses to install: crash history, plus a
2873
+ * bundle waiting for its retry until a process that may retry it.
2850
2874
  * @return The crashed history object
2851
2875
  */
2852
2876
  func getCrashHistory() -> CrashedHistory {
2853
- return loadCrashedHistory()
2877
+ crashedHistoryLock.lock()
2878
+ defer { crashedHistoryLock.unlock() }
2879
+ var history = loadCrashedHistory()
2880
+ if let interrupted = history.interruptedLaunch,
2881
+ !interrupted.retryReady || interrupted.bundleId == retryHeldBundleId,
2882
+ !history.contains(interrupted.bundleId) {
2883
+ history.bundles.append(CrashedBundleEntry(bundleId: interrupted.bundleId))
2884
+ }
2885
+ return history
2854
2886
  }
2855
2887
 
2856
2888
  /**
@@ -2858,11 +2890,57 @@ class BundleFileStorageService: BundleStorageService {
2858
2890
  * @return true if clearing was successful
2859
2891
  */
2860
2892
  func clearCrashHistory() -> Bool {
2893
+ crashedHistoryLock.lock()
2894
+ defer { crashedHistoryLock.unlock() }
2861
2895
  var history = loadCrashedHistory()
2862
2896
  history.clear()
2897
+ retryHeldBundleId = nil
2863
2898
  return saveCrashedHistory(history)
2864
2899
  }
2865
2900
 
2901
+ /// A crash, or a second unfinished launch of the same bundle, adds the bundle
2902
+ /// to crash history. A first unfinished launch cannot tell a hang from a user
2903
+ /// leaving early, so it leaves the bundle one retry instead.
2904
+ private func recordFailedLaunch(_ bundleId: String, unfinishedLaunch: Bool) {
2905
+ crashedHistoryLock.lock()
2906
+ defer { crashedHistoryLock.unlock() }
2907
+ var history = loadCrashedHistory()
2908
+ let interrupted = history.interruptedLaunch
2909
+ if unfinishedLaunch, interrupted?.bundleId != bundleId {
2910
+ NSLog("[BundleStorage] Launch of \(bundleId) ended before first content; it gets one retry")
2911
+ history.interruptedLaunch = InterruptedLaunch(bundleId: bundleId)
2912
+ retryHeldBundleId = bundleId
2913
+ } else {
2914
+ history.addEntry(bundleId)
2915
+ if interrupted?.bundleId == bundleId {
2916
+ history.interruptedLaunch = nil
2917
+ }
2918
+ }
2919
+ _ = saveCrashedHistory(history)
2920
+ }
2921
+
2922
+ /// First content appeared, so the app works again. A bundle waiting for its
2923
+ /// retry may be installed from the next process on. This process keeps
2924
+ /// refusing it, so the session that recovered never reloads into it. Content
2925
+ /// from that bundle verifies it.
2926
+ private func readyInterruptedLaunchRetry(_ currentBundleId: String?) {
2927
+ crashedHistoryLock.lock()
2928
+ defer { crashedHistoryLock.unlock() }
2929
+ var history = loadCrashedHistory()
2930
+ guard let interrupted = history.interruptedLaunch else {
2931
+ return
2932
+ }
2933
+ if interrupted.bundleId == currentBundleId {
2934
+ history.interruptedLaunch = nil
2935
+ } else if interrupted.retryReady {
2936
+ return
2937
+ } else {
2938
+ history.interruptedLaunch?.retryReady = true
2939
+ retryHeldBundleId = interrupted.bundleId
2940
+ }
2941
+ _ = saveCrashedHistory(history)
2942
+ }
2943
+
2866
2944
  func getInstallId() -> String {
2867
2945
  loadOrCreateInstallIdentity().installId
2868
2946
  }
@@ -222,6 +222,37 @@ public struct CrashedBundleEntry: Codable {
222
222
  }
223
223
  }
224
224
 
225
+ // MARK: - InterruptedLaunch
226
+
227
+ /// A bundle whose launch ended before first content without a crash, waiting
228
+ /// for its one retry. Such a launch cannot tell a hang from a user leaving
229
+ /// early, so the bundle is offered again instead of entering crash history.
230
+ /// A second unfinished launch of it adds it there.
231
+ ///
232
+ /// `retryReady` turns true once a later launch shows content. The retry starts
233
+ /// with the next process, so the session that recovered never reloads into the
234
+ /// same bundle.
235
+ public struct InterruptedLaunch: Codable {
236
+ let bundleId: String
237
+ var retryReady: Bool
238
+
239
+ enum CodingKeys: String, CodingKey {
240
+ case bundleId
241
+ case retryReady
242
+ }
243
+
244
+ init(bundleId: String, retryReady: Bool = false) {
245
+ self.bundleId = bundleId
246
+ self.retryReady = retryReady
247
+ }
248
+
249
+ public init(from decoder: Decoder) throws {
250
+ let values = try decoder.container(keyedBy: CodingKeys.self)
251
+ bundleId = try values.decode(String.self, forKey: .bundleId)
252
+ retryReady = try values.decodeIfPresent(Bool.self, forKey: .retryReady) ?? false
253
+ }
254
+ }
255
+
225
256
  // MARK: - CrashedHistory
226
257
 
227
258
  /// History of crashed bundles
@@ -231,10 +262,30 @@ public struct CrashedHistory: Codable {
231
262
 
232
263
  var bundles: [CrashedBundleEntry]
233
264
  var maxHistorySize: Int
265
+ var interruptedLaunch: InterruptedLaunch?
266
+
267
+ enum CodingKeys: String, CodingKey {
268
+ case bundles
269
+ case maxHistorySize
270
+ case interruptedLaunch
271
+ }
234
272
 
235
- init(bundles: [CrashedBundleEntry] = [], maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize) {
273
+ init(
274
+ bundles: [CrashedBundleEntry] = [],
275
+ maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize,
276
+ interruptedLaunch: InterruptedLaunch? = nil
277
+ ) {
236
278
  self.bundles = bundles
237
279
  self.maxHistorySize = maxHistorySize
280
+ self.interruptedLaunch = interruptedLaunch
281
+ }
282
+
283
+ public init(from decoder: Decoder) throws {
284
+ let values = try decoder.container(keyedBy: CodingKeys.self)
285
+ bundles = try values.decode([CrashedBundleEntry].self, forKey: .bundles)
286
+ maxHistorySize = try values.decode(Int.self, forKey: .maxHistorySize)
287
+ // A malformed retry record must not drop the crash history.
288
+ interruptedLaunch = try? values.decodeIfPresent(InterruptedLaunch.self, forKey: .interruptedLaunch)
238
289
  }
239
290
 
240
291
  static func load(from file: URL) -> CrashedHistory {
@@ -298,6 +349,7 @@ public struct CrashedHistory: Codable {
298
349
 
299
350
  mutating func clear() {
300
351
  bundles.removeAll()
352
+ interruptedLaunch = nil
301
353
  }
302
354
  }
303
355
 
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import React
3
+ import UIKit
3
4
 
4
5
  private func hotUpdaterUncaughtExceptionHandler(_ exception: NSException) {
5
6
  HotUpdaterRecoveryManager.shared.handleUncaughtException(exception)
@@ -58,6 +59,7 @@ private func hotUpdaterGetMinBundleId() -> String {
58
59
  private let releaseCatalogCache: ReleaseCatalogCacheService
59
60
  private let keyValueStorage: KeyValueStorageService
60
61
  private var currentLaunchSelection: LaunchSelection?
62
+ private var launchStartObservers: [NSObjectProtocol] = []
61
63
 
62
64
  private static let DEFAULT_CHANNEL = "production"
63
65
  private static let CHANNEL_STORAGE_KEY = "HotUpdaterChannel"
@@ -703,12 +705,49 @@ private func hotUpdaterGetMinBundleId() -> String {
703
705
 
704
706
  let pendingRecovery = recoveryManager.consumePendingCrashRecovery()
705
707
  let selection = bundleStorage.prepareLaunch(bundle: bundle, pendingRecovery: pendingRecovery)
708
+ markLaunchStartedInForeground(selection)
706
709
  recoveryManager.startMonitoring(bundleId: selection.launchedBundleId, shouldRollback: selection.shouldRollbackOnCrash) { [weak self] launchedBundleId in
707
710
  self?.bundleStorage.markLaunchCompleted(bundleId: launchedBundleId)
708
711
  }
709
712
  currentLaunchSelection = selection
710
713
  return selection
711
714
  }
715
+
716
+ // A background launch, such as a silent push, may never render. Only a
717
+ // launch in the foreground may leave an unfinished launch for the next
718
+ // process to roll back.
719
+ private func markLaunchStartedInForeground(_ selection: LaunchSelection) {
720
+ let bundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
721
+ let mark = { [weak self] in
722
+ guard let self else { return }
723
+ self.removeLaunchStartObservers()
724
+ guard let bundleId else { return }
725
+ guard UIApplication.shared.applicationState == .background else {
726
+ self.bundleStorage.markLaunchStarted(bundleId: bundleId)
727
+ return
728
+ }
729
+ self.launchStartObservers = [
730
+ UIApplication.willEnterForegroundNotification,
731
+ UIApplication.didBecomeActiveNotification,
732
+ ].map { name in
733
+ NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
734
+ self?.removeLaunchStartObservers()
735
+ self?.bundleStorage.markLaunchStarted(bundleId: bundleId)
736
+ }
737
+ }
738
+ }
739
+ // UIApplication is read on the main thread.
740
+ if Thread.isMainThread {
741
+ mark()
742
+ } else {
743
+ DispatchQueue.main.async(execute: mark)
744
+ }
745
+ }
746
+
747
+ private func removeLaunchStartObservers() {
748
+ launchStartObservers.forEach { NotificationCenter.default.removeObserver($0) }
749
+ launchStartObservers = []
750
+ }
712
751
  }
713
752
 
714
753
  @objcMembers
@@ -4,5 +4,5 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.HOT_UPDATER_SDK_VERSION = void 0;
7
- const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "1.0.0-rc.34";
7
+ const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "1.0.0-rc.35";
8
8
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
 
3
- export const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.34";
3
+ export const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.35";
4
4
  //# sourceMappingURL=sdkVersion.js.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.34";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.35";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
@@ -1,2 +1,2 @@
1
- export declare const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.34";
1
+ export declare const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.35";
2
2
  //# sourceMappingURL=sdkVersion.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/react-native",
3
- "version": "1.0.0-rc.34",
3
+ "version": "1.0.0-rc.35",
4
4
  "description": "React Native OTA solution for self-hosted",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/commonjs/index.js",
@@ -123,8 +123,8 @@
123
123
  },
124
124
  "dependencies": {
125
125
  "use-sync-external-store": "1.5.0",
126
- "@hot-updater/protocol": "1.0.0-rc.30",
127
- "@hot-updater/plugin-insights": "1.0.0-rc.34"
126
+ "@hot-updater/protocol": "1.0.0-rc.35",
127
+ "@hot-updater/plugin-insights": "1.0.0-rc.35"
128
128
  },
129
129
  "scripts": {
130
130
  "build": "node ./scripts/sync-sdk-version.mjs run -- bob build",
package/src/sdkVersion.ts CHANGED
@@ -1 +1 @@
1
- export const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.34";
1
+ export const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.35";