@hot-updater/react-native 1.1.0 → 1.1.2

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 (45) hide show
  1. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
  2. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
  3. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +2 -1
  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/HotUpdaterImpl.swift +125 -3
  10. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +90 -10
  11. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +2 -0
  12. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +15 -4
  13. package/ios/HotUpdater/Internal/PostCrashUpdateCheck.swift +248 -0
  14. package/ios/HotUpdater/Package.swift +1 -0
  15. package/lib/commonjs/checkForUpdate.js +10 -1
  16. package/lib/commonjs/checkForUpdate.js.map +1 -1
  17. package/lib/commonjs/httpClient.js +1 -0
  18. package/lib/commonjs/httpClient.js.map +1 -1
  19. package/lib/commonjs/recoveryCheckConfig.js +58 -0
  20. package/lib/commonjs/recoveryCheckConfig.js.map +1 -0
  21. package/lib/commonjs/sdkVersion.js +1 -1
  22. package/lib/module/checkForUpdate.js +10 -1
  23. package/lib/module/checkForUpdate.js.map +1 -1
  24. package/lib/module/httpClient.js +1 -0
  25. package/lib/module/httpClient.js.map +1 -1
  26. package/lib/module/recoveryCheckConfig.js +54 -0
  27. package/lib/module/recoveryCheckConfig.js.map +1 -0
  28. package/lib/module/sdkVersion.js +1 -1
  29. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/httpClient.d.ts +2 -0
  31. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts +28 -0
  33. package/lib/typescript/commonjs/recoveryCheckConfig.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  35. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  36. package/lib/typescript/module/httpClient.d.ts +2 -0
  37. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  38. package/lib/typescript/module/recoveryCheckConfig.d.ts +28 -0
  39. package/lib/typescript/module/recoveryCheckConfig.d.ts.map +1 -0
  40. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  41. package/package.json +4 -4
  42. package/src/checkForUpdate.ts +10 -1
  43. package/src/httpClient.ts +3 -0
  44. package/src/recoveryCheckConfig.ts +68 -0
  45. 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()
@@ -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)) {
@@ -60,7 +60,8 @@ internal class HotUpdaterRecoveryAttribution(
60
60
  } else {
61
61
  rootViewsIn(decorView).map { rootView ->
62
62
  val runtime = runCatching { rootView.currentReactContext }.getOrNull()
63
- HotUpdaterDecorRoots.Root(rootView.rootViewTag, runCatching { runtime?.sourceURL }.getOrNull(), runtime)
63
+ // A call, not `rootViewTag`: Kotlin 2.2 has no property for a getter the Kotlin `ReactRoot` declares.
64
+ HotUpdaterDecorRoots.Root(rootView.getRootViewTag(), runCatching { runtime?.sourceURL }.getOrNull(), runtime)
64
65
  }
65
66
  }
66
67
  }
@@ -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
 
