@hot-updater/react-native 0.23.0 → 0.24.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/src/main/java/com/hotupdater/BundleFileStorageService.kt +393 -49
- package/android/src/main/java/com/hotupdater/BundleMetadata.kt +204 -0
- package/android/src/main/java/com/hotupdater/HotUpdater.kt +48 -36
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +134 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +168 -95
- package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +15 -3
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +17 -12
- package/android/src/newarch/HotUpdaterModule.kt +88 -23
- package/android/src/oldarch/HotUpdaterModule.kt +89 -22
- package/android/src/oldarch/HotUpdaterSpec.kt +6 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +401 -77
- package/ios/HotUpdater/Internal/BundleMetadata.swift +177 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +213 -47
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +96 -25
- package/ios/HotUpdater/Internal/SignatureVerifier.swift +35 -29
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +2 -2
- package/ios/HotUpdater/Public/HotUpdater.h +8 -2
- package/lib/commonjs/checkForUpdate.js +31 -28
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/error.js +45 -1
- package/lib/commonjs/error.js.map +1 -1
- package/lib/commonjs/fetchUpdateInfo.js +7 -45
- package/lib/commonjs/fetchUpdateInfo.js.map +1 -1
- package/lib/commonjs/index.js +237 -208
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +103 -3
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/commonjs/wrap.js +39 -1
- package/lib/commonjs/wrap.js.map +1 -1
- package/lib/module/checkForUpdate.js +32 -26
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/error.js +45 -0
- package/lib/module/error.js.map +1 -1
- package/lib/module/fetchUpdateInfo.js +7 -45
- package/lib/module/fetchUpdateInfo.js.map +1 -1
- package/lib/module/index.js +238 -203
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +87 -2
- package/lib/module/native.js.map +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/wrap.js +40 -2
- package/lib/module/wrap.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts +11 -13
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/error.d.ts +120 -0
- package/lib/typescript/commonjs/error.d.ts.map +1 -1
- package/lib/typescript/commonjs/fetchUpdateInfo.d.ts +3 -5
- package/lib/typescript/commonjs/fetchUpdateInfo.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +35 -41
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +58 -2
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +62 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +76 -5
- package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts +11 -13
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/error.d.ts +120 -0
- package/lib/typescript/module/error.d.ts.map +1 -1
- package/lib/typescript/module/fetchUpdateInfo.d.ts +3 -5
- package/lib/typescript/module/fetchUpdateInfo.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +35 -41
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +58 -2
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +62 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +76 -5
- package/lib/typescript/module/wrap.d.ts.map +1 -1
- package/package.json +8 -7
- package/plugin/build/withHotUpdater.js +55 -4
- package/src/checkForUpdate.ts +51 -40
- package/src/error.ts +153 -0
- package/src/fetchUpdateInfo.ts +10 -58
- package/src/index.ts +283 -206
- package/src/native.ts +88 -2
- package/src/specs/NativeHotUpdater.ts +63 -0
- package/src/wrap.tsx +131 -9
- package/android/src/main/java/com/hotupdater/HotUpdaterFactory.kt +0 -52
- package/ios/HotUpdater/Internal/HotUpdaterFactory.swift +0 -24
- package/lib/commonjs/runUpdateProcess.js +0 -69
- package/lib/commonjs/runUpdateProcess.js.map +0 -1
- package/lib/module/runUpdateProcess.js +0 -64
- package/lib/module/runUpdateProcess.js.map +0 -1
- package/lib/typescript/commonjs/runUpdateProcess.d.ts +0 -49
- package/lib/typescript/commonjs/runUpdateProcess.d.ts.map +0 -1
- package/lib/typescript/module/runUpdateProcess.d.ts +0 -49
- package/lib/typescript/module/runUpdateProcess.d.ts.map +0 -1
- package/src/runUpdateProcess.ts +0 -80
|
@@ -32,6 +32,7 @@ interface BundleStorageService {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Gets the URL to the bundle file (cached or fallback)
|
|
35
|
+
* With rollback support: checks for crashed staging bundles
|
|
35
36
|
* @return The path to the bundle file
|
|
36
37
|
*/
|
|
37
38
|
fun getBundleURL(): String
|
|
@@ -42,14 +43,33 @@ interface BundleStorageService {
|
|
|
42
43
|
* @param fileUrl URL of the bundle file to download (or null to reset)
|
|
43
44
|
* @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
|
|
44
45
|
* @param progressCallback Callback for download progress updates
|
|
45
|
-
* @
|
|
46
|
+
* @throws HotUpdaterException if the update fails
|
|
46
47
|
*/
|
|
47
48
|
suspend fun updateBundle(
|
|
48
49
|
bundleId: String,
|
|
49
50
|
fileUrl: String?,
|
|
50
51
|
fileHash: String?,
|
|
51
52
|
progressCallback: (Double) -> Unit,
|
|
52
|
-
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Notifies that the app has started successfully with the current bundle
|
|
57
|
+
* @param currentBundleId The bundle ID that JS reports as currently loaded
|
|
58
|
+
* @return Map containing status and optional crashedBundleId
|
|
59
|
+
*/
|
|
60
|
+
fun notifyAppReady(currentBundleId: String?): Map<String, Any?>
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the crashed bundle history
|
|
64
|
+
* @return CrashedHistory containing crashed bundles
|
|
65
|
+
*/
|
|
66
|
+
fun getCrashHistory(): CrashedHistory
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clears the crashed bundle history
|
|
70
|
+
* @return true if clearing was successful
|
|
71
|
+
*/
|
|
72
|
+
fun clearCrashHistory(): Boolean
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
/**
|
|
@@ -62,20 +82,219 @@ class BundleFileStorageService(
|
|
|
62
82
|
private val decompressService: DecompressService,
|
|
63
83
|
private val preferences: PreferencesService,
|
|
64
84
|
) : BundleStorageService {
|
|
85
|
+
companion object {
|
|
86
|
+
private const val TAG = "BundleStorage"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Session-only rollback tracking (in-memory)
|
|
90
|
+
private var sessionRollbackBundleId: String? = null
|
|
91
|
+
|
|
92
|
+
// MARK: - Bundle Store Directory
|
|
93
|
+
|
|
94
|
+
private fun getBundleStoreDir(): File {
|
|
95
|
+
val baseDir = fileSystem.getExternalFilesDir()
|
|
96
|
+
return File(baseDir, "bundle-store")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private fun getMetadataFile(): File = File(getBundleStoreDir(), BundleMetadata.METADATA_FILENAME)
|
|
100
|
+
|
|
101
|
+
private fun getCrashedHistoryFile(): File = File(getBundleStoreDir(), CrashedHistory.CRASHED_HISTORY_FILENAME)
|
|
102
|
+
|
|
103
|
+
// MARK: - Metadata Operations
|
|
104
|
+
|
|
105
|
+
private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile())
|
|
106
|
+
|
|
107
|
+
private fun saveMetadata(metadata: BundleMetadata): Boolean = metadata.saveToFile(getMetadataFile())
|
|
108
|
+
|
|
109
|
+
private fun createInitialMetadata(): BundleMetadata {
|
|
110
|
+
val currentBundleId = extractBundleIdFromCurrentURL()
|
|
111
|
+
Log.d(TAG, "Creating initial metadata with stableBundleId: $currentBundleId")
|
|
112
|
+
return BundleMetadata(
|
|
113
|
+
stableBundleId = currentBundleId,
|
|
114
|
+
stagingBundleId = null,
|
|
115
|
+
verificationPending = false,
|
|
116
|
+
verificationAttemptedAt = null,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun extractBundleIdFromCurrentURL(): String? {
|
|
121
|
+
val currentUrl = preferences.getItem("HotUpdaterBundleURL") ?: return null
|
|
122
|
+
// "bundle-store/abc123/index.android.bundle" -> "abc123"
|
|
123
|
+
val regex = Regex("bundle-store/([^/]+)/")
|
|
124
|
+
return regex.find(currentUrl)?.groupValues?.get(1)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// MARK: - State Machine
|
|
128
|
+
|
|
129
|
+
private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
|
|
130
|
+
|
|
131
|
+
private fun wasVerificationAttempted(metadata: BundleMetadata): Boolean = metadata.verificationAttemptedAt != null
|
|
132
|
+
|
|
133
|
+
private fun markVerificationAttempted() {
|
|
134
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
135
|
+
val updatedMetadata =
|
|
136
|
+
metadata.copy(
|
|
137
|
+
verificationAttemptedAt = System.currentTimeMillis(),
|
|
138
|
+
updatedAt = System.currentTimeMillis(),
|
|
139
|
+
)
|
|
140
|
+
saveMetadata(updatedMetadata)
|
|
141
|
+
Log.d(TAG, "Marked verification attempted at ${updatedMetadata.verificationAttemptedAt}")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun promoteStagingToStable() {
|
|
145
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
146
|
+
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
147
|
+
|
|
148
|
+
Log.d(TAG, "Promoting staging bundle $stagingBundleId to stable")
|
|
149
|
+
|
|
150
|
+
val updatedMetadata =
|
|
151
|
+
metadata.copy(
|
|
152
|
+
stableBundleId = stagingBundleId,
|
|
153
|
+
stagingBundleId = null,
|
|
154
|
+
verificationPending = false,
|
|
155
|
+
verificationAttemptedAt = null,
|
|
156
|
+
updatedAt = System.currentTimeMillis(),
|
|
157
|
+
)
|
|
158
|
+
saveMetadata(updatedMetadata)
|
|
159
|
+
|
|
160
|
+
// Update HotUpdaterBundleURL preference to point to stable bundle
|
|
161
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
162
|
+
val stableBundleDir = File(bundleStoreDir, stagingBundleId)
|
|
163
|
+
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
164
|
+
if (bundleFile != null) {
|
|
165
|
+
preferences.setItem("HotUpdaterBundleURL", bundleFile.absolutePath)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Cleanup old bundles (keep only the new stable)
|
|
169
|
+
cleanupOldBundles(bundleStoreDir, null, stagingBundleId)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun rollbackToStable() {
|
|
173
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
174
|
+
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
175
|
+
|
|
176
|
+
Log.w(TAG, "Rolling back: adding $stagingBundleId to crashed history")
|
|
177
|
+
|
|
178
|
+
// Add to crashed history
|
|
179
|
+
val crashedHistory = loadCrashedHistory()
|
|
180
|
+
crashedHistory.addEntry(stagingBundleId)
|
|
181
|
+
saveCrashedHistory(crashedHistory)
|
|
182
|
+
|
|
183
|
+
// Save rollback info to session variable (memory only)
|
|
184
|
+
sessionRollbackBundleId = stagingBundleId
|
|
185
|
+
|
|
186
|
+
// Clear staging pointer
|
|
187
|
+
val updatedMetadata =
|
|
188
|
+
metadata.copy(
|
|
189
|
+
stagingBundleId = null,
|
|
190
|
+
verificationPending = false,
|
|
191
|
+
verificationAttemptedAt = null,
|
|
192
|
+
stagingExecutionCount = null,
|
|
193
|
+
updatedAt = System.currentTimeMillis(),
|
|
194
|
+
)
|
|
195
|
+
saveMetadata(updatedMetadata)
|
|
196
|
+
|
|
197
|
+
// Update bundle URL to point to stable bundle
|
|
198
|
+
val stableBundleId = updatedMetadata.stableBundleId
|
|
199
|
+
if (stableBundleId != null) {
|
|
200
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
201
|
+
val stableBundleDir = File(bundleStoreDir, stableBundleId)
|
|
202
|
+
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
203
|
+
if (bundleFile != null && bundleFile.exists()) {
|
|
204
|
+
setBundleURL(bundleFile.absolutePath)
|
|
205
|
+
Log.d(TAG, "Updated bundle URL to stable: $stableBundleId")
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
// No stable bundle available, clear bundle URL (fallback to assets)
|
|
209
|
+
setBundleURL(null)
|
|
210
|
+
Log.d(TAG, "Cleared bundle URL (no stable bundle)")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Remove staging bundle directory
|
|
214
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
215
|
+
val stagingBundleDir = File(bundleStoreDir, stagingBundleId)
|
|
216
|
+
if (stagingBundleDir.exists()) {
|
|
217
|
+
stagingBundleDir.deleteRecursively()
|
|
218
|
+
Log.d(TAG, "Deleted crashed staging bundle directory: $stagingBundleId")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// MARK: - Crashed History
|
|
223
|
+
|
|
224
|
+
private fun loadCrashedHistory(): CrashedHistory = CrashedHistory.loadFromFile(getCrashedHistoryFile())
|
|
225
|
+
|
|
226
|
+
private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
|
|
227
|
+
|
|
228
|
+
private fun isBundleInCrashedHistory(bundleId: String): Boolean = loadCrashedHistory().contains(bundleId)
|
|
229
|
+
|
|
230
|
+
override fun getCrashHistory(): CrashedHistory = loadCrashedHistory()
|
|
231
|
+
|
|
232
|
+
override fun clearCrashHistory(): Boolean {
|
|
233
|
+
val history = CrashedHistory()
|
|
234
|
+
saveCrashedHistory(history)
|
|
235
|
+
Log.d(TAG, "Cleared crash history")
|
|
236
|
+
return true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// MARK: - notifyAppReady
|
|
240
|
+
|
|
241
|
+
override fun notifyAppReady(currentBundleId: String?): Map<String, Any?> {
|
|
242
|
+
val metadata =
|
|
243
|
+
loadMetadataOrNull()
|
|
244
|
+
?: return mapOf("status" to "STABLE")
|
|
245
|
+
|
|
246
|
+
// Check if there was a recent rollback (session variable)
|
|
247
|
+
sessionRollbackBundleId?.let { crashedBundleId ->
|
|
248
|
+
// Clear rollback info (one-time read)
|
|
249
|
+
sessionRollbackBundleId = null
|
|
250
|
+
|
|
251
|
+
Log.d(TAG, "notifyAppReady: recovered from rollback (crashed bundle: $crashedBundleId)")
|
|
252
|
+
return mapOf(
|
|
253
|
+
"status" to "RECOVERED",
|
|
254
|
+
"crashedBundleId" to crashedBundleId,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check for promotion
|
|
259
|
+
if (isVerificationPending(metadata)) {
|
|
260
|
+
val stagingBundleId = metadata.stagingBundleId
|
|
261
|
+
if (stagingBundleId != null && stagingBundleId == currentBundleId) {
|
|
262
|
+
Log.d(TAG, "App started successfully with staging bundle $currentBundleId, promoting to stable")
|
|
263
|
+
promoteStagingToStable()
|
|
264
|
+
return mapOf("status" to "PROMOTED")
|
|
265
|
+
} else {
|
|
266
|
+
Log.d(TAG, "notifyAppReady: bundleId mismatch (staging=$stagingBundleId, current=$currentBundleId)")
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
Log.d(TAG, "notifyAppReady: no verification pending")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No changes
|
|
273
|
+
return mapOf("status" to "STABLE")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// MARK: - Bundle URL Operations
|
|
277
|
+
|
|
65
278
|
override fun setBundleURL(localPath: String?): Boolean {
|
|
279
|
+
Log.d(TAG, "setBundleURL: $localPath")
|
|
66
280
|
preferences.setItem("HotUpdaterBundleURL", localPath)
|
|
67
281
|
return true
|
|
68
282
|
}
|
|
69
283
|
|
|
70
284
|
override fun getCachedBundleURL(): String? {
|
|
71
285
|
val urlString = preferences.getItem("HotUpdaterBundleURL")
|
|
286
|
+
Log.d(TAG, "getCachedBundleURL: read from prefs = $urlString")
|
|
72
287
|
if (urlString.isNullOrEmpty()) {
|
|
288
|
+
Log.d(TAG, "getCachedBundleURL: urlString is null or empty")
|
|
73
289
|
return null
|
|
74
290
|
}
|
|
75
291
|
|
|
76
292
|
val file = File(urlString)
|
|
77
|
-
|
|
293
|
+
val exists = file.exists()
|
|
294
|
+
Log.d(TAG, "getCachedBundleURL: file exists = $exists at path: $urlString")
|
|
295
|
+
if (!exists) {
|
|
78
296
|
preferences.setItem("HotUpdaterBundleURL", null)
|
|
297
|
+
Log.d(TAG, "getCachedBundleURL: file doesn't exist, cleared preference")
|
|
79
298
|
return null
|
|
80
299
|
}
|
|
81
300
|
return urlString
|
|
@@ -83,63 +302,154 @@ class BundleFileStorageService(
|
|
|
83
302
|
|
|
84
303
|
override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
|
|
85
304
|
|
|
86
|
-
|
|
305
|
+
// Track if crash detection has already run in this process
|
|
306
|
+
private var crashDetectionCompleted = false
|
|
307
|
+
|
|
308
|
+
override fun getBundleURL(): String {
|
|
309
|
+
val metadata = loadMetadataOrNull()
|
|
310
|
+
|
|
311
|
+
if (metadata == null) {
|
|
312
|
+
// Legacy mode: no metadata.json exists, use existing behavior
|
|
313
|
+
val cached = getCachedBundleURL()
|
|
314
|
+
val result = cached ?: getFallbackBundleURL()
|
|
315
|
+
Log.d(TAG, "getBundleURL (legacy): returning $result")
|
|
316
|
+
return result
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// New rollback-aware mode - only run crash detection ONCE per process
|
|
320
|
+
if (isVerificationPending(metadata) && !crashDetectionCompleted) {
|
|
321
|
+
crashDetectionCompleted = true
|
|
322
|
+
|
|
323
|
+
if (wasVerificationAttempted(metadata)) {
|
|
324
|
+
// Already executed once but didn't call notifyAppReady → crash!
|
|
325
|
+
Log.w(TAG, "Crash detected: staging bundle executed but didn't call notifyAppReady")
|
|
326
|
+
rollbackToStable()
|
|
327
|
+
} else {
|
|
328
|
+
// First execution - mark verification attempted and give it a chance
|
|
329
|
+
Log.d(TAG, "First execution of staging bundle, marking verification attempted")
|
|
330
|
+
markVerificationAttempted()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Reload metadata after potential rollback
|
|
335
|
+
val currentMetadata = loadMetadataOrNull()
|
|
336
|
+
|
|
337
|
+
// Return staging bundle if verification pending
|
|
338
|
+
if (currentMetadata != null && isVerificationPending(currentMetadata)) {
|
|
339
|
+
val stagingId = currentMetadata.stagingBundleId
|
|
340
|
+
if (stagingId != null) {
|
|
341
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
342
|
+
val stagingBundleDir = File(bundleStoreDir, stagingId)
|
|
343
|
+
val bundleFile = stagingBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
344
|
+
if (bundleFile != null && bundleFile.exists()) {
|
|
345
|
+
Log.d(TAG, "getBundleURL: returning STAGING bundle $stagingId")
|
|
346
|
+
return bundleFile.absolutePath
|
|
347
|
+
} else {
|
|
348
|
+
Log.w(TAG, "getBundleURL: staging bundle file not found for $stagingId")
|
|
349
|
+
// Staging bundle file missing, rollback to stable
|
|
350
|
+
rollbackToStable()
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Return stable bundle URL
|
|
356
|
+
val stableBundleId = currentMetadata?.stableBundleId
|
|
357
|
+
if (stableBundleId != null) {
|
|
358
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
359
|
+
val stableBundleDir = File(bundleStoreDir, stableBundleId)
|
|
360
|
+
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
361
|
+
if (bundleFile != null && bundleFile.exists()) {
|
|
362
|
+
Log.d(TAG, "getBundleURL: returning stable bundle $stableBundleId")
|
|
363
|
+
return bundleFile.absolutePath
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Fallback
|
|
368
|
+
val cached = getCachedBundleURL()
|
|
369
|
+
val result = cached ?: getFallbackBundleURL()
|
|
370
|
+
Log.d(TAG, "getBundleURL: returning $result (cached=$cached)")
|
|
371
|
+
return result
|
|
372
|
+
}
|
|
87
373
|
|
|
88
374
|
override suspend fun updateBundle(
|
|
89
375
|
bundleId: String,
|
|
90
376
|
fileUrl: String?,
|
|
91
377
|
fileHash: String?,
|
|
92
378
|
progressCallback: (Double) -> Unit,
|
|
93
|
-
)
|
|
379
|
+
) {
|
|
94
380
|
Log.d(
|
|
95
|
-
|
|
381
|
+
TAG,
|
|
96
382
|
"updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash",
|
|
97
383
|
)
|
|
98
384
|
|
|
99
385
|
// If no URL is provided, reset to fallback
|
|
100
386
|
if (fileUrl.isNullOrEmpty()) {
|
|
101
387
|
setBundleURL(null)
|
|
102
|
-
return
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Check if bundle is in crashed history
|
|
392
|
+
if (isBundleInCrashedHistory(bundleId)) {
|
|
393
|
+
Log.w(TAG, "Bundle $bundleId is in crashed history, rejecting update")
|
|
394
|
+
throw HotUpdaterException.bundleInCrashedHistory(bundleId)
|
|
103
395
|
}
|
|
104
396
|
|
|
397
|
+
// Initialize metadata if it doesn't exist (lazy initialization)
|
|
398
|
+
val existingMetadata = loadMetadataOrNull()
|
|
399
|
+
val metadata =
|
|
400
|
+
existingMetadata ?: createInitialMetadata().also {
|
|
401
|
+
saveMetadata(it)
|
|
402
|
+
Log.d(TAG, "Created initial metadata during updateBundle")
|
|
403
|
+
}
|
|
404
|
+
|
|
105
405
|
val baseDir = fileSystem.getExternalFilesDir()
|
|
106
|
-
val bundleStoreDir =
|
|
406
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
107
407
|
if (!bundleStoreDir.exists()) {
|
|
108
408
|
bundleStoreDir.mkdirs()
|
|
109
409
|
}
|
|
110
410
|
|
|
111
|
-
val currentBundleId =
|
|
112
|
-
getCachedBundleURL()?.let { cachedUrl ->
|
|
113
|
-
// Only consider cached bundles, not fallback bundles
|
|
114
|
-
if (!cachedUrl.startsWith("assets://")) {
|
|
115
|
-
File(cachedUrl).parentFile?.name
|
|
116
|
-
} else {
|
|
117
|
-
null
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
411
|
val finalBundleDir = File(bundleStoreDir, bundleId)
|
|
121
412
|
if (finalBundleDir.exists()) {
|
|
122
|
-
Log.d(
|
|
413
|
+
Log.d(TAG, "Bundle for bundleId $bundleId already exists. Using cached bundle.")
|
|
123
414
|
val existingIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
124
415
|
if (existingIndexFile != null) {
|
|
125
|
-
// Update last modified time
|
|
416
|
+
// Update last modified time
|
|
126
417
|
finalBundleDir.setLastModified(System.currentTimeMillis())
|
|
418
|
+
|
|
419
|
+
// Update metadata: set as staging
|
|
420
|
+
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
421
|
+
val updatedMetadata =
|
|
422
|
+
currentMetadata.copy(
|
|
423
|
+
stagingBundleId = bundleId,
|
|
424
|
+
verificationPending = true,
|
|
425
|
+
verificationAttemptedAt = null,
|
|
426
|
+
updatedAt = System.currentTimeMillis(),
|
|
427
|
+
)
|
|
428
|
+
saveMetadata(updatedMetadata)
|
|
429
|
+
|
|
430
|
+
// Set bundle URL for backwards compatibility
|
|
127
431
|
setBundleURL(existingIndexFile.absolutePath)
|
|
128
|
-
|
|
129
|
-
|
|
432
|
+
|
|
433
|
+
// Keep both stable and staging bundles
|
|
434
|
+
val stableBundleId = currentMetadata.stableBundleId
|
|
435
|
+
cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
|
|
436
|
+
|
|
437
|
+
Log.d(TAG, "Existing bundle set as staging, will be promoted after notifyAppReady")
|
|
438
|
+
return
|
|
130
439
|
} else {
|
|
131
440
|
// If index.android.bundle is missing, delete and re-download
|
|
132
441
|
finalBundleDir.deleteRecursively()
|
|
133
442
|
}
|
|
134
443
|
}
|
|
135
444
|
|
|
136
|
-
val
|
|
445
|
+
val tempDirName = "bundle-temp"
|
|
446
|
+
val tempDir = File(baseDir, tempDirName)
|
|
137
447
|
if (tempDir.exists()) {
|
|
138
448
|
tempDir.deleteRecursively()
|
|
139
449
|
}
|
|
140
450
|
tempDir.mkdirs()
|
|
141
451
|
|
|
142
|
-
|
|
452
|
+
withContext(Dispatchers.IO) {
|
|
143
453
|
val downloadUrl = URL(fileUrl)
|
|
144
454
|
|
|
145
455
|
// Determine bundle filename from URL
|
|
@@ -162,9 +472,8 @@ class BundleFileStorageService(
|
|
|
162
472
|
Log.d("BundleStorage", "File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes")
|
|
163
473
|
|
|
164
474
|
if (availableBytes < requiredSpace) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return@withContext false
|
|
475
|
+
Log.d("BundleStorage", "Insufficient disk space: need $requiredSpace bytes, available $availableBytes bytes")
|
|
476
|
+
throw HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
|
|
168
477
|
}
|
|
169
478
|
} else {
|
|
170
479
|
Log.d("BundleStorage", "Unable to determine file size, proceeding with download")
|
|
@@ -184,7 +493,17 @@ class BundleFileStorageService(
|
|
|
184
493
|
is DownloadResult.Error -> {
|
|
185
494
|
Log.d("BundleStorage", "Download failed: ${downloadResult.exception.message}")
|
|
186
495
|
tempDir.deleteRecursively()
|
|
187
|
-
|
|
496
|
+
|
|
497
|
+
// Check if this is an incomplete download error
|
|
498
|
+
if (downloadResult.exception is IncompleteDownloadException) {
|
|
499
|
+
val incompleteEx = downloadResult.exception as IncompleteDownloadException
|
|
500
|
+
throw HotUpdaterException.incompleteDownload(
|
|
501
|
+
incompleteEx.expectedSize,
|
|
502
|
+
incompleteEx.actualSize,
|
|
503
|
+
)
|
|
504
|
+
} else {
|
|
505
|
+
throw HotUpdaterException.downloadFailed(downloadResult.exception)
|
|
506
|
+
}
|
|
188
507
|
}
|
|
189
508
|
|
|
190
509
|
is DownloadResult.Success -> {
|
|
@@ -198,7 +517,7 @@ class BundleFileStorageService(
|
|
|
198
517
|
Log.e("BundleStorage", "Bundle verification failed", e)
|
|
199
518
|
tempDir.deleteRecursively()
|
|
200
519
|
tempBundleFile.delete()
|
|
201
|
-
|
|
520
|
+
throw HotUpdaterException.signatureVerificationFailed(e)
|
|
202
521
|
}
|
|
203
522
|
|
|
204
523
|
// 2) Create a .tmp directory under bundle-store (to avoid colliding with an existing bundleId folder)
|
|
@@ -221,7 +540,7 @@ class BundleFileStorageService(
|
|
|
221
540
|
Log.d("BundleStorage", "Failed to extract archive into tmpDir.")
|
|
222
541
|
tempDir.deleteRecursively()
|
|
223
542
|
tmpDir.deleteRecursively()
|
|
224
|
-
|
|
543
|
+
throw HotUpdaterException.extractionFormatError()
|
|
225
544
|
}
|
|
226
545
|
|
|
227
546
|
// 4) Find index.android.bundle inside tmpDir
|
|
@@ -230,7 +549,7 @@ class BundleFileStorageService(
|
|
|
230
549
|
Log.d("BundleStorage", "index.android.bundle not found in tmpDir.")
|
|
231
550
|
tempDir.deleteRecursively()
|
|
232
551
|
tmpDir.deleteRecursively()
|
|
233
|
-
|
|
552
|
+
throw HotUpdaterException.invalidBundle()
|
|
234
553
|
}
|
|
235
554
|
|
|
236
555
|
// 5) Log extracted bundle file size
|
|
@@ -245,9 +564,20 @@ class BundleFileStorageService(
|
|
|
245
564
|
// 7) Attempt to rename tmpDir → finalBundleDir (atomic within the same parent folder)
|
|
246
565
|
val renamed = tmpDir.renameTo(finalBundleDir)
|
|
247
566
|
if (!renamed) {
|
|
248
|
-
// If rename fails, use moveItem
|
|
567
|
+
// If rename fails, use moveItem as fallback
|
|
249
568
|
if (!fileSystem.moveItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
|
|
250
|
-
|
|
569
|
+
// If move also fails, try copy + delete as last resort
|
|
570
|
+
if (!fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
|
|
571
|
+
// All strategies failed
|
|
572
|
+
Log.e(
|
|
573
|
+
"BundleStorage",
|
|
574
|
+
"Failed to move bundle from tmpDir to finalBundleDir (rename, move, and copy all failed)",
|
|
575
|
+
)
|
|
576
|
+
tempDir.deleteRecursively()
|
|
577
|
+
tmpDir.deleteRecursively()
|
|
578
|
+
throw HotUpdaterException.moveOperationFailed()
|
|
579
|
+
}
|
|
580
|
+
// Copy succeeded, clean up tmpDir
|
|
251
581
|
tmpDir.deleteRecursively()
|
|
252
582
|
}
|
|
253
583
|
}
|
|
@@ -258,26 +588,40 @@ class BundleFileStorageService(
|
|
|
258
588
|
Log.d("BundleStorage", "index.android.bundle not found in realDir.")
|
|
259
589
|
tempDir.deleteRecursively()
|
|
260
590
|
finalBundleDir.deleteRecursively()
|
|
261
|
-
|
|
591
|
+
throw HotUpdaterException.invalidBundle()
|
|
262
592
|
}
|
|
263
593
|
|
|
264
594
|
// 9) Update finalBundleDir's last modified time
|
|
265
595
|
finalBundleDir.setLastModified(System.currentTimeMillis())
|
|
266
596
|
|
|
267
|
-
// 10) Save the new bundle
|
|
597
|
+
// 10) Save the new bundle as STAGING with verification pending
|
|
268
598
|
val bundlePath = finalIndexFile.absolutePath
|
|
269
|
-
Log.d(
|
|
599
|
+
Log.d(TAG, "Setting bundle as staging: $bundlePath")
|
|
600
|
+
|
|
601
|
+
// Update metadata: set new bundle as staging
|
|
602
|
+
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
603
|
+
val updatedMetadata =
|
|
604
|
+
currentMetadata.copy(
|
|
605
|
+
stagingBundleId = bundleId,
|
|
606
|
+
verificationPending = true,
|
|
607
|
+
verificationAttemptedAt = null,
|
|
608
|
+
updatedAt = System.currentTimeMillis(),
|
|
609
|
+
)
|
|
610
|
+
saveMetadata(updatedMetadata)
|
|
611
|
+
|
|
612
|
+
// Also update HotUpdaterBundleURL for backwards compatibility
|
|
613
|
+
// This will point to the staging bundle that will be loaded
|
|
270
614
|
setBundleURL(bundlePath)
|
|
271
615
|
|
|
272
616
|
// 11) Clean up temporary and download folders
|
|
273
617
|
tempDir.deleteRecursively()
|
|
274
618
|
|
|
275
|
-
// 12)
|
|
276
|
-
|
|
619
|
+
// 12) Keep both stable and staging bundles
|
|
620
|
+
val stableBundleId = currentMetadata.stableBundleId
|
|
621
|
+
cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
|
|
277
622
|
|
|
278
|
-
Log.d(
|
|
623
|
+
Log.d(TAG, "Downloaded and set bundle as staging successfully. Will be promoted after notifyAppReady.")
|
|
279
624
|
// Progress already at 1.0 from unzip completion
|
|
280
|
-
return@withContext true
|
|
281
625
|
}
|
|
282
626
|
}
|
|
283
627
|
}
|
|
@@ -305,17 +649,17 @@ class BundleFileStorageService(
|
|
|
305
649
|
bundles.forEach { bundle ->
|
|
306
650
|
try {
|
|
307
651
|
if (bundle.name !in bundleIdsToKeep) {
|
|
308
|
-
Log.d(
|
|
652
|
+
Log.d(TAG, "Removing old bundle: ${bundle.name}")
|
|
309
653
|
if (bundle.deleteRecursively()) {
|
|
310
|
-
Log.d(
|
|
654
|
+
Log.d(TAG, "Successfully removed old bundle: ${bundle.name}")
|
|
311
655
|
} else {
|
|
312
|
-
Log.w(
|
|
656
|
+
Log.w(TAG, "Failed to remove old bundle: ${bundle.name}")
|
|
313
657
|
}
|
|
314
658
|
} else {
|
|
315
|
-
Log.d(
|
|
659
|
+
Log.d(TAG, "Keeping bundle: ${bundle.name}")
|
|
316
660
|
}
|
|
317
661
|
} catch (e: Exception) {
|
|
318
|
-
Log.e(
|
|
662
|
+
Log.e(TAG, "Error removing bundle ${bundle.name}: ${e.message}")
|
|
319
663
|
}
|
|
320
664
|
}
|
|
321
665
|
|
|
@@ -325,18 +669,18 @@ class BundleFileStorageService(
|
|
|
325
669
|
file.isDirectory && file.name.endsWith(".tmp")
|
|
326
670
|
}?.forEach { staleTmp ->
|
|
327
671
|
try {
|
|
328
|
-
Log.d(
|
|
672
|
+
Log.d(TAG, "Removing stale tmp directory: ${staleTmp.name}")
|
|
329
673
|
if (staleTmp.deleteRecursively()) {
|
|
330
|
-
Log.d(
|
|
674
|
+
Log.d(TAG, "Successfully removed tmp directory: ${staleTmp.name}")
|
|
331
675
|
} else {
|
|
332
|
-
Log.w(
|
|
676
|
+
Log.w(TAG, "Failed to remove tmp directory: ${staleTmp.name}")
|
|
333
677
|
}
|
|
334
678
|
} catch (e: Exception) {
|
|
335
|
-
Log.e(
|
|
679
|
+
Log.e(TAG, "Error removing tmp directory ${staleTmp.name}: ${e.message}")
|
|
336
680
|
}
|
|
337
681
|
}
|
|
338
682
|
} catch (e: Exception) {
|
|
339
|
-
Log.e(
|
|
683
|
+
Log.e(TAG, "Error during cleanup: ${e.message}")
|
|
340
684
|
}
|
|
341
685
|
}
|
|
342
686
|
}
|