@hot-updater/react-native 0.27.1 → 0.29.0
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 +12 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/AndroidManifestNew.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +9 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +143 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +325 -210
- package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
- package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
- package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +51 -13
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
- package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
- package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
- package/android/src/newarch/HotUpdaterModule.kt +16 -25
- package/android/src/oldarch/HotUpdaterModule.kt +20 -26
- package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +340 -232
- package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
- package/ios/HotUpdater/Internal/CohortService.swift +63 -0
- package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
- package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
- package/ios/HotUpdater/Internal/HotUpdater.mm +376 -70
- package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +321 -9
- package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
- package/lib/commonjs/DefaultResolver.js +3 -5
- package/lib/commonjs/DefaultResolver.js.map +1 -1
- package/lib/commonjs/checkForUpdate.js +2 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +211 -39
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/native.spec.js +443 -0
- package/lib/commonjs/native.spec.js.map +1 -0
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/wrap.js +4 -5
- package/lib/commonjs/wrap.js.map +1 -1
- package/lib/module/DefaultResolver.js +3 -5
- package/lib/module/DefaultResolver.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +14 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +204 -34
- package/lib/module/native.js.map +1 -1
- package/lib/module/native.spec.js +442 -0
- package/lib/module/native.spec.js.map +1 -0
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/wrap.js +5 -6
- package/lib/module/wrap.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +14 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +43 -23
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +32 -8
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +6 -3
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +3 -6
- package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +14 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +43 -23
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +32 -8
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +6 -3
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +3 -6
- package/lib/typescript/module/wrap.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/DefaultResolver.ts +4 -4
- package/src/checkForUpdate.ts +4 -0
- package/src/index.ts +21 -0
- package/src/native.spec.ts +480 -0
- package/src/native.ts +285 -39
- package/src/specs/NativeHotUpdater.ts +36 -6
- package/src/types.ts +7 -3
- package/src/wrap.tsx +8 -12
|
@@ -4,6 +4,7 @@ import android.os.StatFs
|
|
|
4
4
|
import android.util.Log
|
|
5
5
|
import kotlinx.coroutines.Dispatchers
|
|
6
6
|
import kotlinx.coroutines.withContext
|
|
7
|
+
import org.json.JSONObject
|
|
7
8
|
import java.io.File
|
|
8
9
|
import java.net.URL
|
|
9
10
|
|
|
@@ -31,11 +32,11 @@ interface BundleStorageService {
|
|
|
31
32
|
fun getFallbackBundleURL(): String
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* Prepares the bundle launch for the current process.
|
|
36
|
+
* Applies any pending rollback decision from the previous launch and returns
|
|
37
|
+
* the bundle that should be loaded now.
|
|
37
38
|
*/
|
|
38
|
-
fun
|
|
39
|
+
fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* Updates the bundle from the specified URL
|
|
@@ -53,11 +54,14 @@ interface BundleStorageService {
|
|
|
53
54
|
)
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
|
-
*
|
|
57
|
-
* @param currentBundleId The bundle ID that JS reports as currently loaded
|
|
58
|
-
* @return Map containing status and optional crashedBundleId
|
|
57
|
+
* Marks the current launch as successful after the first content appeared.
|
|
59
58
|
*/
|
|
60
|
-
fun
|
|
59
|
+
fun markLaunchCompleted(currentBundleId: String?)
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the launch report for the current process.
|
|
63
|
+
*/
|
|
64
|
+
fun notifyAppReady(): Map<String, Any?>
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
67
|
* Gets the crashed bundle history
|
|
@@ -77,6 +81,18 @@ interface BundleStorageService {
|
|
|
77
81
|
*/
|
|
78
82
|
fun getBaseURL(): String
|
|
79
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Gets the current active bundle ID from bundle storage.
|
|
86
|
+
* Reads manifest.json first and falls back to older metadata when needed.
|
|
87
|
+
*/
|
|
88
|
+
fun getBundleId(): String?
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Gets the current manifest from bundle storage.
|
|
92
|
+
* Returns an empty map when manifest.json is missing or invalid.
|
|
93
|
+
*/
|
|
94
|
+
fun getManifest(): Map<String, Any?>
|
|
95
|
+
|
|
80
96
|
/**
|
|
81
97
|
* Restores the original bundle and clears downloaded bundle state.
|
|
82
98
|
* @return true if the reset was successful
|
|
@@ -99,6 +115,12 @@ class BundleFileStorageService(
|
|
|
99
115
|
private const val TAG = "BundleStorage"
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
private data class ActiveBundleMetadataSnapshot(
|
|
119
|
+
val activeBundleId: String,
|
|
120
|
+
val bundleId: String?,
|
|
121
|
+
val manifest: Map<String, Any?>,
|
|
122
|
+
)
|
|
123
|
+
|
|
102
124
|
init {
|
|
103
125
|
// Ensure bundle store directory exists
|
|
104
126
|
getBundleStoreDir().mkdirs()
|
|
@@ -107,8 +129,11 @@ class BundleFileStorageService(
|
|
|
107
129
|
checkAndCleanupIfIsolationKeyChanged()
|
|
108
130
|
}
|
|
109
131
|
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
private var currentLaunchReport: LaunchReport? = null
|
|
133
|
+
|
|
134
|
+
@Volatile
|
|
135
|
+
private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
|
|
136
|
+
private val activeBundleMetadataLock = Any()
|
|
112
137
|
|
|
113
138
|
// MARK: - Bundle Store Directory
|
|
114
139
|
|
|
@@ -121,6 +146,8 @@ class BundleFileStorageService(
|
|
|
121
146
|
|
|
122
147
|
private fun getCrashedHistoryFile(): File = File(getBundleStoreDir(), CrashedHistory.CRASHED_HISTORY_FILENAME)
|
|
123
148
|
|
|
149
|
+
private fun getLaunchReportFile(): File = File(getBundleStoreDir(), LaunchReport.LAUNCH_REPORT_FILENAME)
|
|
150
|
+
|
|
124
151
|
// MARK: - Metadata Operations
|
|
125
152
|
|
|
126
153
|
private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
|
|
@@ -130,14 +157,30 @@ class BundleFileStorageService(
|
|
|
130
157
|
return updatedMetadata.saveToFile(getMetadataFile())
|
|
131
158
|
}
|
|
132
159
|
|
|
160
|
+
private fun loadLaunchReport(): LaunchReport? =
|
|
161
|
+
currentLaunchReport ?: LaunchReport.loadFromFile(getLaunchReportFile())?.also {
|
|
162
|
+
currentLaunchReport = it
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun saveLaunchReport(report: LaunchReport?) {
|
|
166
|
+
currentLaunchReport = report
|
|
167
|
+
val file = getLaunchReportFile()
|
|
168
|
+
if (report == null) {
|
|
169
|
+
if (file.exists()) {
|
|
170
|
+
file.delete()
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
report.saveToFile(file)
|
|
175
|
+
}
|
|
176
|
+
|
|
133
177
|
private fun createInitialMetadata(): BundleMetadata {
|
|
134
178
|
val currentBundleId = extractBundleIdFromCurrentURL()
|
|
135
|
-
Log.d(TAG, "Creating initial metadata with
|
|
179
|
+
Log.d(TAG, "Creating initial metadata with stagingBundleId: $currentBundleId")
|
|
136
180
|
return BundleMetadata(
|
|
137
|
-
stableBundleId =
|
|
138
|
-
stagingBundleId =
|
|
181
|
+
stableBundleId = null,
|
|
182
|
+
stagingBundleId = currentBundleId,
|
|
139
183
|
verificationPending = false,
|
|
140
|
-
verificationAttemptedAt = null,
|
|
141
184
|
)
|
|
142
185
|
}
|
|
143
186
|
|
|
@@ -148,6 +191,135 @@ class BundleFileStorageService(
|
|
|
148
191
|
return regex.find(currentUrl)?.groupValues?.get(1)
|
|
149
192
|
}
|
|
150
193
|
|
|
194
|
+
private fun findBundleFile(bundleId: String): File? {
|
|
195
|
+
val bundleDir = File(getBundleStoreDir(), bundleId)
|
|
196
|
+
return bundleDir.walk().find { it.name == "index.android.bundle" && it.exists() }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
|
|
200
|
+
|
|
201
|
+
private fun getCurrentVerifiedBundleId(metadata: BundleMetadata): String? =
|
|
202
|
+
when {
|
|
203
|
+
metadata.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
|
|
204
|
+
metadata.stableBundleId != null -> metadata.stableBundleId
|
|
205
|
+
else -> null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private fun getActiveBundleId(): String? {
|
|
209
|
+
val metadata = loadMetadataOrNull()
|
|
210
|
+
return when {
|
|
211
|
+
metadata?.stagingBundleId != null -> metadata.stagingBundleId
|
|
212
|
+
metadata?.stableBundleId != null -> metadata.stableBundleId
|
|
213
|
+
else -> extractBundleIdFromCurrentURL()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun getActiveBundleMetadataSnapshot(): ActiveBundleMetadataSnapshot? {
|
|
218
|
+
val activeBundleId =
|
|
219
|
+
getActiveBundleId() ?: run {
|
|
220
|
+
clearActiveBundleMetadataSnapshot()
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
activeBundleMetadataSnapshot
|
|
225
|
+
?.takeIf { it.activeBundleId == activeBundleId }
|
|
226
|
+
?.let { return it }
|
|
227
|
+
|
|
228
|
+
synchronized(activeBundleMetadataLock) {
|
|
229
|
+
activeBundleMetadataSnapshot
|
|
230
|
+
?.takeIf { it.activeBundleId == activeBundleId }
|
|
231
|
+
?.let { return it }
|
|
232
|
+
|
|
233
|
+
val bundleDir = File(getBundleStoreDir(), activeBundleId)
|
|
234
|
+
if (!bundleDir.exists()) {
|
|
235
|
+
activeBundleMetadataSnapshot = null
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return resolveActiveBundleMetadataSnapshot(bundleDir).also {
|
|
240
|
+
activeBundleMetadataSnapshot = it
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private fun clearActiveBundleMetadataSnapshot() {
|
|
246
|
+
synchronized(activeBundleMetadataLock) {
|
|
247
|
+
activeBundleMetadataSnapshot = null
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun resolveActiveBundleMetadataSnapshot(bundleDir: File): ActiveBundleMetadataSnapshot {
|
|
252
|
+
val manifest = readManifestFromBundleDir(bundleDir) ?: emptyMap()
|
|
253
|
+
val manifestBundleId =
|
|
254
|
+
(manifest["bundleId"] as? String)
|
|
255
|
+
?.trim()
|
|
256
|
+
?.takeIf { it.isNotEmpty() }
|
|
257
|
+
|
|
258
|
+
return ActiveBundleMetadataSnapshot(
|
|
259
|
+
activeBundleId = bundleDir.name,
|
|
260
|
+
bundleId = manifestBundleId ?: readCompatibilityBundleIdFromBundleDir(bundleDir),
|
|
261
|
+
manifest = manifest,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private fun readCompatibilityBundleIdFromBundleDir(bundleDir: File): String? {
|
|
266
|
+
val compatibilityBundleIdFile = File(bundleDir, compatibilityBundleIdFilename())
|
|
267
|
+
if (!compatibilityBundleIdFile.exists()) {
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return try {
|
|
272
|
+
compatibilityBundleIdFile.readText().trim().takeIf { it.isNotEmpty() }
|
|
273
|
+
} catch (e: Exception) {
|
|
274
|
+
Log.w(
|
|
275
|
+
TAG,
|
|
276
|
+
"Failed to read compatibility bundle metadata from ${compatibilityBundleIdFile.absolutePath}: ${e.message}",
|
|
277
|
+
)
|
|
278
|
+
null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private fun compatibilityBundleIdFilename(): String = "BUNDLE_ID"
|
|
283
|
+
|
|
284
|
+
private fun readManifestFromBundleDir(bundleDir: File): Map<String, Any?>? {
|
|
285
|
+
val manifestFile = File(bundleDir, "manifest.json")
|
|
286
|
+
if (!manifestFile.exists()) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return try {
|
|
291
|
+
JSONObject(manifestFile.readText()).let(::jsonObjectToMap)
|
|
292
|
+
} catch (e: Exception) {
|
|
293
|
+
Log.w(TAG, "Failed to read manifest from ${manifestFile.absolutePath}: ${e.message}")
|
|
294
|
+
null
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any?> {
|
|
299
|
+
val result = linkedMapOf<String, Any?>()
|
|
300
|
+
val keys = jsonObject.keys()
|
|
301
|
+
|
|
302
|
+
while (keys.hasNext()) {
|
|
303
|
+
val key = keys.next()
|
|
304
|
+
result[key] = jsonValueToKotlin(jsonObject.opt(key))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return result
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun jsonArrayToList(jsonArray: org.json.JSONArray): List<Any?> =
|
|
311
|
+
List(jsonArray.length()) { index ->
|
|
312
|
+
jsonValueToKotlin(jsonArray.opt(index))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private fun jsonValueToKotlin(value: Any?): Any? =
|
|
316
|
+
when (value) {
|
|
317
|
+
JSONObject.NULL -> null
|
|
318
|
+
is JSONObject -> jsonObjectToMap(value)
|
|
319
|
+
is org.json.JSONArray -> jsonArrayToList(value)
|
|
320
|
+
else -> value
|
|
321
|
+
}
|
|
322
|
+
|
|
151
323
|
/**
|
|
152
324
|
* Checks if isolationKey has changed and cleans up old bundles if needed.
|
|
153
325
|
* This handles migration when isolationKey format changes.
|
|
@@ -213,95 +385,110 @@ class BundleFileStorageService(
|
|
|
213
385
|
|
|
214
386
|
private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
|
|
215
387
|
|
|
216
|
-
private fun
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
val
|
|
221
|
-
metadata
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
388
|
+
private fun prepareMetadataForNewStagingBundle(
|
|
389
|
+
metadata: BundleMetadata,
|
|
390
|
+
bundleId: String,
|
|
391
|
+
): BundleMetadata {
|
|
392
|
+
val currentVerifiedBundleId =
|
|
393
|
+
getCurrentVerifiedBundleId(metadata)?.takeIf { it != bundleId }
|
|
394
|
+
|
|
395
|
+
return metadata.copy(
|
|
396
|
+
stableBundleId = currentVerifiedBundleId,
|
|
397
|
+
stagingBundleId = bundleId,
|
|
398
|
+
verificationPending = true,
|
|
399
|
+
updatedAt = System.currentTimeMillis(),
|
|
400
|
+
)
|
|
227
401
|
}
|
|
228
402
|
|
|
229
|
-
private fun
|
|
230
|
-
val metadata = loadMetadataOrNull() ?: return
|
|
231
|
-
|
|
403
|
+
private fun rollbackPendingBundle(stagingBundleId: String): Boolean {
|
|
404
|
+
val metadata = loadMetadataOrNull() ?: return false
|
|
405
|
+
if (metadata.stagingBundleId != stagingBundleId) {
|
|
406
|
+
return false
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
|
|
410
|
+
|
|
411
|
+
val crashedHistory = loadCrashedHistory()
|
|
412
|
+
crashedHistory.addEntry(stagingBundleId)
|
|
413
|
+
saveCrashedHistory(crashedHistory)
|
|
232
414
|
|
|
233
|
-
|
|
415
|
+
val fallbackBundleId =
|
|
416
|
+
metadata.stableBundleId?.takeIf { candidate ->
|
|
417
|
+
getBundleUrlForId(candidate) != null
|
|
418
|
+
}
|
|
234
419
|
|
|
235
420
|
val updatedMetadata =
|
|
236
421
|
metadata.copy(
|
|
237
|
-
stableBundleId =
|
|
238
|
-
stagingBundleId =
|
|
422
|
+
stableBundleId = null,
|
|
423
|
+
stagingBundleId = fallbackBundleId,
|
|
239
424
|
verificationPending = false,
|
|
240
|
-
verificationAttemptedAt = null,
|
|
241
425
|
updatedAt = System.currentTimeMillis(),
|
|
242
426
|
)
|
|
243
427
|
saveMetadata(updatedMetadata)
|
|
244
428
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
val stableBundleDir = File(bundleStoreDir, stagingBundleId)
|
|
248
|
-
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
249
|
-
if (bundleFile != null) {
|
|
250
|
-
preferences.setItem("HotUpdaterBundleURL", bundleFile.absolutePath)
|
|
251
|
-
}
|
|
429
|
+
val fallbackBundleUrl = fallbackBundleId?.let { getBundleUrlForId(it) }
|
|
430
|
+
setBundleURL(fallbackBundleUrl)
|
|
252
431
|
|
|
253
|
-
|
|
254
|
-
|
|
432
|
+
File(getBundleStoreDir(), stagingBundleId).deleteRecursively()
|
|
433
|
+
saveLaunchReport(LaunchReport(status = "RECOVERED", crashedBundleId = stagingBundleId))
|
|
434
|
+
return true
|
|
255
435
|
}
|
|
256
436
|
|
|
257
|
-
private fun
|
|
437
|
+
private fun applyPendingRecoveryIfNeeded(pendingRecovery: PendingCrashRecovery?) {
|
|
258
438
|
val metadata = loadMetadataOrNull() ?: return
|
|
259
439
|
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
260
440
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// Save rollback info to session variable (memory only)
|
|
269
|
-
sessionRollbackBundleId = stagingBundleId
|
|
441
|
+
if (pendingRecovery?.shouldRollback == true &&
|
|
442
|
+
pendingRecovery.launchedBundleId == stagingBundleId &&
|
|
443
|
+
isVerificationPending(metadata)
|
|
444
|
+
) {
|
|
445
|
+
rollbackPendingBundle(stagingBundleId)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
270
448
|
|
|
271
|
-
|
|
272
|
-
val
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
449
|
+
private fun selectLaunch(): LaunchSelection {
|
|
450
|
+
val metadata = loadMetadataOrNull()
|
|
451
|
+
if (metadata == null) {
|
|
452
|
+
val cached = getCachedBundleURL()
|
|
453
|
+
return LaunchSelection(
|
|
454
|
+
bundleUrl = cached ?: getFallbackBundleURL(),
|
|
455
|
+
launchedBundleId = extractBundleIdFromCurrentURL(),
|
|
456
|
+
shouldRollbackOnCrash = false,
|
|
279
457
|
)
|
|
280
|
-
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
metadata.stagingBundleId?.let { stagingBundleId ->
|
|
461
|
+
val stagingBundleUrl = getBundleUrlForId(stagingBundleId)
|
|
462
|
+
if (stagingBundleUrl != null) {
|
|
463
|
+
return LaunchSelection(
|
|
464
|
+
bundleUrl = stagingBundleUrl,
|
|
465
|
+
launchedBundleId = stagingBundleId,
|
|
466
|
+
shouldRollbackOnCrash = metadata.verificationPending,
|
|
467
|
+
)
|
|
468
|
+
}
|
|
281
469
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (stableBundleId != null) {
|
|
285
|
-
val bundleStoreDir = getBundleStoreDir()
|
|
286
|
-
val stableBundleDir = File(bundleStoreDir, stableBundleId)
|
|
287
|
-
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
288
|
-
if (bundleFile != null && bundleFile.exists()) {
|
|
289
|
-
setBundleURL(bundleFile.absolutePath)
|
|
290
|
-
Log.d(TAG, "Updated bundle URL to stable: $stableBundleId")
|
|
470
|
+
if (metadata.verificationPending && rollbackPendingBundle(stagingBundleId)) {
|
|
471
|
+
return selectLaunch()
|
|
291
472
|
}
|
|
292
|
-
} else {
|
|
293
|
-
// No stable bundle available, clear bundle URL (fallback to assets)
|
|
294
|
-
setBundleURL(null)
|
|
295
|
-
Log.d(TAG, "Cleared bundle URL (no stable bundle)")
|
|
296
473
|
}
|
|
297
474
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
475
|
+
metadata.stableBundleId?.let { stableBundleId ->
|
|
476
|
+
val stableBundleUrl = getBundleUrlForId(stableBundleId)
|
|
477
|
+
if (stableBundleUrl != null) {
|
|
478
|
+
return LaunchSelection(
|
|
479
|
+
bundleUrl = stableBundleUrl,
|
|
480
|
+
launchedBundleId = stableBundleId,
|
|
481
|
+
shouldRollbackOnCrash = false,
|
|
482
|
+
)
|
|
483
|
+
}
|
|
304
484
|
}
|
|
485
|
+
|
|
486
|
+
val cached = getCachedBundleURL()
|
|
487
|
+
return LaunchSelection(
|
|
488
|
+
bundleUrl = cached ?: getFallbackBundleURL(),
|
|
489
|
+
launchedBundleId = extractBundleIdFromCurrentURL(),
|
|
490
|
+
shouldRollbackOnCrash = false,
|
|
491
|
+
)
|
|
305
492
|
}
|
|
306
493
|
|
|
307
494
|
// MARK: - Crashed History
|
|
@@ -321,41 +508,29 @@ class BundleFileStorageService(
|
|
|
321
508
|
return true
|
|
322
509
|
}
|
|
323
510
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
?: return mapOf("status" to "STABLE")
|
|
330
|
-
|
|
331
|
-
// Check if there was a recent rollback (session variable)
|
|
332
|
-
sessionRollbackBundleId?.let { crashedBundleId ->
|
|
333
|
-
// Clear rollback info (one-time read)
|
|
334
|
-
sessionRollbackBundleId = null
|
|
335
|
-
|
|
336
|
-
Log.d(TAG, "notifyAppReady: recovered from rollback (crashed bundle: $crashedBundleId)")
|
|
337
|
-
return mapOf(
|
|
338
|
-
"status" to "RECOVERED",
|
|
339
|
-
"crashedBundleId" to crashedBundleId,
|
|
340
|
-
)
|
|
511
|
+
override fun markLaunchCompleted(currentBundleId: String?) {
|
|
512
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
513
|
+
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
514
|
+
if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
|
|
515
|
+
return
|
|
341
516
|
}
|
|
342
517
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
} else {
|
|
351
|
-
Log.d(TAG, "notifyAppReady: bundleId mismatch (staging=$stagingBundleId, current=$currentBundleId)")
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
Log.d(TAG, "notifyAppReady: no verification pending")
|
|
355
|
-
}
|
|
518
|
+
saveMetadata(
|
|
519
|
+
metadata.copy(
|
|
520
|
+
verificationPending = false,
|
|
521
|
+
updatedAt = System.currentTimeMillis(),
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
}
|
|
356
525
|
|
|
357
|
-
|
|
358
|
-
|
|
526
|
+
// MARK: - notifyAppReady
|
|
527
|
+
|
|
528
|
+
override fun notifyAppReady(): Map<String, Any?> {
|
|
529
|
+
val report = loadLaunchReport() ?: return mapOf("status" to "STABLE")
|
|
530
|
+
return buildMap {
|
|
531
|
+
put("status", report.status)
|
|
532
|
+
report.crashedBundleId?.let { put("crashedBundleId", it) }
|
|
533
|
+
}
|
|
359
534
|
}
|
|
360
535
|
|
|
361
536
|
// MARK: - Bundle URL Operations
|
|
@@ -363,6 +538,7 @@ class BundleFileStorageService(
|
|
|
363
538
|
override fun setBundleURL(localPath: String?): Boolean {
|
|
364
539
|
Log.d(TAG, "setBundleURL: $localPath")
|
|
365
540
|
preferences.setItem("HotUpdaterBundleURL", localPath)
|
|
541
|
+
clearActiveBundleMetadataSnapshot()
|
|
366
542
|
return true
|
|
367
543
|
}
|
|
368
544
|
|
|
@@ -387,73 +563,16 @@ class BundleFileStorageService(
|
|
|
387
563
|
|
|
388
564
|
override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
|
|
389
565
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
override fun getBundleURL(): String {
|
|
394
|
-
val metadata = loadMetadataOrNull()
|
|
395
|
-
|
|
396
|
-
if (metadata == null) {
|
|
397
|
-
// Legacy mode: no metadata.json exists, use existing behavior
|
|
398
|
-
val cached = getCachedBundleURL()
|
|
399
|
-
val result = cached ?: getFallbackBundleURL()
|
|
400
|
-
Log.d(TAG, "getBundleURL (legacy): returning $result")
|
|
401
|
-
return result
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// New rollback-aware mode - only run crash detection ONCE per process
|
|
405
|
-
if (isVerificationPending(metadata) && !crashDetectionCompleted) {
|
|
406
|
-
crashDetectionCompleted = true
|
|
407
|
-
|
|
408
|
-
if (wasVerificationAttempted(metadata)) {
|
|
409
|
-
// Already executed once but didn't call notifyAppReady → crash!
|
|
410
|
-
Log.w(TAG, "Crash detected: staging bundle executed but didn't call notifyAppReady")
|
|
411
|
-
rollbackToStable()
|
|
412
|
-
} else {
|
|
413
|
-
// First execution - mark verification attempted and give it a chance
|
|
414
|
-
Log.d(TAG, "First execution of staging bundle, marking verification attempted")
|
|
415
|
-
markVerificationAttempted()
|
|
416
|
-
}
|
|
417
|
-
}
|
|
566
|
+
override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
|
|
567
|
+
saveLaunchReport(null)
|
|
568
|
+
applyPendingRecoveryIfNeeded(pendingRecovery)
|
|
418
569
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (stagingId != null) {
|
|
426
|
-
val bundleStoreDir = getBundleStoreDir()
|
|
427
|
-
val stagingBundleDir = File(bundleStoreDir, stagingId)
|
|
428
|
-
val bundleFile = stagingBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
429
|
-
if (bundleFile != null && bundleFile.exists()) {
|
|
430
|
-
Log.d(TAG, "getBundleURL: returning STAGING bundle $stagingId")
|
|
431
|
-
return bundleFile.absolutePath
|
|
432
|
-
} else {
|
|
433
|
-
Log.w(TAG, "getBundleURL: staging bundle file not found for $stagingId")
|
|
434
|
-
// Staging bundle file missing, rollback to stable
|
|
435
|
-
rollbackToStable()
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Return stable bundle URL
|
|
441
|
-
val stableBundleId = currentMetadata?.stableBundleId
|
|
442
|
-
if (stableBundleId != null) {
|
|
443
|
-
val bundleStoreDir = getBundleStoreDir()
|
|
444
|
-
val stableBundleDir = File(bundleStoreDir, stableBundleId)
|
|
445
|
-
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
446
|
-
if (bundleFile != null && bundleFile.exists()) {
|
|
447
|
-
Log.d(TAG, "getBundleURL: returning stable bundle $stableBundleId")
|
|
448
|
-
return bundleFile.absolutePath
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Fallback
|
|
453
|
-
val cached = getCachedBundleURL()
|
|
454
|
-
val result = cached ?: getFallbackBundleURL()
|
|
455
|
-
Log.d(TAG, "getBundleURL: returning $result (cached=$cached)")
|
|
456
|
-
return result
|
|
570
|
+
val selection = selectLaunch()
|
|
571
|
+
Log.d(
|
|
572
|
+
TAG,
|
|
573
|
+
"prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
|
|
574
|
+
)
|
|
575
|
+
return selection
|
|
457
576
|
}
|
|
458
577
|
|
|
459
578
|
override suspend fun updateBundle(
|
|
@@ -525,23 +644,16 @@ class BundleFileStorageService(
|
|
|
525
644
|
|
|
526
645
|
// Update metadata: set as staging
|
|
527
646
|
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
528
|
-
val updatedMetadata =
|
|
529
|
-
currentMetadata.copy(
|
|
530
|
-
stagingBundleId = bundleId,
|
|
531
|
-
verificationPending = true,
|
|
532
|
-
verificationAttemptedAt = null,
|
|
533
|
-
updatedAt = System.currentTimeMillis(),
|
|
534
|
-
)
|
|
647
|
+
val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
|
|
535
648
|
saveMetadata(updatedMetadata)
|
|
536
649
|
|
|
537
650
|
// Set bundle URL for backwards compatibility
|
|
538
651
|
setBundleURL(existingIndexFile.absolutePath)
|
|
539
652
|
|
|
540
|
-
// Keep
|
|
541
|
-
|
|
542
|
-
cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
|
|
653
|
+
// Keep the current verified bundle as a fallback if one exists.
|
|
654
|
+
cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
|
|
543
655
|
|
|
544
|
-
Log.d(TAG, "Existing bundle set as staging
|
|
656
|
+
Log.d(TAG, "Existing bundle set as staging bundle for next launch")
|
|
545
657
|
return
|
|
546
658
|
} else {
|
|
547
659
|
// If index.android.bundle is missing, delete and re-download
|
|
@@ -721,13 +833,7 @@ class BundleFileStorageService(
|
|
|
721
833
|
|
|
722
834
|
// Update metadata: set new bundle as staging
|
|
723
835
|
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
724
|
-
val updatedMetadata =
|
|
725
|
-
currentMetadata.copy(
|
|
726
|
-
stagingBundleId = bundleId,
|
|
727
|
-
verificationPending = true,
|
|
728
|
-
verificationAttemptedAt = null,
|
|
729
|
-
updatedAt = System.currentTimeMillis(),
|
|
730
|
-
)
|
|
836
|
+
val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
|
|
731
837
|
saveMetadata(updatedMetadata)
|
|
732
838
|
|
|
733
839
|
// Also update HotUpdaterBundleURL for backwards compatibility
|
|
@@ -737,11 +843,10 @@ class BundleFileStorageService(
|
|
|
737
843
|
// 11) Clean up temporary and download folders
|
|
738
844
|
tempDir.deleteRecursively()
|
|
739
845
|
|
|
740
|
-
// 12) Keep
|
|
741
|
-
|
|
742
|
-
cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
|
|
846
|
+
// 12) Keep the fallback bundle and the new staging bundle.
|
|
847
|
+
cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
|
|
743
848
|
|
|
744
|
-
Log.d(TAG, "Downloaded and set bundle as staging successfully
|
|
849
|
+
Log.d(TAG, "Downloaded and set bundle as staging successfully for the next launch.")
|
|
745
850
|
// Progress already at 1.0 from unzip completion
|
|
746
851
|
}
|
|
747
852
|
}
|
|
@@ -813,14 +918,7 @@ class BundleFileStorageService(
|
|
|
813
918
|
*/
|
|
814
919
|
override fun getBaseURL(): String {
|
|
815
920
|
return try {
|
|
816
|
-
val
|
|
817
|
-
val activeBundleId =
|
|
818
|
-
when {
|
|
819
|
-
metadata?.verificationPending == true && metadata.stagingBundleId != null ->
|
|
820
|
-
metadata.stagingBundleId
|
|
821
|
-
metadata?.stableBundleId != null -> metadata.stableBundleId
|
|
822
|
-
else -> extractBundleIdFromCurrentURL()
|
|
823
|
-
}
|
|
921
|
+
val activeBundleId = getActiveBundleId()
|
|
824
922
|
|
|
825
923
|
if (activeBundleId != null) {
|
|
826
924
|
val bundleDir = File(getBundleStoreDir(), activeBundleId)
|
|
@@ -836,6 +934,22 @@ class BundleFileStorageService(
|
|
|
836
934
|
}
|
|
837
935
|
}
|
|
838
936
|
|
|
937
|
+
override fun getBundleId(): String? =
|
|
938
|
+
try {
|
|
939
|
+
getActiveBundleMetadataSnapshot()?.bundleId
|
|
940
|
+
} catch (e: Exception) {
|
|
941
|
+
Log.e(TAG, "Error getting bundle ID: ${e.message}")
|
|
942
|
+
null
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
override fun getManifest(): Map<String, Any?> =
|
|
946
|
+
try {
|
|
947
|
+
getActiveBundleMetadataSnapshot()?.manifest ?: emptyMap()
|
|
948
|
+
} catch (e: Exception) {
|
|
949
|
+
Log.e(TAG, "Error getting manifest: ${e.message}")
|
|
950
|
+
emptyMap()
|
|
951
|
+
}
|
|
952
|
+
|
|
839
953
|
override suspend fun resetChannel(): Boolean =
|
|
840
954
|
withContext(Dispatchers.IO) {
|
|
841
955
|
if (!setBundleURL(null)) {
|
|
@@ -848,18 +962,19 @@ class BundleFileStorageService(
|
|
|
848
962
|
stableBundleId = null,
|
|
849
963
|
stagingBundleId = null,
|
|
850
964
|
verificationPending = false,
|
|
851
|
-
verificationAttemptedAt = null,
|
|
852
|
-
stagingExecutionCount = null,
|
|
853
965
|
)
|
|
854
966
|
|
|
855
967
|
if (!saveMetadata(clearedMetadata)) {
|
|
856
968
|
return@withContext false
|
|
857
969
|
}
|
|
858
970
|
|
|
971
|
+
saveLaunchReport(null)
|
|
972
|
+
|
|
859
973
|
getBundleStoreDir().listFiles()?.forEach { file ->
|
|
860
974
|
if (
|
|
861
975
|
file.name == BundleMetadata.METADATA_FILENAME ||
|
|
862
|
-
file.name == CrashedHistory.CRASHED_HISTORY_FILENAME
|
|
976
|
+
file.name == CrashedHistory.CRASHED_HISTORY_FILENAME ||
|
|
977
|
+
file.name == LaunchReport.LAUNCH_REPORT_FILENAME
|
|
863
978
|
) {
|
|
864
979
|
return@forEach
|
|
865
980
|
}
|