@@ -0,0 +1,195 @@
1
+ package com.hotupdater
2
+
3
+ import android.util.Log
4
+ import kotlinx.coroutines.runBlocking
5
+ import org.json.JSONObject
6
+ import java.util.concurrent.Executors
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+
9
+ /**
10
+ * The update check JavaScript leaves in main's key-value store for the check
11
+ * after a crash: native knows no server of its own. A device whose JavaScript
12
+ * never wrote one, such as one whose user hasn't accepted the app's privacy
13
+ * terms yet, has none, so native sends no request.
14
+ */
15
+ internal class RecoveryCheckConfig(
16
+ val baseURL: String,
17
+ val requestHeaders: Map<String, String>,
18
+ val requestTimeoutMillis: Long,
19
+ val updateStrategy: HotUpdaterUpdateStrategy,
20
+ ) {
21
+ /** The check on the current channel, as JavaScript's own check makes it. */
22
+ fun toRequest(): NativeCheckRequest =
23
+ NativeCheckRequest(
24
+ baseURL = baseURL,
25
+ updateStrategy = updateStrategy,
26
+ requestHeaders = requestHeaders,
27
+ requestTimeoutMillis = requestTimeoutMillis,
28
+ )
29
+
30
+ /** This record as JavaScript writes it, which [parse] reads back. */
31
+ fun toJson(): String =
32
+ JSONObject()
33
+ .put("version", VERSION)
34
+ .put("baseURL", baseURL)
35
+ .put("requestHeaders", JSONObject(requestHeaders))
36
+ .put("requestTimeoutMs", requestTimeoutMillis)
37
+ .put("updateStrategy", updateStrategy.value)
38
+ .toString()
39
+
40
+ companion object {
41
+ const val KEY = "hot-updater/recovery-check-config"
42
+ private const val VERSION = 1
43
+ private const val DEFAULT_TIMEOUT_MS = 5_000L
44
+
45
+ /**
46
+ * The record of a native check of main the server answered: its
47
+ * headers as the request sent them ([effectiveHeaders]). Its channel
48
+ * is left out, as JavaScript leaves it out: the check after a crash
49
+ * uses the device's own.
50
+ */
51
+ fun of(request: NativeCheckRequest): RecoveryCheckConfig =
52
+ RecoveryCheckConfig(
53
+ request.baseURL,
54
+ effectiveHeaders(request.requestHeaders),
55
+ request.requestTimeoutMillis,
56
+ request.updateStrategy,
57
+ )
58
+
59
+ /**
60
+ * The record [value] holds, or null for a missing, unparsable,
61
+ * other-version, or invalid one: no absolute HTTP(S) baseURL, an
62
+ * unknown strategy, headers that aren't strings, or a timeout that
63
+ * isn't a positive number. A record JavaScript writes always has
64
+ * headers and a timeout; one without them takes the defaults.
65
+ */
66
+ fun parse(value: String?): RecoveryCheckConfig? {
67
+ val json = value?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
68
+ if (json.opt("version") != VERSION) return null
69
+ val baseURL = json.opt("baseURL") as? String ?: return null
70
+ if (runCatching { requireValidBaseURL(baseURL) }.isFailure) return null
71
+ val strategy = json.opt("updateStrategy") as? String
72
+ val updateStrategy = HotUpdaterUpdateStrategy.values().firstOrNull { it.value == strategy } ?: return null
73
+ val headers = LinkedHashMap<String, String>()
74
+ if (json.has("requestHeaders")) {
75
+ val values = json.opt("requestHeaders") as? JSONObject ?: return null
76
+ values.keys().forEach { name -> headers[name] = values.opt(name) as? String ?: return null }
77
+ }
78
+ val timeout =
79
+ if (json.has("requestTimeoutMs")) {
80
+ val number = json.opt("requestTimeoutMs") as? Number ?: return null
81
+ number.toDouble().takeIf { it.isFinite() && it >= 1 }?.toLong() ?: return null
82
+ } else {
83
+ DEFAULT_TIMEOUT_MS
84
+ }
85
+ return RecoveryCheckConfig(baseURL, headers, timeout, updateStrategy)
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Main's update check at the launch after a crash. Since first content
92
+ * verifies a launch, a bundle whose own update flow crashes later can never
93
+ * fetch its fix from JavaScript; native checks once, in the background, and
94
+ * stages what it finds for the next launch, with no reload.
95
+ *
96
+ * A crash leaves its marker, which the next launch's preparation consumes:
97
+ * [markPending] records that in the key-value store, so a launch that ends
98
+ * before the check finishes, or shows no UI, leaves it for the next launch
99
+ * that does. [startIfPending] then runs the check once per process, after the
100
+ * launch is prepared and outside its locks, once a started activity shows UI.
101
+ *
102
+ * @param whenUiShown runs its action once this process shows UI, at once
103
+ * when it already does; on the main thread
104
+ * @param check checks with main's native checker, which queues the plugin
105
+ * events JavaScript replays
106
+ * @param background runs work off the thread React Native starts on
107
+ */
108
+ internal class PostCrashUpdateCheck(
109
+ private val getItem: (String) -> String?,
110
+ private val setItem: (String, String?) -> Unit,
111
+ private val whenUiShown: ((() -> Unit) -> Unit),
112
+ private val check: suspend (NativeCheckRequest) -> HotUpdaterUpdate?,
113
+ private val isRestartProcess: () -> Boolean,
114
+ private val background: (Runnable) -> Unit = defaultBackground,
115
+ ) {
116
+ private val started = AtomicBoolean(false)
117
+
118
+ /** Main's launch preparation consumed a crash marker that doesn't ask to roll back. */
119
+ fun markPending() {
120
+ setItem(PENDING_KEY, PENDING_VALUE)
121
+ }
122
+
123
+ /**
124
+ * Main's launch preparation consumed a marker that asks to roll back: the
125
+ * rollback settles that crash locally, and a check an earlier crash left
126
+ * pending with it.
127
+ */
128
+ fun clearPending() {
129
+ setItem(PENDING_KEY, null)
130
+ }
131
+
132
+ /**
133
+ * Starts the check when a crash left it pending and this process hasn't
134
+ * run it yet. Returns at once: reading the store is left to [background].
135
+ */
136
+ fun startIfPending() {
137
+ if (started.get()) return
138
+ background(
139
+ Runnable {
140
+ // Hot Updater's restart process only relaunches the app.
141
+ if (isRestartProcess()) return@Runnable
142
+ if (getItem(PENDING_KEY) != PENDING_VALUE) return@Runnable
143
+ // Without a server there is nothing to ask, and the check
144
+ // waits for a launch whose JavaScript left one.
145
+ if (RecoveryCheckConfig.parse(getItem(RecoveryCheckConfig.KEY)) == null) {
146
+ log("skipped no config")
147
+ return@Runnable
148
+ }
149
+ // A launch without the flag must not use up the process's check.
150
+ if (!started.compareAndSet(false, true)) return@Runnable
151
+ whenUiShown { background(Runnable { run() }) }
152
+ },
153
+ )
154
+ }
155
+
156
+ private fun run() {
157
+ // Read again: JavaScript may have saved a newer one while the app came up.
158
+ val config = RecoveryCheckConfig.parse(getItem(RecoveryCheckConfig.KEY))
159
+ if (config == null) {
160
+ log("skipped no config")
161
+ return
162
+ }
163
+ log("started")
164
+ try {
165
+ val update = runBlocking { check(config.toRequest()) }
166
+ when {
167
+ update == null -> log("no update")
168
+ runBlocking { update.updateBundle() } -> log("staged ${update.id}")
169
+ else -> log("skipped superseded")
170
+ }
171
+ } catch (error: Exception) {
172
+ log("failed ${error.message ?: error.javaClass.simpleName}")
173
+ } finally {
174
+ // The attempt finished, whatever it found; a process that died
175
+ // during it leaves the flag for the next launch to try again.
176
+ setItem(PENDING_KEY, null)
177
+ }
178
+ }
179
+
180
+ private fun log(status: String) {
181
+ Log.i(TAG, "[HotUpdaterRecovery] Post-crash update check: $status")
182
+ }
183
+
184
+ companion object {
185
+ const val PENDING_KEY = "hot-updater/recovery-check-pending"
186
+ private const val PENDING_VALUE = "1"
187
+ private const val TAG = "HotUpdaterRecovery"
188
+
189
+ private val executor by lazy {
190
+ Executors.newSingleThreadExecutor { task -> Thread(task, "HotUpdaterPostCrashCheck").apply { isDaemon = true } }
191
+ }
192
+
193
+ private val defaultBackground: (Runnable) -> Unit = { task -> executor.execute(task) }
194
+ }
195
+ }