@hot-updater/react-native 0.36.17 → 0.36.19
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/build.gradle +3 -0
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +94 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +121 -24
- package/android/src/main/java/com/hotupdater/BundleMetadata.kt +39 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +13 -0
- package/android/src/test/java/com/hotupdater/ActivityStartGateTest.kt +118 -0
- package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +273 -0
- package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +2 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +101 -24
- package/ios/HotUpdater/Internal/BundleMetadata.swift +53 -1
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +39 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +28 -2
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/package.json +6 -6
- package/src/sdkVersion.ts +1 -1
package/android/build.gradle
CHANGED
|
@@ -146,6 +146,9 @@ dependencies {
|
|
|
146
146
|
}
|
|
147
147
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
148
148
|
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
|
|
149
|
+
// ProcessLifecycleOwner, for an activity that started before Hot Updater was created.
|
|
150
|
+
// React Native's AppCompat already brings it at runtime.
|
|
151
|
+
implementation "androidx.lifecycle:lifecycle-process:2.6.1"
|
|
149
152
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
|
|
150
153
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
|
151
154
|
implementation "org.apache.commons:commons-compress:1.27.1"
|
|
@@ -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)
|
|
@@ -98,6 +98,12 @@ interface BundleStorageService {
|
|
|
98
98
|
progressCallback: (UpdateProgressPayload) -> Unit,
|
|
99
99
|
)
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Records that the current launch of a verification-pending bundle can show UI.
|
|
103
|
+
* The next process rolls the bundle back unless first content verified it.
|
|
104
|
+
*/
|
|
105
|
+
fun markLaunchStarted(launchedBundleId: String?)
|
|
106
|
+
|
|
101
107
|
/**
|
|
102
108
|
* Marks the current launch as successful after the first content appeared.
|
|
103
109
|
*/
|
|
@@ -109,7 +115,8 @@ interface BundleStorageService {
|
|
|
109
115
|
fun notifyAppReady(): Map<String, Any?>
|
|
110
116
|
|
|
111
117
|
/**
|
|
112
|
-
* Gets the crashed bundle history
|
|
118
|
+
* Gets the crashed bundle history, plus a bundle that waits for its retry after
|
|
119
|
+
* an unfinished launch while this process still refuses it (see [InterruptedLaunch])
|
|
113
120
|
* @return CrashedHistory containing crashed bundles
|
|
114
121
|
*/
|
|
115
122
|
fun getCrashHistory(): CrashedHistory
|
|
@@ -488,6 +495,16 @@ class BundleFileStorageService(
|
|
|
488
495
|
private var hasPreparedLaunch = false
|
|
489
496
|
private var currentLaunchReport: LaunchReport? = null
|
|
490
497
|
|
|
498
|
+
// Crash history is written at first content on the main thread while JS can read it.
|
|
499
|
+
private val crashedHistoryLock = Any()
|
|
500
|
+
|
|
501
|
+
// markLaunchStarted runs at an activity start, which can come while JS stages a
|
|
502
|
+
// newer bundle; both read and rewrite metadata.
|
|
503
|
+
private val stagingMetadataLock = Any()
|
|
504
|
+
|
|
505
|
+
// A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
|
|
506
|
+
private var retryHeldBundleId: String? = null
|
|
507
|
+
|
|
491
508
|
@Volatile
|
|
492
509
|
private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
|
|
493
510
|
private val activeBundleMetadataLock = Any()
|
|
@@ -936,7 +953,14 @@ class BundleFileStorageService(
|
|
|
936
953
|
)
|
|
937
954
|
}
|
|
938
955
|
|
|
939
|
-
|
|
956
|
+
/**
|
|
957
|
+
* Rolls back the pending staging bundle. [unfinishedLaunch] means its launch
|
|
958
|
+
* ended before first content without a crash marker.
|
|
959
|
+
*/
|
|
960
|
+
private fun rollbackPendingBundle(
|
|
961
|
+
stagingBundleId: String,
|
|
962
|
+
unfinishedLaunch: Boolean = false,
|
|
963
|
+
): Boolean {
|
|
940
964
|
val metadata = loadMetadataOrNull() ?: return false
|
|
941
965
|
if (metadata.stagingBundleId != stagingBundleId) {
|
|
942
966
|
return false
|
|
@@ -944,9 +968,7 @@ class BundleFileStorageService(
|
|
|
944
968
|
|
|
945
969
|
Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
|
|
946
970
|
|
|
947
|
-
|
|
948
|
-
crashedHistory.addEntry(stagingBundleId)
|
|
949
|
-
saveCrashedHistory(crashedHistory)
|
|
971
|
+
recordFailedLaunch(stagingBundleId, unfinishedLaunch)
|
|
950
972
|
|
|
951
973
|
val fallbackBundleId =
|
|
952
974
|
metadata.stableBundleId?.takeIf { candidate ->
|
|
@@ -1034,18 +1056,103 @@ class BundleFileStorageService(
|
|
|
1034
1056
|
|
|
1035
1057
|
private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
|
|
1036
1058
|
|
|
1037
|
-
private fun isBundleInCrashedHistory(bundleId: String): Boolean =
|
|
1059
|
+
private fun isBundleInCrashedHistory(bundleId: String): Boolean = getCrashHistory().contains(bundleId)
|
|
1038
1060
|
|
|
1039
|
-
|
|
1061
|
+
/**
|
|
1062
|
+
* The bundles this device refuses to install: crash history, plus a bundle
|
|
1063
|
+
* waiting for its retry until a process that may retry it.
|
|
1064
|
+
*/
|
|
1065
|
+
override fun getCrashHistory(): CrashedHistory =
|
|
1066
|
+
synchronized(crashedHistoryLock) {
|
|
1067
|
+
val history = loadCrashedHistory()
|
|
1068
|
+
val interrupted = history.interruptedLaunch
|
|
1069
|
+
if (interrupted != null &&
|
|
1070
|
+
(!interrupted.retryReady || interrupted.bundleId == retryHeldBundleId) &&
|
|
1071
|
+
!history.contains(interrupted.bundleId)
|
|
1072
|
+
) {
|
|
1073
|
+
history.bundles.add(CrashedBundleEntry(interrupted.bundleId, crashedAt = System.currentTimeMillis()))
|
|
1074
|
+
}
|
|
1075
|
+
history
|
|
1076
|
+
}
|
|
1040
1077
|
|
|
1041
1078
|
override fun clearCrashHistory(): Boolean {
|
|
1042
|
-
|
|
1043
|
-
|
|
1079
|
+
synchronized(crashedHistoryLock) {
|
|
1080
|
+
saveCrashedHistory(CrashedHistory())
|
|
1081
|
+
retryHeldBundleId = null
|
|
1082
|
+
}
|
|
1044
1083
|
Log.d(TAG, "Cleared crash history")
|
|
1045
1084
|
return true
|
|
1046
1085
|
}
|
|
1047
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* A crash, or a second unfinished launch of the same bundle, adds the bundle to
|
|
1089
|
+
* crash history. A first unfinished launch cannot tell a hang from a user leaving
|
|
1090
|
+
* early, so it leaves the bundle one retry instead.
|
|
1091
|
+
*/
|
|
1092
|
+
private fun recordFailedLaunch(
|
|
1093
|
+
bundleId: String,
|
|
1094
|
+
unfinishedLaunch: Boolean,
|
|
1095
|
+
) {
|
|
1096
|
+
synchronized(crashedHistoryLock) {
|
|
1097
|
+
val history = loadCrashedHistory()
|
|
1098
|
+
val interrupted = history.interruptedLaunch
|
|
1099
|
+
if (unfinishedLaunch && interrupted?.bundleId != bundleId) {
|
|
1100
|
+
Log.w(TAG, "Launch of $bundleId ended before first content; it gets one retry")
|
|
1101
|
+
history.interruptedLaunch = InterruptedLaunch(bundleId)
|
|
1102
|
+
retryHeldBundleId = bundleId
|
|
1103
|
+
} else {
|
|
1104
|
+
history.addEntry(bundleId)
|
|
1105
|
+
if (interrupted?.bundleId == bundleId) {
|
|
1106
|
+
history.interruptedLaunch = null
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
saveCrashedHistory(history)
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* First content appeared, so the app works again. A bundle waiting for its retry
|
|
1115
|
+
* may be installed from the next process on. This process keeps refusing it, so
|
|
1116
|
+
* the session that recovered never reloads into it. Content from that bundle
|
|
1117
|
+
* verifies it.
|
|
1118
|
+
*/
|
|
1119
|
+
private fun readyInterruptedLaunchRetry(currentBundleId: String?) {
|
|
1120
|
+
synchronized(crashedHistoryLock) {
|
|
1121
|
+
val history = loadCrashedHistory()
|
|
1122
|
+
val interrupted = history.interruptedLaunch ?: return
|
|
1123
|
+
if (interrupted.bundleId == currentBundleId) {
|
|
1124
|
+
history.interruptedLaunch = null
|
|
1125
|
+
} else if (interrupted.retryReady) {
|
|
1126
|
+
return
|
|
1127
|
+
} else {
|
|
1128
|
+
history.interruptedLaunch = interrupted.copy(retryReady = true)
|
|
1129
|
+
retryHeldBundleId = interrupted.bundleId
|
|
1130
|
+
}
|
|
1131
|
+
saveCrashedHistory(history)
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
private fun saveNewStagingMetadata(bundleId: String): BundleMetadata =
|
|
1136
|
+
synchronized(stagingMetadataLock) {
|
|
1137
|
+
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
1138
|
+
prepareMetadataForNewStagingBundle(currentMetadata, bundleId).also { saveMetadata(it) }
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
override fun markLaunchStarted(launchedBundleId: String?) {
|
|
1142
|
+
synchronized(stagingMetadataLock) {
|
|
1143
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
1144
|
+
if (!isVerificationPending(metadata) ||
|
|
1145
|
+
metadata.launchInProgress ||
|
|
1146
|
+
metadata.stagingBundleId != launchedBundleId
|
|
1147
|
+
) {
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
saveMetadata(metadata.copy(launchInProgress = true))
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1048
1154
|
override fun markLaunchCompleted(currentBundleId: String?) {
|
|
1155
|
+
readyInterruptedLaunchRetry(currentBundleId)
|
|
1049
1156
|
val metadata = loadMetadataOrNull() ?: return
|
|
1050
1157
|
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
1051
1158
|
if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
|
|
@@ -1115,17 +1222,13 @@ class BundleFileStorageService(
|
|
|
1115
1222
|
if (!hasPreparedLaunch) {
|
|
1116
1223
|
val metadata = loadMetadataOrNull()
|
|
1117
1224
|
if (metadata?.verificationPending == true && metadata.launchInProgress) {
|
|
1118
|
-
metadata.stagingBundleId?.let { rollbackPendingBundle(it) }
|
|
1225
|
+
metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
|
|
1119
1226
|
}
|
|
1120
1227
|
}
|
|
1121
1228
|
hasPreparedLaunch = true
|
|
1122
1229
|
|
|
1230
|
+
// A launch is recorded only once it can show UI (markLaunchStarted).
|
|
1123
1231
|
val selection = selectLaunch()
|
|
1124
|
-
if (selection.shouldRollbackOnCrash) {
|
|
1125
|
-
loadMetadataOrNull()?.let { metadata ->
|
|
1126
|
-
saveMetadata(metadata.copy(launchInProgress = true))
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
1232
|
Log.d(
|
|
1130
1233
|
TAG,
|
|
1131
1234
|
"prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
|
|
@@ -1204,9 +1307,7 @@ class BundleFileStorageService(
|
|
|
1204
1307
|
finalBundleDir.setLastModified(System.currentTimeMillis())
|
|
1205
1308
|
|
|
1206
1309
|
// Update metadata: set as staging
|
|
1207
|
-
val
|
|
1208
|
-
val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
|
|
1209
|
-
saveMetadata(updatedMetadata)
|
|
1310
|
+
val updatedMetadata = saveNewStagingMetadata(bundleId)
|
|
1210
1311
|
|
|
1211
1312
|
// Set bundle URL for backwards compatibility
|
|
1212
1313
|
setBundleURL(existingBundleFile.absolutePath)
|
|
@@ -1437,9 +1538,7 @@ class BundleFileStorageService(
|
|
|
1437
1538
|
Log.d(TAG, "Setting bundle as staging: $bundlePath")
|
|
1438
1539
|
|
|
1439
1540
|
// Update metadata: set new bundle as staging
|
|
1440
|
-
val
|
|
1441
|
-
val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
|
|
1442
|
-
saveMetadata(updatedMetadata)
|
|
1541
|
+
val updatedMetadata = saveNewStagingMetadata(bundleId)
|
|
1443
1542
|
|
|
1444
1543
|
// Also update HotUpdaterBundleURL for backwards compatibility
|
|
1445
1544
|
// This will point to the staging bundle that will be loaded
|
|
@@ -1800,9 +1899,7 @@ class BundleFileStorageService(
|
|
|
1800
1899
|
|
|
1801
1900
|
finalBundleDir.setLastModified(System.currentTimeMillis())
|
|
1802
1901
|
|
|
1803
|
-
val
|
|
1804
|
-
val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
|
|
1805
|
-
saveMetadata(updatedMetadata)
|
|
1902
|
+
val updatedMetadata = saveNewStagingMetadata(bundleId)
|
|
1806
1903
|
setBundleURL(finalIndexFile.absolutePath)
|
|
1807
1904
|
|
|
1808
1905
|
tempDir.deleteRecursively()
|
|
@@ -129,12 +129,48 @@ data class CrashedBundleEntry(
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A bundle whose launch ended before first content without a crash, waiting
|
|
134
|
+
* for its one retry. Such a launch cannot tell a hang from a user leaving
|
|
135
|
+
* early, so the bundle is offered again instead of entering crash history.
|
|
136
|
+
* A second unfinished launch of it adds it there.
|
|
137
|
+
*
|
|
138
|
+
* [retryReady] turns true once a later launch shows content. The retry
|
|
139
|
+
* starts with the next process, so the session that recovered never reloads
|
|
140
|
+
* into the same bundle.
|
|
141
|
+
*/
|
|
142
|
+
data class InterruptedLaunch(
|
|
143
|
+
val bundleId: String,
|
|
144
|
+
val retryReady: Boolean = false,
|
|
145
|
+
) {
|
|
146
|
+
companion object {
|
|
147
|
+
fun fromJson(json: JSONObject): InterruptedLaunch? =
|
|
148
|
+
if (json.isNull("bundleId")) {
|
|
149
|
+
null
|
|
150
|
+
} else {
|
|
151
|
+
json.getString("bundleId").takeIf { it.isNotEmpty() }?.let { bundleId ->
|
|
152
|
+
InterruptedLaunch(
|
|
153
|
+
bundleId = bundleId,
|
|
154
|
+
retryReady = json.optBoolean("retryReady", false),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
fun toJson(): JSONObject =
|
|
161
|
+
JSONObject().apply {
|
|
162
|
+
put("bundleId", bundleId)
|
|
163
|
+
put("retryReady", retryReady)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
/**
|
|
133
168
|
* History of crashed bundles
|
|
134
169
|
*/
|
|
135
170
|
data class CrashedHistory(
|
|
136
171
|
val bundles: MutableList<CrashedBundleEntry> = mutableListOf(),
|
|
137
172
|
val maxHistorySize: Int = DEFAULT_MAX_HISTORY_SIZE,
|
|
173
|
+
var interruptedLaunch: InterruptedLaunch? = null,
|
|
138
174
|
) {
|
|
139
175
|
companion object {
|
|
140
176
|
private const val TAG = "CrashedHistory"
|
|
@@ -150,6 +186,7 @@ data class CrashedHistory(
|
|
|
150
186
|
return CrashedHistory(
|
|
151
187
|
bundles = bundles,
|
|
152
188
|
maxHistorySize = json.optInt("maxHistorySize", DEFAULT_MAX_HISTORY_SIZE),
|
|
189
|
+
interruptedLaunch = json.optJSONObject("interruptedLaunch")?.let(InterruptedLaunch::fromJson),
|
|
153
190
|
)
|
|
154
191
|
}
|
|
155
192
|
|
|
@@ -175,6 +212,7 @@ data class CrashedHistory(
|
|
|
175
212
|
bundles.forEach { bundlesArray.put(it.toJson()) }
|
|
176
213
|
put("bundles", bundlesArray)
|
|
177
214
|
put("maxHistorySize", maxHistorySize)
|
|
215
|
+
interruptedLaunch?.let { put("interruptedLaunch", it.toJson()) }
|
|
178
216
|
}
|
|
179
217
|
|
|
180
218
|
fun saveToFile(file: File): Boolean =
|
|
@@ -222,6 +260,7 @@ data class CrashedHistory(
|
|
|
222
260
|
|
|
223
261
|
fun clear() {
|
|
224
262
|
bundles.clear()
|
|
263
|
+
interruptedLaunch = null
|
|
225
264
|
}
|
|
226
265
|
}
|
|
227
266
|
|
|
@@ -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
|
|
@@ -16,6 +17,7 @@ class HotUpdaterImpl {
|
|
|
16
17
|
private val bundleStorage: BundleStorageService
|
|
17
18
|
private val preferences: PreferencesService
|
|
18
19
|
private val recoveryManager: HotUpdaterRecoveryManager
|
|
20
|
+
private val activityStartGate: ActivityStartGate
|
|
19
21
|
private var currentLaunchSelection: LaunchSelection? = null
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -29,6 +31,7 @@ class HotUpdaterImpl {
|
|
|
29
31
|
this.context = context.applicationContext
|
|
30
32
|
this.bundleStorage = bundleStorage
|
|
31
33
|
this.preferences = preferences
|
|
34
|
+
this.activityStartGate = ActivityStartGate(this.context as? Application)
|
|
32
35
|
this.recoveryManager = HotUpdaterRecoveryManager(this.context)
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -439,6 +442,16 @@ class HotUpdaterImpl {
|
|
|
439
442
|
|
|
440
443
|
val pendingRecovery = recoveryManager.consumePendingCrashRecovery()
|
|
441
444
|
val selection = bundleStorage.prepareLaunch(pendingRecovery)
|
|
445
|
+
// A headless JS task, such as a background push, loads the bundle without
|
|
446
|
+
// an activity and never renders. Only a launch with an activity may leave
|
|
447
|
+
// an unfinished launch for the next process to roll back.
|
|
448
|
+
activityStartGate.runWhenActivityStarted(
|
|
449
|
+
if (selection.shouldRollbackOnCrash) {
|
|
450
|
+
{ bundleStorage.markLaunchStarted(selection.launchedBundleId) }
|
|
451
|
+
} else {
|
|
452
|
+
null
|
|
453
|
+
},
|
|
454
|
+
)
|
|
442
455
|
recoveryManager.startMonitoring(
|
|
443
456
|
bundleId = selection.launchedBundleId,
|
|
444
457
|
shouldRollback = selection.shouldRollbackOnCrash,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
class ActivityStartGateTest {
|
|
9
|
+
private val activity = Activity()
|
|
10
|
+
|
|
11
|
+
@Test
|
|
12
|
+
fun `headless launch waits for the first activity start`() {
|
|
13
|
+
val application = RecordingApplication()
|
|
14
|
+
val gate = createGate(application)
|
|
15
|
+
var runs = 0
|
|
16
|
+
|
|
17
|
+
gate.runWhenActivityStarted { runs += 1 }
|
|
18
|
+
assertEquals(0, runs)
|
|
19
|
+
|
|
20
|
+
application.startActivity()
|
|
21
|
+
application.startActivity()
|
|
22
|
+
assertEquals(1, runs)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun `launch with a started activity runs at once`() {
|
|
27
|
+
val application = RecordingApplication()
|
|
28
|
+
val gate = createGate(application)
|
|
29
|
+
var runs = 0
|
|
30
|
+
|
|
31
|
+
application.startActivity()
|
|
32
|
+
gate.runWhenActivityStarted { runs += 1 }
|
|
33
|
+
assertEquals(1, runs)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
fun `stopped activity waits for the next start`() {
|
|
38
|
+
val application = RecordingApplication()
|
|
39
|
+
val gate = createGate(application)
|
|
40
|
+
var runs = 0
|
|
41
|
+
|
|
42
|
+
application.startActivity()
|
|
43
|
+
application.stopActivity()
|
|
44
|
+
gate.runWhenActivityStarted { runs += 1 }
|
|
45
|
+
assertEquals(0, runs)
|
|
46
|
+
|
|
47
|
+
application.startActivity()
|
|
48
|
+
assertEquals(1, runs)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
fun `stop of an activity started before the gate keeps later starts counted`() {
|
|
53
|
+
val application = RecordingApplication()
|
|
54
|
+
val gate = createGate(application)
|
|
55
|
+
var runs = 0
|
|
56
|
+
|
|
57
|
+
application.stopActivity()
|
|
58
|
+
application.startActivity()
|
|
59
|
+
gate.runWhenActivityStarted { runs += 1 }
|
|
60
|
+
|
|
61
|
+
assertEquals(1, runs)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Test
|
|
65
|
+
fun `next launch replaces or cancels the waiting one`() {
|
|
66
|
+
val application = RecordingApplication()
|
|
67
|
+
val gate = createGate(application)
|
|
68
|
+
val runs = mutableListOf<String>()
|
|
69
|
+
|
|
70
|
+
gate.runWhenActivityStarted { runs += "first" }
|
|
71
|
+
gate.runWhenActivityStarted { runs += "reload" }
|
|
72
|
+
application.startActivity()
|
|
73
|
+
application.stopActivity()
|
|
74
|
+
gate.runWhenActivityStarted { runs += "verified" }
|
|
75
|
+
gate.runWhenActivityStarted(null)
|
|
76
|
+
application.startActivity()
|
|
77
|
+
|
|
78
|
+
assertEquals(listOf("reload"), runs)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test
|
|
82
|
+
fun `activity started before the gate runs at once`() {
|
|
83
|
+
// A brownfield app adds React Native to an activity already on screen,
|
|
84
|
+
// and Android does not replay that start to a late registration.
|
|
85
|
+
val application = RecordingApplication()
|
|
86
|
+
var runs = 0
|
|
87
|
+
|
|
88
|
+
createGate(application, startedBefore = true).runWhenActivityStarted { runs += 1 }
|
|
89
|
+
|
|
90
|
+
assertEquals(1, runs)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test
|
|
94
|
+
fun `runs at once without an application to observe`() {
|
|
95
|
+
var runs = 0
|
|
96
|
+
|
|
97
|
+
createGate(null).runWhenActivityStarted { runs += 1 }
|
|
98
|
+
|
|
99
|
+
assertEquals(1, runs)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private fun createGate(
|
|
103
|
+
application: Application?,
|
|
104
|
+
startedBefore: Boolean = false,
|
|
105
|
+
) = ActivityStartGate(application) { startedBefore }
|
|
106
|
+
|
|
107
|
+
private inner class RecordingApplication : Application() {
|
|
108
|
+
private val callbacks = mutableListOf<ActivityLifecycleCallbacks>()
|
|
109
|
+
|
|
110
|
+
override fun registerActivityLifecycleCallbacks(callback: ActivityLifecycleCallbacks) {
|
|
111
|
+
callbacks += callback
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fun startActivity() = callbacks.forEach { it.onActivityStarted(activity) }
|
|
115
|
+
|
|
116
|
+
fun stopActivity() = callbacks.forEach { it.onActivityStopped(activity) }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -68,6 +68,7 @@ class BundleFileStorageServiceTest {
|
|
|
68
68
|
val firstLaunch = firstProcess.prepareLaunch(null)
|
|
69
69
|
assertEquals("hung-bundle", firstLaunch.launchedBundleId)
|
|
70
70
|
assertTrue(firstLaunch.shouldRollbackOnCrash)
|
|
71
|
+
firstProcess.markLaunchStarted("hung-bundle")
|
|
71
72
|
|
|
72
73
|
assertEquals("hung-bundle", firstProcess.prepareLaunch(null).launchedBundleId)
|
|
73
74
|
assertFalse(firstProcess.getCrashHistory().contains("hung-bundle"))
|
|
@@ -88,6 +89,224 @@ class BundleFileStorageServiceTest {
|
|
|
88
89
|
assertEquals(nextLaunch.launchedBundleId, thirdProcess.prepareLaunch(null).launchedBundleId)
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
@Test
|
|
93
|
+
fun `headless staging launch stays pending on the next cold start`() {
|
|
94
|
+
val rootDir = temporaryFolder.newFolder()
|
|
95
|
+
val preferences = InMemoryPreferencesService()
|
|
96
|
+
listOf("stable-bundle", "staged-bundle").forEach { bundleId ->
|
|
97
|
+
val directory = createBundleDir(rootDir, bundleId)
|
|
98
|
+
writeFile(directory, "index.android.bundle")
|
|
99
|
+
writeManifest(directory, listOf("index.android.bundle"))
|
|
100
|
+
}
|
|
101
|
+
writeMetadata(
|
|
102
|
+
rootDir,
|
|
103
|
+
BundleMetadata(
|
|
104
|
+
isolationKey = TEST_ISOLATION_KEY,
|
|
105
|
+
stableBundleId = "stable-bundle",
|
|
106
|
+
stagingBundleId = "staged-bundle",
|
|
107
|
+
verificationPending = true,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
// Issue #1468: a headless JS task (a background FCM message) prepares the
|
|
111
|
+
// launch without an activity, so first content never appears.
|
|
112
|
+
val headlessLaunch = createService(rootDir, preferences).prepareLaunch(null)
|
|
113
|
+
assertEquals("staged-bundle", headlessLaunch.launchedBundleId)
|
|
114
|
+
assertTrue(headlessLaunch.shouldRollbackOnCrash)
|
|
115
|
+
|
|
116
|
+
// The OS reclaims the cached process, then the user opens the app.
|
|
117
|
+
val nextProcess = createService(rootDir, preferences)
|
|
118
|
+
val nextLaunch = nextProcess.prepareLaunch(null)
|
|
119
|
+
assertEquals("staged-bundle", nextLaunch.launchedBundleId)
|
|
120
|
+
assertTrue(nextLaunch.shouldRollbackOnCrash)
|
|
121
|
+
assertFalse(nextProcess.getCrashHistory().contains("staged-bundle"))
|
|
122
|
+
assertEquals("STABLE", nextProcess.notifyAppReady()["status"])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun `launch start records only the pending staged bundle`() {
|
|
127
|
+
val rootDir = temporaryFolder.newFolder()
|
|
128
|
+
val preferences = InMemoryPreferencesService()
|
|
129
|
+
listOf("stable-bundle", "staged-bundle").forEach { bundleId ->
|
|
130
|
+
val directory = createBundleDir(rootDir, bundleId)
|
|
131
|
+
writeFile(directory, "index.android.bundle")
|
|
132
|
+
writeManifest(directory, listOf("index.android.bundle"))
|
|
133
|
+
}
|
|
134
|
+
writeMetadata(
|
|
135
|
+
rootDir,
|
|
136
|
+
BundleMetadata(
|
|
137
|
+
isolationKey = TEST_ISOLATION_KEY,
|
|
138
|
+
stableBundleId = "stable-bundle",
|
|
139
|
+
stagingBundleId = "staged-bundle",
|
|
140
|
+
verificationPending = true,
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
val process = createService(rootDir, preferences)
|
|
144
|
+
process.prepareLaunch(null)
|
|
145
|
+
|
|
146
|
+
// The activity can start after JS staged another bundle, or after first
|
|
147
|
+
// content already verified this one.
|
|
148
|
+
process.markLaunchStarted("stable-bundle")
|
|
149
|
+
assertFalse(loadMetadata(rootDir)!!.launchInProgress)
|
|
150
|
+
process.markLaunchCompleted("staged-bundle")
|
|
151
|
+
process.markLaunchStarted("staged-bundle")
|
|
152
|
+
assertFalse(loadMetadata(rootDir)!!.launchInProgress)
|
|
153
|
+
|
|
154
|
+
val nextLaunch = createService(rootDir, preferences).prepareLaunch(null)
|
|
155
|
+
assertEquals("staged-bundle", nextLaunch.launchedBundleId)
|
|
156
|
+
assertFalse(nextLaunch.shouldRollbackOnCrash)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Issue #1469: a launch that ends before first content without a crash marker
|
|
160
|
+
// may be a user leaving early rather than a hang.
|
|
161
|
+
@Test
|
|
162
|
+
fun `unfinished launch retries the bundle once before crash history`() =
|
|
163
|
+
runBlocking {
|
|
164
|
+
val rootDir = temporaryFolder.newFolder()
|
|
165
|
+
val preferences = InMemoryPreferencesService()
|
|
166
|
+
leaveUnfinishedLaunch(rootDir, preferences)
|
|
167
|
+
|
|
168
|
+
// The next process still rolls back at once and reports RECOVERED.
|
|
169
|
+
val recovered = createService(rootDir, preferences)
|
|
170
|
+
assertEquals("stable-bundle", recovered.prepareLaunch(null).launchedBundleId)
|
|
171
|
+
assertEquals("RECOVERED", recovered.notifyAppReady()["status"])
|
|
172
|
+
assertFalse(loadCrashedHistory(rootDir).contains("retried-bundle"))
|
|
173
|
+
assertEquals(InterruptedLaunch("retried-bundle"), loadCrashedHistory(rootDir).interruptedLaunch)
|
|
174
|
+
// The session that recovered refuses the bundle, even after first content.
|
|
175
|
+
recovered.markLaunchCompleted("stable-bundle")
|
|
176
|
+
assertTrue(recovered.getCrashHistory().contains("retried-bundle"))
|
|
177
|
+
assertEquals("BUNDLE_IN_CRASHED_HISTORY", updateError(recovered, "retried-bundle")?.code)
|
|
178
|
+
|
|
179
|
+
// A later process may install it again.
|
|
180
|
+
val retrying = createService(rootDir, preferences)
|
|
181
|
+
assertEquals("stable-bundle", retrying.prepareLaunch(null).launchedBundleId)
|
|
182
|
+
assertFalse(retrying.getCrashHistory().contains("retried-bundle"))
|
|
183
|
+
stageRetriedBundle(rootDir)
|
|
184
|
+
|
|
185
|
+
// The retry ends before first content too: now crash history keeps it.
|
|
186
|
+
val retryLaunch = createService(rootDir, preferences)
|
|
187
|
+
assertEquals("retried-bundle", retryLaunch.prepareLaunch(null).launchedBundleId)
|
|
188
|
+
retryLaunch.markLaunchStarted("retried-bundle")
|
|
189
|
+
val next = createService(rootDir, preferences)
|
|
190
|
+
assertEquals("stable-bundle", next.prepareLaunch(null).launchedBundleId)
|
|
191
|
+
assertEquals("RECOVERED", next.notifyAppReady()["status"])
|
|
192
|
+
assertTrue(loadCrashedHistory(rootDir).contains("retried-bundle"))
|
|
193
|
+
assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
|
|
194
|
+
next.markLaunchCompleted("stable-bundle")
|
|
195
|
+
val later = createService(rootDir, preferences)
|
|
196
|
+
later.prepareLaunch(null)
|
|
197
|
+
assertEquals("BUNDLE_IN_CRASHED_HISTORY", updateError(later, "retried-bundle")?.code)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@Test
|
|
201
|
+
fun `retried bundle that reaches first content is verified`() {
|
|
202
|
+
val rootDir = temporaryFolder.newFolder()
|
|
203
|
+
val preferences = InMemoryPreferencesService()
|
|
204
|
+
leaveUnfinishedLaunch(rootDir, preferences)
|
|
205
|
+
val recovered = createService(rootDir, preferences)
|
|
206
|
+
recovered.prepareLaunch(null)
|
|
207
|
+
recovered.markLaunchCompleted("stable-bundle")
|
|
208
|
+
createService(rootDir, preferences).prepareLaunch(null)
|
|
209
|
+
stageRetriedBundle(rootDir)
|
|
210
|
+
|
|
211
|
+
val retryLaunch = createService(rootDir, preferences)
|
|
212
|
+
assertEquals("retried-bundle", retryLaunch.prepareLaunch(null).launchedBundleId)
|
|
213
|
+
retryLaunch.markLaunchCompleted("retried-bundle")
|
|
214
|
+
|
|
215
|
+
assertTrue(loadCrashedHistory(rootDir).bundles.isEmpty())
|
|
216
|
+
assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
|
|
217
|
+
val next = createService(rootDir, preferences).prepareLaunch(null)
|
|
218
|
+
assertEquals("retried-bundle", next.launchedBundleId)
|
|
219
|
+
assertFalse(next.shouldRollbackOnCrash)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@Test
|
|
223
|
+
fun `retry waits for a session that showed content`() {
|
|
224
|
+
val rootDir = temporaryFolder.newFolder()
|
|
225
|
+
val preferences = InMemoryPreferencesService()
|
|
226
|
+
leaveUnfinishedLaunch(rootDir, preferences)
|
|
227
|
+
|
|
228
|
+
// A headless process consumes the unfinished launch and never renders.
|
|
229
|
+
val headless = createService(rootDir, preferences)
|
|
230
|
+
assertEquals("stable-bundle", headless.prepareLaunch(null).launchedBundleId)
|
|
231
|
+
assertTrue(headless.getCrashHistory().contains("retried-bundle"))
|
|
232
|
+
|
|
233
|
+
// The user's next open is the first session that recovered.
|
|
234
|
+
val opened = createService(rootDir, preferences)
|
|
235
|
+
opened.prepareLaunch(null)
|
|
236
|
+
assertTrue(opened.getCrashHistory().contains("retried-bundle"))
|
|
237
|
+
opened.markLaunchCompleted("stable-bundle")
|
|
238
|
+
assertTrue(opened.getCrashHistory().contains("retried-bundle"))
|
|
239
|
+
|
|
240
|
+
val later = createService(rootDir, preferences)
|
|
241
|
+
later.prepareLaunch(null)
|
|
242
|
+
assertFalse(later.getCrashHistory().contains("retried-bundle"))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@Test
|
|
246
|
+
fun `retry readies after content from the built-in bundle`() {
|
|
247
|
+
val rootDir = temporaryFolder.newFolder()
|
|
248
|
+
val preferences = InMemoryPreferencesService()
|
|
249
|
+
leaveUnfinishedLaunch(rootDir, preferences, stableBundleId = null)
|
|
250
|
+
val recovered = createService(rootDir, preferences)
|
|
251
|
+
recovered.prepareLaunch(null)
|
|
252
|
+
|
|
253
|
+
// First content of the built-in bundle reports no bundle ID.
|
|
254
|
+
recovered.markLaunchCompleted(null)
|
|
255
|
+
|
|
256
|
+
val later = createService(rootDir, preferences)
|
|
257
|
+
later.prepareLaunch(null)
|
|
258
|
+
assertFalse(later.getCrashHistory().contains("retried-bundle"))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@Test
|
|
262
|
+
fun `crash of a retried bundle adds it to crash history`() {
|
|
263
|
+
val rootDir = temporaryFolder.newFolder()
|
|
264
|
+
val preferences = InMemoryPreferencesService()
|
|
265
|
+
leaveUnfinishedLaunch(rootDir, preferences)
|
|
266
|
+
val recovered = createService(rootDir, preferences)
|
|
267
|
+
recovered.prepareLaunch(null)
|
|
268
|
+
recovered.markLaunchCompleted("stable-bundle")
|
|
269
|
+
createService(rootDir, preferences).prepareLaunch(null)
|
|
270
|
+
stageRetriedBundle(rootDir)
|
|
271
|
+
assertEquals("retried-bundle", createService(rootDir, preferences).prepareLaunch(null).launchedBundleId)
|
|
272
|
+
|
|
273
|
+
// That launch crashed before first content and left a crash marker.
|
|
274
|
+
val next =
|
|
275
|
+
createService(rootDir, preferences)
|
|
276
|
+
.prepareLaunch(PendingCrashRecovery(launchedBundleId = "retried-bundle", shouldRollback = true))
|
|
277
|
+
|
|
278
|
+
assertEquals("stable-bundle", next.launchedBundleId)
|
|
279
|
+
assertTrue(loadCrashedHistory(rootDir).contains("retried-bundle"))
|
|
280
|
+
assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@Test
|
|
284
|
+
fun `clearing crash history drops a waiting retry`() {
|
|
285
|
+
val rootDir = temporaryFolder.newFolder()
|
|
286
|
+
val preferences = InMemoryPreferencesService()
|
|
287
|
+
leaveUnfinishedLaunch(rootDir, preferences)
|
|
288
|
+
val recovered = createService(rootDir, preferences)
|
|
289
|
+
recovered.prepareLaunch(null)
|
|
290
|
+
|
|
291
|
+
assertTrue(recovered.clearCrashHistory())
|
|
292
|
+
|
|
293
|
+
assertFalse(recovered.getCrashHistory().contains("retried-bundle"))
|
|
294
|
+
assertNull(loadCrashedHistory(rootDir).interruptedLaunch)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@Test
|
|
298
|
+
fun `malformed retry record keeps crash history`() {
|
|
299
|
+
val rootDir = temporaryFolder.newFolder()
|
|
300
|
+
File(bundleStoreDir(rootDir), CrashedHistory.CRASHED_HISTORY_FILENAME).writeText(
|
|
301
|
+
"""{"bundles":[{"bundleId":"crashed-bundle","crashedAt":1}],"maxHistorySize":10,"interruptedLaunch":{}}""",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
val history = createService(rootDir).getCrashHistory()
|
|
305
|
+
|
|
306
|
+
assertEquals(listOf("crashed-bundle"), history.bundles.map { it.bundleId })
|
|
307
|
+
assertNull(history.interruptedLaunch)
|
|
308
|
+
}
|
|
309
|
+
|
|
91
310
|
@Test
|
|
92
311
|
fun `resolveBundleFile uses single manifest bundle at root`() {
|
|
93
312
|
val rootDir = temporaryFolder.newFolder("root-manifest-bundle")
|
|
@@ -458,6 +677,60 @@ class BundleFileStorageServiceTest {
|
|
|
458
677
|
TEST_ISOLATION_KEY,
|
|
459
678
|
)
|
|
460
679
|
|
|
680
|
+
private fun loadCrashedHistory(rootDir: File): CrashedHistory =
|
|
681
|
+
CrashedHistory.loadFromFile(File(bundleStoreDir(rootDir), CrashedHistory.CRASHED_HISTORY_FILENAME))
|
|
682
|
+
|
|
683
|
+
// Stages retried-bundle over stable-bundle, or over the built-in bundle, and
|
|
684
|
+
// leaves a launch of it that ended before first content.
|
|
685
|
+
private fun leaveUnfinishedLaunch(
|
|
686
|
+
rootDir: File,
|
|
687
|
+
preferences: InMemoryPreferencesService,
|
|
688
|
+
stableBundleId: String? = "stable-bundle",
|
|
689
|
+
) {
|
|
690
|
+
listOfNotNull(stableBundleId, "retried-bundle").forEach { bundleId ->
|
|
691
|
+
val directory = createBundleDir(rootDir, bundleId)
|
|
692
|
+
writeFile(directory, "index.android.bundle")
|
|
693
|
+
writeManifest(directory, listOf("index.android.bundle"))
|
|
694
|
+
}
|
|
695
|
+
writeMetadata(
|
|
696
|
+
rootDir,
|
|
697
|
+
BundleMetadata(
|
|
698
|
+
isolationKey = TEST_ISOLATION_KEY,
|
|
699
|
+
stableBundleId = stableBundleId,
|
|
700
|
+
stagingBundleId = "retried-bundle",
|
|
701
|
+
verificationPending = true,
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
val launch = createService(rootDir, preferences)
|
|
705
|
+
assertEquals("retried-bundle", launch.prepareLaunch(null).launchedBundleId)
|
|
706
|
+
launch.markLaunchStarted("retried-bundle")
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// What an install of retried-bundle over the recovered stable bundle leaves.
|
|
710
|
+
private fun stageRetriedBundle(rootDir: File) {
|
|
711
|
+
val directory = createBundleDir(rootDir, "retried-bundle")
|
|
712
|
+
writeFile(directory, "index.android.bundle")
|
|
713
|
+
writeManifest(directory, listOf("index.android.bundle"))
|
|
714
|
+
writeMetadata(
|
|
715
|
+
rootDir,
|
|
716
|
+
BundleMetadata(
|
|
717
|
+
isolationKey = TEST_ISOLATION_KEY,
|
|
718
|
+
stableBundleId = "stable-bundle",
|
|
719
|
+
stagingBundleId = "retried-bundle",
|
|
720
|
+
verificationPending = true,
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// The crash history check runs before any download.
|
|
726
|
+
private suspend fun updateError(
|
|
727
|
+
service: BundleFileStorageService,
|
|
728
|
+
bundleId: String,
|
|
729
|
+
): HotUpdaterException? =
|
|
730
|
+
runCatching {
|
|
731
|
+
service.updateBundle(bundleId, "https://example.com/$bundleId.zip", null, null, null, null) {}
|
|
732
|
+
}.exceptionOrNull() as? HotUpdaterException
|
|
733
|
+
|
|
461
734
|
private fun writeFile(
|
|
462
735
|
rootDir: File,
|
|
463
736
|
relativePath: String,
|
|
@@ -185,6 +185,8 @@ class HotUpdaterImplTest {
|
|
|
185
185
|
progressCallback: (UpdateProgressPayload) -> Unit,
|
|
186
186
|
) = Unit
|
|
187
187
|
|
|
188
|
+
override fun markLaunchStarted(launchedBundleId: String?) = Unit
|
|
189
|
+
|
|
188
190
|
override fun markLaunchCompleted(currentBundleId: String?) = Unit
|
|
189
191
|
|
|
190
192
|
override fun notifyAppReady(): Map<String, Any?> = mapOf("status" to "STABLE")
|
|
@@ -277,6 +277,7 @@ public protocol BundleStorageService {
|
|
|
277
277
|
func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
|
|
278
278
|
|
|
279
279
|
// Rollback support
|
|
280
|
+
func markLaunchStarted(bundleId: String?)
|
|
280
281
|
func markLaunchCompleted(bundleId: String?)
|
|
281
282
|
func notifyAppReady() -> [String: Any]
|
|
282
283
|
func getCrashHistory() -> CrashedHistory
|
|
@@ -351,6 +352,13 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
351
352
|
|
|
352
353
|
private var hasPreparedLaunch = false
|
|
353
354
|
private var currentLaunchReport: LaunchReport?
|
|
355
|
+
// Crash history is written at first content on the main thread while JavaScript can read it.
|
|
356
|
+
private let crashedHistoryLock = NSLock()
|
|
357
|
+
// A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
|
|
358
|
+
private var retryHeldBundleId: String?
|
|
359
|
+
// markLaunchStarted can run when the app enters the foreground while JavaScript
|
|
360
|
+
// stages a newer bundle; both read and rewrite metadata.
|
|
361
|
+
private let stagingMetadataLock = NSLock()
|
|
354
362
|
private let activeBundleMetadataLock = NSLock()
|
|
355
363
|
private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
|
|
356
364
|
|
|
@@ -1128,15 +1136,15 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1128
1136
|
)
|
|
1129
1137
|
}
|
|
1130
1138
|
|
|
1139
|
+
/// Rolls back the pending staging bundle. `unfinishedLaunch` means its launch
|
|
1140
|
+
/// ended before first content without a crash marker.
|
|
1131
1141
|
@discardableResult
|
|
1132
|
-
private func rollbackPendingBundle(_ stagingId: String) -> Bool {
|
|
1142
|
+
private func rollbackPendingBundle(_ stagingId: String, unfinishedLaunch: Bool = false) -> Bool {
|
|
1133
1143
|
guard var metadata = loadMetadataOrNull(), metadata.stagingBundleId == stagingId else {
|
|
1134
1144
|
return false
|
|
1135
1145
|
}
|
|
1136
1146
|
|
|
1137
|
-
|
|
1138
|
-
crashedHistory.addEntry(stagingId)
|
|
1139
|
-
let _ = saveCrashedHistory(crashedHistory)
|
|
1147
|
+
recordFailedLaunch(stagingId, unfinishedLaunch: unfinishedLaunch)
|
|
1140
1148
|
|
|
1141
1149
|
let fallbackBundleId = metadata.stableBundleId.flatMap { candidate in
|
|
1142
1150
|
if case .success(let storeDir) = bundleStoreDir() {
|
|
@@ -1617,15 +1625,11 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1617
1625
|
metadata.verificationPending,
|
|
1618
1626
|
metadata.launchInProgress,
|
|
1619
1627
|
let stagingBundleId = metadata.stagingBundleId {
|
|
1620
|
-
rollbackPendingBundle(stagingBundleId)
|
|
1628
|
+
rollbackPendingBundle(stagingBundleId, unfinishedLaunch: true)
|
|
1621
1629
|
}
|
|
1622
1630
|
hasPreparedLaunch = true
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
metadata.launchInProgress = true
|
|
1626
|
-
_ = saveMetadata(metadata)
|
|
1627
|
-
}
|
|
1628
|
-
return selection
|
|
1631
|
+
// A launch is recorded only once it can show UI (markLaunchStarted).
|
|
1632
|
+
return selectLaunch(bundle: bundle)
|
|
1629
1633
|
}
|
|
1630
1634
|
|
|
1631
1635
|
// MARK: - Bundle Update
|
|
@@ -1640,8 +1644,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1640
1644
|
*/
|
|
1641
1645
|
func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, manifestUrl: URL?, manifestFileHash: String?, changedAssets: [String: ChangedAssetDescriptor]?, progressHandler: @escaping (UpdateProgressPayload) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
|
|
1642
1646
|
// Check if bundle is in crashed history
|
|
1643
|
-
|
|
1644
|
-
if crashedHistory.contains(bundleId) {
|
|
1647
|
+
if getCrashHistory().contains(bundleId) {
|
|
1645
1648
|
NSLog("[BundleStorage] Bundle '\(bundleId)' is in crashed history, rejecting update")
|
|
1646
1649
|
completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
|
|
1647
1650
|
return
|
|
@@ -1699,9 +1702,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1699
1702
|
let setResult = self.setBundleURL(localPath: bundlePath)
|
|
1700
1703
|
switch setResult {
|
|
1701
1704
|
case .success:
|
|
1702
|
-
let
|
|
1703
|
-
let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
|
|
1704
|
-
let _ = self.saveMetadata(updatedMetadata)
|
|
1705
|
+
let updatedMetadata = self.saveNewStagingMetadata(bundleId)
|
|
1705
1706
|
NSLog("[BundleStorage] Set staging bundle (cached): \(bundleId), verificationPending: true")
|
|
1706
1707
|
|
|
1707
1708
|
self.emitArchiveProgress(
|
|
@@ -2131,9 +2132,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2131
2132
|
)
|
|
2132
2133
|
switch self.setBundleURL(localPath: finalBundlePath) {
|
|
2133
2134
|
case .success:
|
|
2134
|
-
let
|
|
2135
|
-
let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
|
|
2136
|
-
let _ = self.saveMetadata(updatedMetadata)
|
|
2135
|
+
let updatedMetadata = self.saveNewStagingMetadata(bundleId)
|
|
2137
2136
|
self.cleanupTemporaryFiles([tempDirectory])
|
|
2138
2137
|
self.scheduleCleanupOldBundles(
|
|
2139
2138
|
bundleIdsToKeep: [currentBundleId, updatedMetadata.stableBundleId, bundleId].compactMap { $0 }
|
|
@@ -2469,9 +2468,7 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2469
2468
|
NSLog("[BundleStorage] Successfully set bundle URL: \(finalBundlePath)")
|
|
2470
2469
|
|
|
2471
2470
|
// 13) Set staging metadata for rollback support
|
|
2472
|
-
let
|
|
2473
|
-
let updatedMetadata = self.prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
|
|
2474
|
-
let _ = self.saveMetadata(updatedMetadata)
|
|
2471
|
+
let updatedMetadata = self.saveNewStagingMetadata(bundleId)
|
|
2475
2472
|
NSLog("[BundleStorage] Set staging bundle: \(bundleId), verificationPending: true")
|
|
2476
2473
|
|
|
2477
2474
|
// 14) Clean up the temporary directory
|
|
@@ -2542,7 +2539,32 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2542
2539
|
/**
|
|
2543
2540
|
* Marks the current launch as successful after the first content appeared.
|
|
2544
2541
|
*/
|
|
2542
|
+
private func saveNewStagingMetadata(_ bundleId: String) -> BundleMetadata {
|
|
2543
|
+
stagingMetadataLock.lock()
|
|
2544
|
+
defer { stagingMetadataLock.unlock() }
|
|
2545
|
+
let currentMetadata = loadMetadataOrNull() ?? createInitialMetadata()
|
|
2546
|
+
let updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId: bundleId)
|
|
2547
|
+
_ = saveMetadata(updatedMetadata)
|
|
2548
|
+
return updatedMetadata
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
func markLaunchStarted(bundleId: String?) {
|
|
2552
|
+
stagingMetadataLock.lock()
|
|
2553
|
+
defer { stagingMetadataLock.unlock() }
|
|
2554
|
+
guard let bundleId,
|
|
2555
|
+
var metadata = loadMetadataOrNull(),
|
|
2556
|
+
metadata.verificationPending,
|
|
2557
|
+
!metadata.launchInProgress,
|
|
2558
|
+
metadata.stagingBundleId == bundleId else {
|
|
2559
|
+
return
|
|
2560
|
+
}
|
|
2561
|
+
metadata.launchInProgress = true
|
|
2562
|
+
_ = saveMetadata(metadata)
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2545
2565
|
func markLaunchCompleted(bundleId: String?) {
|
|
2566
|
+
// Before the guard: content from the built-in bundle (nil) also counts.
|
|
2567
|
+
readyInterruptedLaunchRetry(bundleId)
|
|
2546
2568
|
guard let bundleId,
|
|
2547
2569
|
var metadata = loadMetadataOrNull(),
|
|
2548
2570
|
metadata.verificationPending,
|
|
@@ -2569,11 +2591,20 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2569
2591
|
}
|
|
2570
2592
|
|
|
2571
2593
|
/**
|
|
2572
|
-
* Returns the
|
|
2594
|
+
* Returns the bundles this device refuses to install: crash history, plus a
|
|
2595
|
+
* bundle waiting for its retry until a process that may retry it.
|
|
2573
2596
|
* @return The crashed history object
|
|
2574
2597
|
*/
|
|
2575
2598
|
func getCrashHistory() -> CrashedHistory {
|
|
2576
|
-
|
|
2599
|
+
crashedHistoryLock.lock()
|
|
2600
|
+
defer { crashedHistoryLock.unlock() }
|
|
2601
|
+
var history = loadCrashedHistory()
|
|
2602
|
+
if let interrupted = history.interruptedLaunch,
|
|
2603
|
+
!interrupted.retryReady || interrupted.bundleId == retryHeldBundleId,
|
|
2604
|
+
!history.contains(interrupted.bundleId) {
|
|
2605
|
+
history.bundles.append(CrashedBundleEntry(bundleId: interrupted.bundleId))
|
|
2606
|
+
}
|
|
2607
|
+
return history
|
|
2577
2608
|
}
|
|
2578
2609
|
|
|
2579
2610
|
/**
|
|
@@ -2581,11 +2612,57 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
2581
2612
|
* @return true if clearing was successful
|
|
2582
2613
|
*/
|
|
2583
2614
|
func clearCrashHistory() -> Bool {
|
|
2615
|
+
crashedHistoryLock.lock()
|
|
2616
|
+
defer { crashedHistoryLock.unlock() }
|
|
2584
2617
|
var history = loadCrashedHistory()
|
|
2585
2618
|
history.clear()
|
|
2619
|
+
retryHeldBundleId = nil
|
|
2586
2620
|
return saveCrashedHistory(history)
|
|
2587
2621
|
}
|
|
2588
2622
|
|
|
2623
|
+
/// A crash, or a second unfinished launch of the same bundle, adds the bundle
|
|
2624
|
+
/// to crash history. A first unfinished launch cannot tell a hang from a user
|
|
2625
|
+
/// leaving early, so it leaves the bundle one retry instead.
|
|
2626
|
+
private func recordFailedLaunch(_ bundleId: String, unfinishedLaunch: Bool) {
|
|
2627
|
+
crashedHistoryLock.lock()
|
|
2628
|
+
defer { crashedHistoryLock.unlock() }
|
|
2629
|
+
var history = loadCrashedHistory()
|
|
2630
|
+
let interrupted = history.interruptedLaunch
|
|
2631
|
+
if unfinishedLaunch, interrupted?.bundleId != bundleId {
|
|
2632
|
+
NSLog("[BundleStorage] Launch of \(bundleId) ended before first content; it gets one retry")
|
|
2633
|
+
history.interruptedLaunch = InterruptedLaunch(bundleId: bundleId)
|
|
2634
|
+
retryHeldBundleId = bundleId
|
|
2635
|
+
} else {
|
|
2636
|
+
history.addEntry(bundleId)
|
|
2637
|
+
if interrupted?.bundleId == bundleId {
|
|
2638
|
+
history.interruptedLaunch = nil
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
_ = saveCrashedHistory(history)
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
/// First content appeared, so the app works again. A bundle waiting for its
|
|
2645
|
+
/// retry may be installed from the next process on. This process keeps
|
|
2646
|
+
/// refusing it, so the session that recovered never reloads into it. Content
|
|
2647
|
+
/// from that bundle verifies it.
|
|
2648
|
+
private func readyInterruptedLaunchRetry(_ currentBundleId: String?) {
|
|
2649
|
+
crashedHistoryLock.lock()
|
|
2650
|
+
defer { crashedHistoryLock.unlock() }
|
|
2651
|
+
var history = loadCrashedHistory()
|
|
2652
|
+
guard let interrupted = history.interruptedLaunch else {
|
|
2653
|
+
return
|
|
2654
|
+
}
|
|
2655
|
+
if interrupted.bundleId == currentBundleId {
|
|
2656
|
+
history.interruptedLaunch = nil
|
|
2657
|
+
} else if interrupted.retryReady {
|
|
2658
|
+
return
|
|
2659
|
+
} else {
|
|
2660
|
+
history.interruptedLaunch?.retryReady = true
|
|
2661
|
+
retryHeldBundleId = interrupted.bundleId
|
|
2662
|
+
}
|
|
2663
|
+
_ = saveCrashedHistory(history)
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2589
2666
|
/**
|
|
2590
2667
|
* Gets the base URL for the current active bundle directory
|
|
2591
2668
|
* Returns the file:// URL to the bundle directory without trailing slash
|
|
@@ -120,6 +120,37 @@ public struct CrashedBundleEntry: Codable {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// MARK: - InterruptedLaunch
|
|
124
|
+
|
|
125
|
+
/// A bundle whose launch ended before first content without a crash, waiting
|
|
126
|
+
/// for its one retry. Such a launch cannot tell a hang from a user leaving
|
|
127
|
+
/// early, so the bundle is offered again instead of entering crash history.
|
|
128
|
+
/// A second unfinished launch of it adds it there.
|
|
129
|
+
///
|
|
130
|
+
/// `retryReady` turns true once a later launch shows content. The retry starts
|
|
131
|
+
/// with the next process, so the session that recovered never reloads into the
|
|
132
|
+
/// same bundle.
|
|
133
|
+
public struct InterruptedLaunch: Codable {
|
|
134
|
+
let bundleId: String
|
|
135
|
+
var retryReady: Bool
|
|
136
|
+
|
|
137
|
+
enum CodingKeys: String, CodingKey {
|
|
138
|
+
case bundleId
|
|
139
|
+
case retryReady
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
init(bundleId: String, retryReady: Bool = false) {
|
|
143
|
+
self.bundleId = bundleId
|
|
144
|
+
self.retryReady = retryReady
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public init(from decoder: Decoder) throws {
|
|
148
|
+
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
149
|
+
bundleId = try values.decode(String.self, forKey: .bundleId)
|
|
150
|
+
retryReady = try values.decodeIfPresent(Bool.self, forKey: .retryReady) ?? false
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
// MARK: - CrashedHistory
|
|
124
155
|
|
|
125
156
|
/// History of crashed bundles
|
|
@@ -129,10 +160,30 @@ public struct CrashedHistory: Codable {
|
|
|
129
160
|
|
|
130
161
|
var bundles: [CrashedBundleEntry]
|
|
131
162
|
var maxHistorySize: Int
|
|
163
|
+
var interruptedLaunch: InterruptedLaunch?
|
|
164
|
+
|
|
165
|
+
enum CodingKeys: String, CodingKey {
|
|
166
|
+
case bundles
|
|
167
|
+
case maxHistorySize
|
|
168
|
+
case interruptedLaunch
|
|
169
|
+
}
|
|
132
170
|
|
|
133
|
-
init(
|
|
171
|
+
init(
|
|
172
|
+
bundles: [CrashedBundleEntry] = [],
|
|
173
|
+
maxHistorySize: Int = CrashedHistory.defaultMaxHistorySize,
|
|
174
|
+
interruptedLaunch: InterruptedLaunch? = nil
|
|
175
|
+
) {
|
|
134
176
|
self.bundles = bundles
|
|
135
177
|
self.maxHistorySize = maxHistorySize
|
|
178
|
+
self.interruptedLaunch = interruptedLaunch
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public init(from decoder: Decoder) throws {
|
|
182
|
+
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
183
|
+
bundles = try values.decode([CrashedBundleEntry].self, forKey: .bundles)
|
|
184
|
+
maxHistorySize = try values.decode(Int.self, forKey: .maxHistorySize)
|
|
185
|
+
// A malformed retry record must not drop the crash history.
|
|
186
|
+
interruptedLaunch = try? values.decodeIfPresent(InterruptedLaunch.self, forKey: .interruptedLaunch)
|
|
136
187
|
}
|
|
137
188
|
|
|
138
189
|
static func load(from file: URL) -> CrashedHistory {
|
|
@@ -196,6 +247,7 @@ public struct CrashedHistory: Codable {
|
|
|
196
247
|
|
|
197
248
|
mutating func clear() {
|
|
198
249
|
bundles.removeAll()
|
|
250
|
+
interruptedLaunch = nil
|
|
199
251
|
}
|
|
200
252
|
}
|
|
201
253
|
|
|
@@ -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)
|
|
@@ -35,6 +36,7 @@ private func hotUpdaterPerformRecoveryReload() -> Bool {
|
|
|
35
36
|
private let cohortService: CohortService
|
|
36
37
|
private let recoveryManager: HotUpdaterRecoveryManager
|
|
37
38
|
private var currentLaunchSelection: LaunchSelection?
|
|
39
|
+
private var launchStartObservers: [NSObjectProtocol] = []
|
|
38
40
|
|
|
39
41
|
private static let DEFAULT_CHANNEL = "production"
|
|
40
42
|
private static let CHANNEL_STORAGE_KEY = "HotUpdaterChannel"
|
|
@@ -482,12 +484,49 @@ private func hotUpdaterPerformRecoveryReload() -> Bool {
|
|
|
482
484
|
|
|
483
485
|
let pendingRecovery = recoveryManager.consumePendingCrashRecovery()
|
|
484
486
|
let selection = bundleStorage.prepareLaunch(bundle: bundle, pendingRecovery: pendingRecovery)
|
|
487
|
+
markLaunchStartedInForeground(selection)
|
|
485
488
|
recoveryManager.startMonitoring(bundleId: selection.launchedBundleId, shouldRollback: selection.shouldRollbackOnCrash) { [weak self] launchedBundleId in
|
|
486
489
|
self?.bundleStorage.markLaunchCompleted(bundleId: launchedBundleId)
|
|
487
490
|
}
|
|
488
491
|
currentLaunchSelection = selection
|
|
489
492
|
return selection
|
|
490
493
|
}
|
|
494
|
+
|
|
495
|
+
// A background launch, such as a silent push, may never render. Only a
|
|
496
|
+
// launch in the foreground may leave an unfinished launch for the next
|
|
497
|
+
// process to roll back.
|
|
498
|
+
private func markLaunchStartedInForeground(_ selection: LaunchSelection) {
|
|
499
|
+
let bundleId = selection.shouldRollbackOnCrash ? selection.launchedBundleId : nil
|
|
500
|
+
let mark = { [weak self] in
|
|
501
|
+
guard let self else { return }
|
|
502
|
+
self.removeLaunchStartObservers()
|
|
503
|
+
guard let bundleId else { return }
|
|
504
|
+
guard UIApplication.shared.applicationState == .background else {
|
|
505
|
+
self.bundleStorage.markLaunchStarted(bundleId: bundleId)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
self.launchStartObservers = [
|
|
509
|
+
UIApplication.willEnterForegroundNotification,
|
|
510
|
+
UIApplication.didBecomeActiveNotification,
|
|
511
|
+
].map { name in
|
|
512
|
+
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
513
|
+
self?.removeLaunchStartObservers()
|
|
514
|
+
self?.bundleStorage.markLaunchStarted(bundleId: bundleId)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// UIApplication is read on the main thread.
|
|
519
|
+
if Thread.isMainThread {
|
|
520
|
+
mark()
|
|
521
|
+
} else {
|
|
522
|
+
DispatchQueue.main.async(execute: mark)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private func removeLaunchStartObservers() {
|
|
527
|
+
launchStartObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
528
|
+
launchStartObservers = []
|
|
529
|
+
}
|
|
491
530
|
}
|
|
492
531
|
|
|
493
532
|
@objcMembers
|
|
@@ -26,6 +26,32 @@ protocol DownloadService {
|
|
|
26
26
|
enum DownloadError: Error {
|
|
27
27
|
case incompleteDownload(expected: Int64, actual: Int64)
|
|
28
28
|
case invalidContentLength
|
|
29
|
+
|
|
30
|
+
/// The size of the file a successful response leaves, or nil when the
|
|
31
|
+
/// response does not tell. A 206 answers a range: when the system resumes
|
|
32
|
+
/// an interrupted background download, `expectedContentLength` counts
|
|
33
|
+
/// only the resumed range while the file holds every byte, so the size is
|
|
34
|
+
/// the total in `Content-Range: bytes <first>-<last>/<total>`.
|
|
35
|
+
static func expectedFileSize(for response: URLResponse?) -> Int64? {
|
|
36
|
+
if let httpResponse = response as? HTTPURLResponse,
|
|
37
|
+
httpResponse.statusCode == 206 {
|
|
38
|
+
guard let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range"),
|
|
39
|
+
let separator = contentRange.lastIndex(of: "/"),
|
|
40
|
+
let total = Int64(
|
|
41
|
+
contentRange[contentRange.index(after: separator)...]
|
|
42
|
+
.trimmingCharacters(in: .whitespaces)
|
|
43
|
+
),
|
|
44
|
+
total > 0 else {
|
|
45
|
+
return nil
|
|
46
|
+
}
|
|
47
|
+
return total
|
|
48
|
+
}
|
|
49
|
+
guard let expectedContentLength = response?.expectedContentLength,
|
|
50
|
+
expectedContentLength > 0 else {
|
|
51
|
+
return nil
|
|
52
|
+
}
|
|
53
|
+
return expectedContentLength
|
|
54
|
+
}
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
// Task state for persistence and recovery
|
|
@@ -161,7 +187,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
161
187
|
}
|
|
162
188
|
|
|
163
189
|
// Verify file size
|
|
164
|
-
let expectedSize = downloadTask.response
|
|
190
|
+
let expectedSize = DownloadError.expectedFileSize(for: downloadTask.response)
|
|
165
191
|
let actualSize: Int64?
|
|
166
192
|
do {
|
|
167
193
|
let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
|
|
@@ -171,7 +197,7 @@ extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
|
171
197
|
actualSize = nil
|
|
172
198
|
}
|
|
173
199
|
|
|
174
|
-
if expectedSize
|
|
200
|
+
if let expectedSize, let actualSize, actualSize != expectedSize {
|
|
175
201
|
NSLog("[DownloadService] Download incomplete: \(actualSize) / \(expectedSize) bytes")
|
|
176
202
|
// Delete incomplete file
|
|
177
203
|
try? FileManager.default.removeItem(at: location)
|
|
@@ -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 = "0.36.
|
|
7
|
+
const HOT_UPDATER_SDK_VERSION = exports.HOT_UPDATER_SDK_VERSION = "0.36.19";
|
|
8
8
|
//# sourceMappingURL=sdkVersion.js.map
|
package/lib/module/sdkVersion.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const HOT_UPDATER_SDK_VERSION = "0.36.
|
|
1
|
+
export declare const HOT_UPDATER_SDK_VERSION = "0.36.19";
|
|
2
2
|
//# sourceMappingURL=sdkVersion.d.ts.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const HOT_UPDATER_SDK_VERSION = "0.36.
|
|
1
|
+
export declare const HOT_UPDATER_SDK_VERSION = "0.36.19";
|
|
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": "0.36.
|
|
3
|
+
"version": "0.36.19",
|
|
4
4
|
"description": "React Native OTA solution for self-hosted",
|
|
5
5
|
"source": "./src/index.ts",
|
|
6
6
|
"main": "./lib/commonjs/index.js",
|
|
@@ -131,14 +131,14 @@
|
|
|
131
131
|
"react-native-builder-bob": "0.41.0",
|
|
132
132
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
133
133
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
134
|
-
"hot-updater": "0.36.
|
|
134
|
+
"hot-updater": "0.36.19"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
137
|
"use-sync-external-store": "1.5.0",
|
|
138
|
-
"@hot-updater/cli-tools": "0.36.
|
|
139
|
-
"@hot-updater/core": "0.36.
|
|
140
|
-
"@hot-updater/
|
|
141
|
-
"@hot-updater/
|
|
138
|
+
"@hot-updater/cli-tools": "0.36.19",
|
|
139
|
+
"@hot-updater/core": "0.36.19",
|
|
140
|
+
"@hot-updater/js": "0.36.19",
|
|
141
|
+
"@hot-updater/plugin-core": "0.36.19"
|
|
142
142
|
},
|
|
143
143
|
"scripts": {
|
|
144
144
|
"build": "node ./scripts/sync-sdk-version.mjs run -- bob build && tsc -p plugin/tsconfig.build.json",
|
package/src/sdkVersion.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const HOT_UPDATER_SDK_VERSION = "0.36.
|
|
1
|
+
export const HOT_UPDATER_SDK_VERSION = "0.36.19";
|