@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.
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +1 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +91 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +2 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +27 -5
- package/android/src/main/java/com/hotupdater/HotUpdaterRestartActivity.kt +39 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +2 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +9 -4
- package/android/src/main/java/com/hotupdater/PostCrashUpdateCheck.kt +195 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +125 -3
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +90 -10
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +2 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +15 -4
- package/ios/HotUpdater/Internal/PostCrashUpdateCheck.swift +248 -0
- package/ios/HotUpdater/Package.swift +1 -0
- package/lib/commonjs/checkForUpdate.js +10 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/httpClient.js +1 -0
- package/lib/commonjs/httpClient.js.map +1 -1
- package/lib/commonjs/recoveryCheckConfig.js +58 -0
- package/lib/commonjs/recoveryCheckConfig.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/module/checkForUpdate.js +10 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/httpClient.js +1 -0
- package/lib/module/httpClient.js.map +1 -1
- package/lib/module/recoveryCheckConfig.js +54 -0
- package/lib/module/recoveryCheckConfig.js.map +1 -0
- package/lib/module/sdkVersion.js +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/httpClient.d.ts +2 -0
- package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/recoveryCheckConfig.d.ts +28 -0
- package/lib/typescript/commonjs/recoveryCheckConfig.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/httpClient.d.ts +2 -0
- package/lib/typescript/module/httpClient.d.ts.map +1 -1
- package/lib/typescript/module/recoveryCheckConfig.d.ts +28 -0
- package/lib/typescript/module/recoveryCheckConfig.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +10 -1
- package/src/httpClient.ts +3 -0
- package/src/recoveryCheckConfig.ts +68 -0
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|