react-native-instantpay-code-push 1.1.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/InstantpayCodePush.podspec +20 -0
- package/LICENSE +20 -0
- package/README.md +158 -0
- package/android/build.gradle +91 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/instantpaycodepush/BundleFileStorageService.kt +835 -0
- package/android/src/main/java/com/instantpaycodepush/BundleMetadata.kt +249 -0
- package/android/src/main/java/com/instantpaycodepush/CommonHelper.kt +39 -0
- package/android/src/main/java/com/instantpaycodepush/DecompressService.kt +85 -0
- package/android/src/main/java/com/instantpaycodepush/DecompressionStrategy.kt +24 -0
- package/android/src/main/java/com/instantpaycodepush/FileManagerService.kt +105 -0
- package/android/src/main/java/com/instantpaycodepush/HashUtils.kt +50 -0
- package/android/src/main/java/com/instantpaycodepush/InstantpayCodePushModule.kt +182 -0
- package/android/src/main/java/com/instantpaycodepush/InstantpayCodePushPackage.kt +33 -0
- package/android/src/main/java/com/instantpaycodepush/IpayCodePush.kt +101 -0
- package/android/src/main/java/com/instantpaycodepush/IpayCodePushException.kt +135 -0
- package/android/src/main/java/com/instantpaycodepush/IpayCodePushImpl.kt +329 -0
- package/android/src/main/java/com/instantpaycodepush/OkHttpDownloadService.kt +283 -0
- package/android/src/main/java/com/instantpaycodepush/ReactIntegrationManager.kt +141 -0
- package/android/src/main/java/com/instantpaycodepush/ReactIntegrationManagerBase.kt +35 -0
- package/android/src/main/java/com/instantpaycodepush/SignatureVerifier.kt +354 -0
- package/android/src/main/java/com/instantpaycodepush/VersionedPreferencesService.kt +70 -0
- package/android/src/main/java/com/instantpaycodepush/ZipDecompressionStrategy.kt +198 -0
- package/ios/InstantpayCodePush.h +5 -0
- package/ios/InstantpayCodePush.mm +21 -0
- package/lib/module/DefaultResolver.js +34 -0
- package/lib/module/DefaultResolver.js.map +1 -0
- package/lib/module/NativeInstantpayCodePush.js +5 -0
- package/lib/module/NativeInstantpayCodePush.js.map +1 -0
- package/lib/module/checkForUpdate.js +68 -0
- package/lib/module/checkForUpdate.js.map +1 -0
- package/lib/module/error.js +137 -0
- package/lib/module/error.js.map +1 -0
- package/lib/module/fetchUpdateInfo.js +36 -0
- package/lib/module/fetchUpdateInfo.js.map +1 -0
- package/lib/module/global.d.js +8 -0
- package/lib/module/global.d.js.map +1 -0
- package/lib/module/hooks/useEventCallback.js +13 -0
- package/lib/module/hooks/useEventCallback.js.map +1 -0
- package/lib/module/index.js +291 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native.js +233 -0
- package/lib/module/native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/store.js +53 -0
- package/lib/module/store.js.map +1 -0
- package/lib/module/types.js +62 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/wrap.js +171 -0
- package/lib/module/wrap.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/DefaultResolver.d.ts +10 -0
- package/lib/typescript/src/DefaultResolver.d.ts.map +1 -0
- package/lib/typescript/src/NativeInstantpayCodePush.d.ts +100 -0
- package/lib/typescript/src/NativeInstantpayCodePush.d.ts.map +1 -0
- package/lib/typescript/src/checkForUpdate.d.ts +29 -0
- package/lib/typescript/src/checkForUpdate.d.ts.map +1 -0
- package/lib/typescript/src/error.d.ts +124 -0
- package/lib/typescript/src/error.d.ts.map +1 -0
- package/lib/typescript/src/fetchUpdateInfo.d.ts +8 -0
- package/lib/typescript/src/fetchUpdateInfo.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useEventCallback.d.ts +5 -0
- package/lib/typescript/src/hooks/useEventCallback.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +203 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/native.d.ts +128 -0
- package/lib/typescript/src/native.d.ts.map +1 -0
- package/lib/typescript/src/store.d.ts +11 -0
- package/lib/typescript/src/store.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +174 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/wrap.d.ts +179 -0
- package/lib/typescript/src/wrap.d.ts.map +1 -0
- package/package.json +174 -0
- package/src/DefaultResolver.ts +36 -0
- package/src/NativeInstantpayCodePush.ts +111 -0
- package/src/checkForUpdate.ts +122 -0
- package/src/error.ts +159 -0
- package/src/fetchUpdateInfo.ts +47 -0
- package/src/global.d.ts +23 -0
- package/src/hooks/useEventCallback.ts +30 -0
- package/src/index.tsx +379 -0
- package/src/native.ts +280 -0
- package/src/store.ts +69 -0
- package/src/types.ts +227 -0
- package/src/wrap.tsx +384 -0
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
package com.instantpaycodepush
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.StatFs
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import kotlinx.coroutines.Dispatchers
|
|
7
|
+
import kotlinx.coroutines.withContext
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.net.URL
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
interface BundleStorageService {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sets the current bundle URL
|
|
16
|
+
* @param localPath Path to the bundle file (or null to reset)
|
|
17
|
+
* @return true if the operation was successful
|
|
18
|
+
*/
|
|
19
|
+
fun setBundleURL(localPath: String?): Boolean
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gets the URL to the cached bundle file
|
|
23
|
+
* @return The path to the cached bundle or null if not found
|
|
24
|
+
*/
|
|
25
|
+
fun getCachedBundleURL(): String?
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Gets the URL to the fallback bundle included in the app
|
|
29
|
+
* @return The fallback bundle path
|
|
30
|
+
*/
|
|
31
|
+
fun getFallbackBundleURL(): String
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gets the URL to the bundle file (cached or fallback)
|
|
35
|
+
* With rollback support: checks for crashed staging bundles
|
|
36
|
+
* @return The path to the bundle file
|
|
37
|
+
*/
|
|
38
|
+
fun getBundleURL(): String
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Updates the bundle from the specified URL
|
|
42
|
+
* @param bundleId ID of the bundle to update
|
|
43
|
+
* @param fileUrl URL of the bundle file to download (or null to reset)
|
|
44
|
+
* @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
|
|
45
|
+
* @param progressCallback Callback for download progress updates
|
|
46
|
+
* @throws IpayCodePushException if the update fails
|
|
47
|
+
*/
|
|
48
|
+
suspend fun updateBundle(
|
|
49
|
+
bundleId: String,
|
|
50
|
+
fileUrl: String?,
|
|
51
|
+
fileHash: String?,
|
|
52
|
+
progressCallback: (Double) -> Unit,
|
|
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
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gets the base URL for the current active bundle directory
|
|
76
|
+
* @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
|
|
77
|
+
*/
|
|
78
|
+
fun getBaseURL(): String
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Implementation of BundleStorageService
|
|
83
|
+
*/
|
|
84
|
+
class BundleFileStorageService(
|
|
85
|
+
private val context: Context,
|
|
86
|
+
private val fileSystem: FileSystemService,
|
|
87
|
+
private val downloadService: DownloadService,
|
|
88
|
+
private val decompressService: DecompressService,
|
|
89
|
+
private val preferences: PreferencesService,
|
|
90
|
+
private val isolationKey: String,
|
|
91
|
+
) : BundleStorageService {
|
|
92
|
+
|
|
93
|
+
companion object {
|
|
94
|
+
private const val CLASS_TAG = "*BundleStorage"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
init {
|
|
98
|
+
// Ensure bundle store directory exists
|
|
99
|
+
getBundleStoreDir().mkdirs()
|
|
100
|
+
|
|
101
|
+
// Clean up old bundles if isolationKey format changed
|
|
102
|
+
checkAndCleanupIfIsolationKeyChanged()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Session-only rollback tracking (in-memory)
|
|
106
|
+
private var sessionRollbackBundleId: String? = null
|
|
107
|
+
|
|
108
|
+
// MARK: - Bundle Store Directory
|
|
109
|
+
private fun getBundleStoreDir(): File {
|
|
110
|
+
val baseDir = fileSystem.getExternalFilesDir()
|
|
111
|
+
return File(baseDir, "bundle-store")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private fun getMetadataFile(): File = File(getBundleStoreDir(), BundleMetadata.METADATA_FILENAME)
|
|
115
|
+
|
|
116
|
+
private fun getCrashedHistoryFile(): File = File(getBundleStoreDir(), CrashedHistory.CRASHED_HISTORY_FILENAME)
|
|
117
|
+
|
|
118
|
+
// MARK: - Metadata Operations
|
|
119
|
+
|
|
120
|
+
private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
|
|
121
|
+
|
|
122
|
+
private fun saveMetadata(metadata: BundleMetadata): Boolean {
|
|
123
|
+
val updatedMetadata = metadata.copy(isolationKey = isolationKey)
|
|
124
|
+
return updatedMetadata.saveToFile(getMetadataFile())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun createInitialMetadata(): BundleMetadata {
|
|
128
|
+
val currentBundleId = extractBundleIdFromCurrentURL()
|
|
129
|
+
CommonHelper.logPrint(CLASS_TAG, "Creating initial metadata with stableBundleId: $currentBundleId")
|
|
130
|
+
return BundleMetadata(
|
|
131
|
+
stableBundleId = currentBundleId,
|
|
132
|
+
stagingBundleId = null,
|
|
133
|
+
verificationPending = false,
|
|
134
|
+
verificationAttemptedAt = null,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private fun extractBundleIdFromCurrentURL(): String? {
|
|
139
|
+
val currentUrl = preferences.getItem("IpayCodePushBundleURL") ?: return null
|
|
140
|
+
// "bundle-store/abc123/index.android.bundle" -> "abc123"
|
|
141
|
+
val regex = Regex("bundle-store/([^/]+)/")
|
|
142
|
+
return regex.find(currentUrl)?.groupValues?.get(1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Checks if isolationKey has changed and cleans up old bundles if needed.
|
|
147
|
+
* This handles migration when isolationKey format changes.
|
|
148
|
+
*/
|
|
149
|
+
private fun checkAndCleanupIfIsolationKeyChanged() {
|
|
150
|
+
val metadataFile = getMetadataFile()
|
|
151
|
+
|
|
152
|
+
if (!metadataFile.exists()) {
|
|
153
|
+
// First launch - no cleanup needed
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Read metadata without validation to get stored isolationKey
|
|
159
|
+
val jsonString = metadataFile.readText()
|
|
160
|
+
val json = org.json.JSONObject(jsonString)
|
|
161
|
+
val storedIsolationKey = json.optString("isolationKey", null)
|
|
162
|
+
|
|
163
|
+
if (storedIsolationKey != null && storedIsolationKey != isolationKey) {
|
|
164
|
+
// isolationKey changed - migration needed
|
|
165
|
+
CommonHelper.logPrint(CLASS_TAG, "isolationKey changed: $storedIsolationKey -> $isolationKey")
|
|
166
|
+
CommonHelper.logPrint(CLASS_TAG, "Cleaning up old bundles for migration")
|
|
167
|
+
cleanupAllBundlesForMigration()
|
|
168
|
+
}
|
|
169
|
+
} catch (e: Exception) {
|
|
170
|
+
CommonHelper.logPrint(CLASS_TAG, "Error checking isolationKey: ${e.message}")
|
|
171
|
+
Log.e(CLASS_TAG, "Error checking isolationKey: ${e.message}")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Removes all bundle directories during migration.
|
|
177
|
+
* Called when isolationKey format changes.
|
|
178
|
+
*/
|
|
179
|
+
private fun cleanupAllBundlesForMigration() {
|
|
180
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
181
|
+
|
|
182
|
+
if (!bundleStoreDir.exists()) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
var cleanedCount = 0
|
|
188
|
+
bundleStoreDir.listFiles()?.forEach { file ->
|
|
189
|
+
if (file.isDirectory) {
|
|
190
|
+
try {
|
|
191
|
+
if (file.deleteRecursively()) {
|
|
192
|
+
cleanedCount++
|
|
193
|
+
CommonHelper.logPrint(CLASS_TAG, "Migration: removed old bundle ${file.name}")
|
|
194
|
+
}
|
|
195
|
+
} catch (e: Exception) {
|
|
196
|
+
CommonHelper.logPrint(CLASS_TAG,"Error removing bundle ${file.name}: ${e.message}")
|
|
197
|
+
Log.e(CLASS_TAG, "Error removing bundle ${file.name}: ${e.message}")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
CommonHelper.logPrint(CLASS_TAG, "Migration cleanup complete: removed $cleanedCount bundles")
|
|
203
|
+
} catch (e: Exception) {
|
|
204
|
+
CommonHelper.logPrint(CLASS_TAG, "Error during migration cleanup: ${e.message}")
|
|
205
|
+
Log.e(CLASS_TAG, "Error during migration cleanup: ${e.message}")
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// MARK: - State Machine
|
|
210
|
+
|
|
211
|
+
private fun isVerificationPending(metadata: BundleMetadata): Boolean = metadata.verificationPending && metadata.stagingBundleId != null
|
|
212
|
+
|
|
213
|
+
private fun wasVerificationAttempted(metadata: BundleMetadata): Boolean = metadata.verificationAttemptedAt != null
|
|
214
|
+
|
|
215
|
+
private fun markVerificationAttempted() {
|
|
216
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
217
|
+
val updatedMetadata =
|
|
218
|
+
metadata.copy(
|
|
219
|
+
verificationAttemptedAt = System.currentTimeMillis(),
|
|
220
|
+
updatedAt = System.currentTimeMillis(),
|
|
221
|
+
)
|
|
222
|
+
saveMetadata(updatedMetadata)
|
|
223
|
+
CommonHelper.logPrint(CLASS_TAG, "Marked verification attempted at ${updatedMetadata.verificationAttemptedAt}")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private fun promoteStagingToStable() {
|
|
227
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
228
|
+
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
229
|
+
|
|
230
|
+
CommonHelper.logPrint(CLASS_TAG,"Promoting staging bundle $stagingBundleId to stable")
|
|
231
|
+
|
|
232
|
+
val updatedMetadata =
|
|
233
|
+
metadata.copy(
|
|
234
|
+
stableBundleId = stagingBundleId,
|
|
235
|
+
stagingBundleId = null,
|
|
236
|
+
verificationPending = false,
|
|
237
|
+
verificationAttemptedAt = null,
|
|
238
|
+
updatedAt = System.currentTimeMillis(),
|
|
239
|
+
)
|
|
240
|
+
saveMetadata(updatedMetadata)
|
|
241
|
+
|
|
242
|
+
// Update IpayCodePushBundleURL preference to point to stable bundle
|
|
243
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
244
|
+
val stableBundleDir = File(bundleStoreDir, stagingBundleId)
|
|
245
|
+
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
246
|
+
if (bundleFile != null) {
|
|
247
|
+
preferences.setItem("IpayCodePushBundleURL", bundleFile.absolutePath)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Cleanup old bundles (keep only the new stable)
|
|
251
|
+
cleanupOldBundles(bundleStoreDir, null, stagingBundleId)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fun rollbackToStable() {
|
|
255
|
+
val metadata = loadMetadataOrNull() ?: return
|
|
256
|
+
val stagingBundleId = metadata.stagingBundleId ?: return
|
|
257
|
+
|
|
258
|
+
CommonHelper.logPrint(CLASS_TAG, "Rolling back: adding $stagingBundleId to crashed history")
|
|
259
|
+
|
|
260
|
+
// Add to crashed history
|
|
261
|
+
val crashedHistory = loadCrashedHistory()
|
|
262
|
+
crashedHistory.addEntry(stagingBundleId)
|
|
263
|
+
saveCrashedHistory(crashedHistory)
|
|
264
|
+
|
|
265
|
+
// Save rollback info to session variable (memory only)
|
|
266
|
+
sessionRollbackBundleId = stagingBundleId
|
|
267
|
+
|
|
268
|
+
// Clear staging pointer
|
|
269
|
+
val updatedMetadata =
|
|
270
|
+
metadata.copy(
|
|
271
|
+
stagingBundleId = null,
|
|
272
|
+
verificationPending = false,
|
|
273
|
+
verificationAttemptedAt = null,
|
|
274
|
+
stagingExecutionCount = null,
|
|
275
|
+
updatedAt = System.currentTimeMillis(),
|
|
276
|
+
)
|
|
277
|
+
saveMetadata(updatedMetadata)
|
|
278
|
+
|
|
279
|
+
// Update bundle URL to point to stable bundle
|
|
280
|
+
val stableBundleId = updatedMetadata.stableBundleId
|
|
281
|
+
if (stableBundleId != null) {
|
|
282
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
283
|
+
val stableBundleDir = File(bundleStoreDir, stableBundleId)
|
|
284
|
+
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
285
|
+
if (bundleFile != null && bundleFile.exists()) {
|
|
286
|
+
setBundleURL(bundleFile.absolutePath)
|
|
287
|
+
CommonHelper.logPrint(CLASS_TAG, "Updated bundle URL to stable: $stableBundleId")
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
// No stable bundle available, clear bundle URL (fallback to assets)
|
|
291
|
+
setBundleURL(null)
|
|
292
|
+
CommonHelper.logPrint(CLASS_TAG, "Cleared bundle URL (no stable bundle)")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Remove staging bundle directory
|
|
296
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
297
|
+
val stagingBundleDir = File(bundleStoreDir, stagingBundleId)
|
|
298
|
+
if (stagingBundleDir.exists()) {
|
|
299
|
+
stagingBundleDir.deleteRecursively()
|
|
300
|
+
CommonHelper.logPrint(CLASS_TAG, "Deleted crashed staging bundle directory: $stagingBundleId")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// MARK: - Crashed History
|
|
305
|
+
|
|
306
|
+
private fun loadCrashedHistory(): CrashedHistory = CrashedHistory.loadFromFile(getCrashedHistoryFile())
|
|
307
|
+
|
|
308
|
+
private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
|
|
309
|
+
|
|
310
|
+
private fun isBundleInCrashedHistory(bundleId: String): Boolean = loadCrashedHistory().contains(bundleId)
|
|
311
|
+
|
|
312
|
+
override fun getCrashHistory(): CrashedHistory = loadCrashedHistory()
|
|
313
|
+
|
|
314
|
+
override fun clearCrashHistory(): Boolean {
|
|
315
|
+
val history = CrashedHistory()
|
|
316
|
+
saveCrashedHistory(history)
|
|
317
|
+
CommonHelper.logPrint(CLASS_TAG, "Cleared crash history")
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// MARK: - notifyAppReady
|
|
322
|
+
|
|
323
|
+
override fun notifyAppReady(currentBundleId: String?): Map<String, Any?> {
|
|
324
|
+
val metadata =
|
|
325
|
+
loadMetadataOrNull()
|
|
326
|
+
?: return mapOf("status" to "STABLE")
|
|
327
|
+
|
|
328
|
+
// Check if there was a recent rollback (session variable)
|
|
329
|
+
sessionRollbackBundleId?.let { crashedBundleId ->
|
|
330
|
+
// Clear rollback info (one-time read)
|
|
331
|
+
sessionRollbackBundleId = null
|
|
332
|
+
|
|
333
|
+
CommonHelper.logPrint(CLASS_TAG, "notifyAppReady: recovered from rollback (crashed bundle: $crashedBundleId)")
|
|
334
|
+
return mapOf(
|
|
335
|
+
"status" to "RECOVERED",
|
|
336
|
+
"crashedBundleId" to crashedBundleId,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Check for promotion
|
|
341
|
+
if (isVerificationPending(metadata)) {
|
|
342
|
+
val stagingBundleId = metadata.stagingBundleId
|
|
343
|
+
if (stagingBundleId != null && stagingBundleId == currentBundleId) {
|
|
344
|
+
CommonHelper.logPrint(CLASS_TAG, "App started successfully with staging bundle $currentBundleId, promoting to stable")
|
|
345
|
+
promoteStagingToStable()
|
|
346
|
+
return mapOf("status" to "PROMOTED")
|
|
347
|
+
} else {
|
|
348
|
+
CommonHelper.logPrint(CLASS_TAG, "notifyAppReady: bundleId mismatch (staging=$stagingBundleId, current=$currentBundleId)")
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
CommonHelper.logPrint(CLASS_TAG, "notifyAppReady: no verification pending")
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// No changes
|
|
355
|
+
return mapOf("status" to "STABLE")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// MARK: - Bundle URL Operations
|
|
359
|
+
|
|
360
|
+
override fun setBundleURL(localPath: String?): Boolean {
|
|
361
|
+
CommonHelper.logPrint(CLASS_TAG, "setBundleURL: $localPath")
|
|
362
|
+
preferences.setItem("IpayCodePushBundleURL", localPath)
|
|
363
|
+
return true
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
override fun getCachedBundleURL(): String? {
|
|
367
|
+
val urlString = preferences.getItem("IpayCodePushBundleURL")
|
|
368
|
+
CommonHelper.logPrint(CLASS_TAG, "getCachedBundleURL: read from prefs = $urlString")
|
|
369
|
+
if (urlString.isNullOrEmpty()) {
|
|
370
|
+
CommonHelper.logPrint(CLASS_TAG, "getCachedBundleURL: urlString is null or empty")
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
val file = File(urlString)
|
|
375
|
+
val exists = file.exists()
|
|
376
|
+
CommonHelper.logPrint(CLASS_TAG, "getCachedBundleURL: file exists = $exists at path: $urlString")
|
|
377
|
+
if (!exists) {
|
|
378
|
+
preferences.setItem("IpayCodePushBundleURL", null)
|
|
379
|
+
CommonHelper.logPrint(CLASS_TAG, "getCachedBundleURL: file doesn't exist, cleared preference")
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
return urlString
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
|
|
386
|
+
|
|
387
|
+
// Track if crash detection has already run in this process
|
|
388
|
+
private var crashDetectionCompleted = false
|
|
389
|
+
|
|
390
|
+
override fun getBundleURL(): String {
|
|
391
|
+
val metadata = loadMetadataOrNull()
|
|
392
|
+
|
|
393
|
+
if (metadata == null) {
|
|
394
|
+
// Legacy mode: no metadata.json exists, use existing behavior
|
|
395
|
+
val cached = getCachedBundleURL()
|
|
396
|
+
val result = cached ?: getFallbackBundleURL()
|
|
397
|
+
CommonHelper.logPrint(CLASS_TAG, "getBundleURL (legacy): returning $result")
|
|
398
|
+
return result
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// New rollback-aware mode - only run crash detection ONCE per process
|
|
402
|
+
if (isVerificationPending(metadata) && !crashDetectionCompleted) {
|
|
403
|
+
crashDetectionCompleted = true
|
|
404
|
+
|
|
405
|
+
if (wasVerificationAttempted(metadata)) {
|
|
406
|
+
// Already executed once but didn't call notifyAppReady → crash!
|
|
407
|
+
CommonHelper.logPrint(CommonHelper.WARNING_LOG,CLASS_TAG, "Crash detected: staging bundle executed but didn't call notifyAppReady")
|
|
408
|
+
rollbackToStable()
|
|
409
|
+
} else {
|
|
410
|
+
// First execution - mark verification attempted and give it a chance
|
|
411
|
+
CommonHelper.logPrint(CLASS_TAG, "First execution of staging bundle, marking verification attempted")
|
|
412
|
+
markVerificationAttempted()
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Reload metadata after potential rollback
|
|
417
|
+
val currentMetadata = loadMetadataOrNull()
|
|
418
|
+
|
|
419
|
+
// Return staging bundle if verification pending
|
|
420
|
+
if (currentMetadata != null && isVerificationPending(currentMetadata)) {
|
|
421
|
+
val stagingId = currentMetadata.stagingBundleId
|
|
422
|
+
if (stagingId != null) {
|
|
423
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
424
|
+
val stagingBundleDir = File(bundleStoreDir, stagingId)
|
|
425
|
+
val bundleFile = stagingBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
426
|
+
if (bundleFile != null && bundleFile.exists()) {
|
|
427
|
+
CommonHelper.logPrint(CLASS_TAG,"getBundleURL: returning STAGING bundle $stagingId")
|
|
428
|
+
return bundleFile.absolutePath
|
|
429
|
+
} else {
|
|
430
|
+
CommonHelper.logPrint(CommonHelper.WARNING_LOG,CLASS_TAG, "getBundleURL: staging bundle file not found for $stagingId")
|
|
431
|
+
// Staging bundle file missing, rollback to stable
|
|
432
|
+
rollbackToStable()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Return stable bundle URL
|
|
438
|
+
val stableBundleId = currentMetadata?.stableBundleId
|
|
439
|
+
if (stableBundleId != null) {
|
|
440
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
441
|
+
val stableBundleDir = File(bundleStoreDir, stableBundleId)
|
|
442
|
+
val bundleFile = stableBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
443
|
+
if (bundleFile != null && bundleFile.exists()) {
|
|
444
|
+
CommonHelper.logPrint(CLASS_TAG, "getBundleURL: returning stable bundle $stableBundleId")
|
|
445
|
+
return bundleFile.absolutePath
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Fallback
|
|
450
|
+
val cached = getCachedBundleURL()
|
|
451
|
+
val result = cached ?: getFallbackBundleURL()
|
|
452
|
+
CommonHelper.logPrint(CLASS_TAG, "getBundleURL: returning $result (cached=$cached)")
|
|
453
|
+
return result
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
override suspend fun updateBundle(
|
|
457
|
+
bundleId: String,
|
|
458
|
+
fileUrl: String?,
|
|
459
|
+
fileHash: String?,
|
|
460
|
+
progressCallback: (Double) -> Unit,
|
|
461
|
+
) {
|
|
462
|
+
CommonHelper.logPrint(CLASS_TAG,"updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash")
|
|
463
|
+
|
|
464
|
+
// If no URL is provided, reset to fallback and clean up all bundles
|
|
465
|
+
if (fileUrl.isNullOrEmpty()) {
|
|
466
|
+
CommonHelper.logPrint(CLASS_TAG, "fileUrl is null or empty, resetting to fallback bundle")
|
|
467
|
+
|
|
468
|
+
withContext(Dispatchers.IO) {
|
|
469
|
+
// 1. Set bundle URL to null (reset preference)
|
|
470
|
+
val setResult = setBundleURL(null)
|
|
471
|
+
if (!setResult) {
|
|
472
|
+
CommonHelper.logPrint(CommonHelper.WARNING_LOG,CLASS_TAG, "Failed to reset bundle URL")
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 2. Reset metadata to initial state (clear all bundle references)
|
|
476
|
+
val metadata = createInitialMetadata()
|
|
477
|
+
val saveResult = saveMetadata(metadata)
|
|
478
|
+
if (!saveResult) {
|
|
479
|
+
CommonHelper.logPrint(CommonHelper.WARNING_LOG,CLASS_TAG, "Failed to reset metadata")
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 3. Clean up all downloaded bundles
|
|
483
|
+
// Pass null for currentBundleId to remove all bundles except the new bundleId
|
|
484
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
485
|
+
cleanupOldBundles(bundleStoreDir, null, bundleId)
|
|
486
|
+
|
|
487
|
+
CommonHelper.logPrint(CLASS_TAG, "Successfully reset to fallback bundle and cleaned up downloads")
|
|
488
|
+
}
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Check if bundle is in crashed history
|
|
493
|
+
if (isBundleInCrashedHistory(bundleId)) {
|
|
494
|
+
CommonHelper.logPrint(CommonHelper.WARNING_LOG,CLASS_TAG, "Bundle $bundleId is in crashed history, rejecting update")
|
|
495
|
+
throw IpayCodePushException.bundleInCrashedHistory(bundleId)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Initialize metadata if it doesn't exist (lazy initialization)
|
|
499
|
+
val existingMetadata = loadMetadataOrNull()
|
|
500
|
+
val metadata =
|
|
501
|
+
existingMetadata ?: createInitialMetadata().also {
|
|
502
|
+
saveMetadata(it)
|
|
503
|
+
CommonHelper.logPrint(CLASS_TAG, "Created initial metadata during updateBundle")
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
val baseDir = fileSystem.getExternalFilesDir()
|
|
507
|
+
val bundleStoreDir = getBundleStoreDir()
|
|
508
|
+
if (!bundleStoreDir.exists()) {
|
|
509
|
+
bundleStoreDir.mkdirs()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
val finalBundleDir = File(bundleStoreDir, bundleId)
|
|
513
|
+
if (finalBundleDir.exists()) {
|
|
514
|
+
CommonHelper.logPrint(CLASS_TAG, "Bundle for bundleId $bundleId already exists. Using cached bundle.")
|
|
515
|
+
val existingIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
516
|
+
if (existingIndexFile != null) {
|
|
517
|
+
// Update last modified time
|
|
518
|
+
finalBundleDir.setLastModified(System.currentTimeMillis())
|
|
519
|
+
|
|
520
|
+
// Update metadata: set as staging
|
|
521
|
+
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
522
|
+
val updatedMetadata =
|
|
523
|
+
currentMetadata.copy(
|
|
524
|
+
stagingBundleId = bundleId,
|
|
525
|
+
verificationPending = true,
|
|
526
|
+
verificationAttemptedAt = null,
|
|
527
|
+
updatedAt = System.currentTimeMillis(),
|
|
528
|
+
)
|
|
529
|
+
saveMetadata(updatedMetadata)
|
|
530
|
+
|
|
531
|
+
// Set bundle URL for backwards compatibility
|
|
532
|
+
setBundleURL(existingIndexFile.absolutePath)
|
|
533
|
+
|
|
534
|
+
// Keep both stable and staging bundles
|
|
535
|
+
val stableBundleId = currentMetadata.stableBundleId
|
|
536
|
+
cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
|
|
537
|
+
|
|
538
|
+
CommonHelper.logPrint(CLASS_TAG, "Existing bundle set as staging, will be promoted after notifyAppReady")
|
|
539
|
+
return
|
|
540
|
+
} else {
|
|
541
|
+
// If index.android.bundle is missing, delete and re-download
|
|
542
|
+
finalBundleDir.deleteRecursively()
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
val tempDirName = "bundle-temp"
|
|
547
|
+
val tempDir = File(baseDir, tempDirName)
|
|
548
|
+
if (tempDir.exists()) {
|
|
549
|
+
tempDir.deleteRecursively()
|
|
550
|
+
}
|
|
551
|
+
tempDir.mkdirs()
|
|
552
|
+
|
|
553
|
+
withContext(Dispatchers.IO) {
|
|
554
|
+
val downloadUrl = URL(fileUrl)
|
|
555
|
+
|
|
556
|
+
// Determine bundle filename from URL
|
|
557
|
+
val bundleFileName =
|
|
558
|
+
if (downloadUrl.path.isNotEmpty()) {
|
|
559
|
+
File(downloadUrl.path).name.ifEmpty { "bundle.zip" }
|
|
560
|
+
} else {
|
|
561
|
+
"bundle.zip"
|
|
562
|
+
}
|
|
563
|
+
val tempBundleFile = File(tempDir, bundleFileName)
|
|
564
|
+
|
|
565
|
+
// Download the file (0% - 80%)
|
|
566
|
+
// Disk space check will be performed in fileSizeCallback
|
|
567
|
+
var diskSpaceError: IpayCodePushException? = null
|
|
568
|
+
|
|
569
|
+
val downloadResult =
|
|
570
|
+
downloadService.downloadFile(
|
|
571
|
+
downloadUrl,
|
|
572
|
+
tempBundleFile,
|
|
573
|
+
fileSizeCallback = { fileSize ->
|
|
574
|
+
// Perform disk space check when file size is known
|
|
575
|
+
if (baseDir != null) {
|
|
576
|
+
val stat = StatFs(baseDir.absolutePath)
|
|
577
|
+
val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
|
|
578
|
+
val requiredSpace = fileSize * 2 // ZIP + extracted files
|
|
579
|
+
|
|
580
|
+
CommonHelper.logPrint(
|
|
581
|
+
CLASS_TAG,
|
|
582
|
+
"File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if (availableBytes < requiredSpace) {
|
|
586
|
+
CommonHelper.logPrint(
|
|
587
|
+
CLASS_TAG,
|
|
588
|
+
"Insufficient disk space detected: need $requiredSpace bytes, available $availableBytes bytes",
|
|
589
|
+
)
|
|
590
|
+
// Store error to be thrown after download completes/cancels
|
|
591
|
+
diskSpaceError = IpayCodePushException.insufficientDiskSpace(requiredSpace, availableBytes)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
) { downloadProgress ->
|
|
596
|
+
// Map download progress to 0.0 - 0.8
|
|
597
|
+
progressCallback(downloadProgress * 0.8)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Check for disk space error first before processing download result
|
|
601
|
+
diskSpaceError?.let {
|
|
602
|
+
CommonHelper.logPrint(CLASS_TAG, "Throwing disk space error")
|
|
603
|
+
tempDir.deleteRecursively()
|
|
604
|
+
throw it
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
when (downloadResult) {
|
|
608
|
+
is DownloadResult.Error -> {
|
|
609
|
+
CommonHelper.logPrint(CLASS_TAG, "Download failed: ${downloadResult.exception.message}")
|
|
610
|
+
tempDir.deleteRecursively()
|
|
611
|
+
|
|
612
|
+
// Check if this is an incomplete download error
|
|
613
|
+
if (downloadResult.exception is IncompleteDownloadException) {
|
|
614
|
+
val incompleteEx = downloadResult.exception as IncompleteDownloadException
|
|
615
|
+
throw IpayCodePushException.incompleteDownload(
|
|
616
|
+
incompleteEx.expectedSize,
|
|
617
|
+
incompleteEx.actualSize,
|
|
618
|
+
)
|
|
619
|
+
} else {
|
|
620
|
+
throw IpayCodePushException.downloadFailed(downloadResult.exception)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
is DownloadResult.Success -> {
|
|
625
|
+
CommonHelper.logPrint(CLASS_TAG, "Download successful")
|
|
626
|
+
// 1) Verify bundle integrity (hash or signature based on fileHash format)
|
|
627
|
+
CommonHelper.logPrint(CLASS_TAG, "Verifying bundle integrity...")
|
|
628
|
+
try {
|
|
629
|
+
SignatureVerifier.verifyBundle(context, tempBundleFile, fileHash)
|
|
630
|
+
CommonHelper.logPrint(CLASS_TAG, "Bundle verification completed successfully")
|
|
631
|
+
} catch (e: SignatureVerificationException) {
|
|
632
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Bundle verification failed $e")
|
|
633
|
+
tempDir.deleteRecursively()
|
|
634
|
+
tempBundleFile.delete()
|
|
635
|
+
throw IpayCodePushException.signatureVerificationFailed(e)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// 2) Create a .tmp directory under bundle-store (to avoid colliding with an existing bundleId folder)
|
|
639
|
+
val tmpDir = File(bundleStoreDir, "$bundleId.tmp")
|
|
640
|
+
if (tmpDir.exists()) {
|
|
641
|
+
tmpDir.deleteRecursively()
|
|
642
|
+
}
|
|
643
|
+
tmpDir.mkdirs()
|
|
644
|
+
|
|
645
|
+
// 3) Extract archive into tmpDir (80% - 100%)
|
|
646
|
+
CommonHelper.logPrint(CLASS_TAG, "Extracting $tempBundleFile → $tmpDir")
|
|
647
|
+
if (!decompressService.extractZipFile(
|
|
648
|
+
tempBundleFile.absolutePath,
|
|
649
|
+
tmpDir.absolutePath,
|
|
650
|
+
) { unzipProgress ->
|
|
651
|
+
// Map unzip progress (0.0 - 1.0) to overall progress (0.8 - 1.0)
|
|
652
|
+
progressCallback(0.8 + (unzipProgress * 0.2))
|
|
653
|
+
}
|
|
654
|
+
) {
|
|
655
|
+
CommonHelper.logPrint(CLASS_TAG, "Failed to extract archive into tmpDir.")
|
|
656
|
+
tempDir.deleteRecursively()
|
|
657
|
+
tmpDir.deleteRecursively()
|
|
658
|
+
throw IpayCodePushException.extractionFormatError()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 4) Find index.android.bundle inside tmpDir
|
|
662
|
+
val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
|
|
663
|
+
if (extractedIndex == null) {
|
|
664
|
+
CommonHelper.logPrint(CLASS_TAG,"index.android.bundle not found in tmpDir.")
|
|
665
|
+
tempDir.deleteRecursively()
|
|
666
|
+
tmpDir.deleteRecursively()
|
|
667
|
+
throw IpayCodePushException.invalidBundle()
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 5) Log extracted bundle file size
|
|
671
|
+
val bundleSize = extractedIndex.length()
|
|
672
|
+
CommonHelper.logPrint(CLASS_TAG, "Extracted bundle size: $bundleSize bytes")
|
|
673
|
+
|
|
674
|
+
// 6) If the realDir (bundle-store/<bundleId>) exists, delete it
|
|
675
|
+
if (finalBundleDir.exists()) {
|
|
676
|
+
finalBundleDir.deleteRecursively()
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 7) Attempt to rename tmpDir → finalBundleDir (atomic within the same parent folder)
|
|
680
|
+
val renamed = tmpDir.renameTo(finalBundleDir)
|
|
681
|
+
if (!renamed) {
|
|
682
|
+
// If rename fails, use moveItem as fallback
|
|
683
|
+
if (!fileSystem.moveItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
|
|
684
|
+
// If move also fails, try copy + delete as last resort
|
|
685
|
+
if (!fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
|
|
686
|
+
// All strategies failed
|
|
687
|
+
CommonHelper.logPrint(
|
|
688
|
+
CommonHelper.ERROR_LOG,
|
|
689
|
+
CLASS_TAG,
|
|
690
|
+
"Failed to move bundle from tmpDir to finalBundleDir (rename, move, and copy all failed)",
|
|
691
|
+
)
|
|
692
|
+
tempDir.deleteRecursively()
|
|
693
|
+
tmpDir.deleteRecursively()
|
|
694
|
+
throw IpayCodePushException.moveOperationFailed()
|
|
695
|
+
}
|
|
696
|
+
// Copy succeeded, clean up tmpDir
|
|
697
|
+
tmpDir.deleteRecursively()
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 8) Verify index.android.bundle exists inside finalBundleDir
|
|
702
|
+
val finalIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
|
|
703
|
+
if (finalIndexFile == null) {
|
|
704
|
+
CommonHelper.logPrint(CLASS_TAG, "index.android.bundle not found in realDir.")
|
|
705
|
+
tempDir.deleteRecursively()
|
|
706
|
+
finalBundleDir.deleteRecursively()
|
|
707
|
+
throw IpayCodePushException.invalidBundle()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// 9) Update finalBundleDir's last modified time
|
|
711
|
+
finalBundleDir.setLastModified(System.currentTimeMillis())
|
|
712
|
+
|
|
713
|
+
// 10) Save the new bundle as STAGING with verification pending
|
|
714
|
+
val bundlePath = finalIndexFile.absolutePath
|
|
715
|
+
CommonHelper.logPrint(CLASS_TAG, "Setting bundle as staging: $bundlePath")
|
|
716
|
+
|
|
717
|
+
// Update metadata: set new bundle as staging
|
|
718
|
+
val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
|
|
719
|
+
val updatedMetadata =
|
|
720
|
+
currentMetadata.copy(
|
|
721
|
+
stagingBundleId = bundleId,
|
|
722
|
+
verificationPending = true,
|
|
723
|
+
verificationAttemptedAt = null,
|
|
724
|
+
updatedAt = System.currentTimeMillis(),
|
|
725
|
+
)
|
|
726
|
+
saveMetadata(updatedMetadata)
|
|
727
|
+
|
|
728
|
+
// Also update IpayCodePushBundleURL for backwards compatibility
|
|
729
|
+
// This will point to the staging bundle that will be loaded
|
|
730
|
+
setBundleURL(bundlePath)
|
|
731
|
+
|
|
732
|
+
// 11) Clean up temporary and download folders
|
|
733
|
+
tempDir.deleteRecursively()
|
|
734
|
+
|
|
735
|
+
// 12) Keep both stable and staging bundles
|
|
736
|
+
val stableBundleId = currentMetadata.stableBundleId
|
|
737
|
+
cleanupOldBundles(bundleStoreDir, stableBundleId, bundleId)
|
|
738
|
+
|
|
739
|
+
CommonHelper.logPrint(CLASS_TAG, "Downloaded and set bundle as staging successfully. Will be promoted after notifyAppReady.")
|
|
740
|
+
// Progress already at 1.0 from unzip completion
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Removes old bundles except for the specified bundle IDs, and any leftover .tmp directories
|
|
748
|
+
*/
|
|
749
|
+
private fun cleanupOldBundles(
|
|
750
|
+
bundleStoreDir: File,
|
|
751
|
+
currentBundleId: String?,
|
|
752
|
+
bundleId: String,
|
|
753
|
+
) {
|
|
754
|
+
try {
|
|
755
|
+
// List only directories that are not .tmp
|
|
756
|
+
val bundles =
|
|
757
|
+
bundleStoreDir
|
|
758
|
+
.listFiles { file ->
|
|
759
|
+
file.isDirectory && !file.name.endsWith(".tmp")
|
|
760
|
+
}?.toList() ?: return
|
|
761
|
+
|
|
762
|
+
// Keep only the specified bundle IDs (filter out null values)
|
|
763
|
+
val bundleIdsToKeep = setOfNotNull(currentBundleId, bundleId).filter { it.isNotBlank() }
|
|
764
|
+
|
|
765
|
+
bundles.forEach { bundle ->
|
|
766
|
+
try {
|
|
767
|
+
if (bundle.name !in bundleIdsToKeep) {
|
|
768
|
+
CommonHelper.logPrint(CLASS_TAG, "Removing old bundle: ${bundle.name}")
|
|
769
|
+
if (bundle.deleteRecursively()) {
|
|
770
|
+
CommonHelper.logPrint(CLASS_TAG, "Successfully removed old bundle: ${bundle.name}")
|
|
771
|
+
} else {
|
|
772
|
+
CommonHelper.logPrint(CLASS_TAG, "Failed to remove old bundle: ${bundle.name}")
|
|
773
|
+
}
|
|
774
|
+
} else {
|
|
775
|
+
CommonHelper.logPrint(CLASS_TAG, "Keeping bundle: ${bundle.name}")
|
|
776
|
+
}
|
|
777
|
+
} catch (e: Exception) {
|
|
778
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Error removing bundle ${bundle.name}: ${e.message}")
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Remove any leftover .tmp directories
|
|
783
|
+
bundleStoreDir
|
|
784
|
+
.listFiles { file ->
|
|
785
|
+
file.isDirectory && file.name.endsWith(".tmp")
|
|
786
|
+
}?.forEach { staleTmp ->
|
|
787
|
+
try {
|
|
788
|
+
CommonHelper.logPrint(CLASS_TAG, "Removing stale tmp directory: ${staleTmp.name}")
|
|
789
|
+
if (staleTmp.deleteRecursively()) {
|
|
790
|
+
CommonHelper.logPrint(CLASS_TAG, "Successfully removed tmp directory: ${staleTmp.name}")
|
|
791
|
+
} else {
|
|
792
|
+
CommonHelper.logPrint(CommonHelper.WARNING_LOG,CLASS_TAG, "Failed to remove tmp directory: ${staleTmp.name}")
|
|
793
|
+
}
|
|
794
|
+
} catch (e: Exception) {
|
|
795
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG, "Error removing tmp directory ${staleTmp.name}: ${e.message}")
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} catch (e: Exception) {
|
|
799
|
+
CommonHelper.logPrint(CommonHelper.ERROR_LOG,CLASS_TAG,"Error during cleanup: ${e.message}")
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Gets the base URL for the current active bundle directory.
|
|
805
|
+
* Returns the file:// URL to the bundle directory without trailing slash.
|
|
806
|
+
* This is used for Expo DOM components to construct full asset paths.
|
|
807
|
+
* @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
|
|
808
|
+
*/
|
|
809
|
+
override fun getBaseURL(): String {
|
|
810
|
+
return try {
|
|
811
|
+
val metadata = loadMetadataOrNull()
|
|
812
|
+
|
|
813
|
+
val activeBundleId =
|
|
814
|
+
when {
|
|
815
|
+
metadata?.verificationPending == true && metadata.stagingBundleId != null ->
|
|
816
|
+
metadata.stagingBundleId
|
|
817
|
+
metadata?.stableBundleId != null -> metadata.stableBundleId
|
|
818
|
+
else -> extractBundleIdFromCurrentURL()
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (activeBundleId != null) {
|
|
822
|
+
val bundleDir = File(getBundleStoreDir(), activeBundleId)
|
|
823
|
+
if (bundleDir.exists()) {
|
|
824
|
+
return "file://${bundleDir.absolutePath}"
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
""
|
|
829
|
+
} catch (e: Exception) {
|
|
830
|
+
CommonHelper.logPrint(CLASS_TAG, "Error getting base URL: ${e.message}")
|
|
831
|
+
Log.e(CLASS_TAG, "Error getting base URL: ${e.message}")
|
|
832
|
+
""
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|