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.
Files changed (87) hide show
  1. package/InstantpayCodePush.podspec +20 -0
  2. package/LICENSE +20 -0
  3. package/README.md +158 -0
  4. package/android/build.gradle +91 -0
  5. package/android/gradle.properties +5 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/instantpaycodepush/BundleFileStorageService.kt +835 -0
  8. package/android/src/main/java/com/instantpaycodepush/BundleMetadata.kt +249 -0
  9. package/android/src/main/java/com/instantpaycodepush/CommonHelper.kt +39 -0
  10. package/android/src/main/java/com/instantpaycodepush/DecompressService.kt +85 -0
  11. package/android/src/main/java/com/instantpaycodepush/DecompressionStrategy.kt +24 -0
  12. package/android/src/main/java/com/instantpaycodepush/FileManagerService.kt +105 -0
  13. package/android/src/main/java/com/instantpaycodepush/HashUtils.kt +50 -0
  14. package/android/src/main/java/com/instantpaycodepush/InstantpayCodePushModule.kt +182 -0
  15. package/android/src/main/java/com/instantpaycodepush/InstantpayCodePushPackage.kt +33 -0
  16. package/android/src/main/java/com/instantpaycodepush/IpayCodePush.kt +101 -0
  17. package/android/src/main/java/com/instantpaycodepush/IpayCodePushException.kt +135 -0
  18. package/android/src/main/java/com/instantpaycodepush/IpayCodePushImpl.kt +329 -0
  19. package/android/src/main/java/com/instantpaycodepush/OkHttpDownloadService.kt +283 -0
  20. package/android/src/main/java/com/instantpaycodepush/ReactIntegrationManager.kt +141 -0
  21. package/android/src/main/java/com/instantpaycodepush/ReactIntegrationManagerBase.kt +35 -0
  22. package/android/src/main/java/com/instantpaycodepush/SignatureVerifier.kt +354 -0
  23. package/android/src/main/java/com/instantpaycodepush/VersionedPreferencesService.kt +70 -0
  24. package/android/src/main/java/com/instantpaycodepush/ZipDecompressionStrategy.kt +198 -0
  25. package/ios/InstantpayCodePush.h +5 -0
  26. package/ios/InstantpayCodePush.mm +21 -0
  27. package/lib/module/DefaultResolver.js +34 -0
  28. package/lib/module/DefaultResolver.js.map +1 -0
  29. package/lib/module/NativeInstantpayCodePush.js +5 -0
  30. package/lib/module/NativeInstantpayCodePush.js.map +1 -0
  31. package/lib/module/checkForUpdate.js +68 -0
  32. package/lib/module/checkForUpdate.js.map +1 -0
  33. package/lib/module/error.js +137 -0
  34. package/lib/module/error.js.map +1 -0
  35. package/lib/module/fetchUpdateInfo.js +36 -0
  36. package/lib/module/fetchUpdateInfo.js.map +1 -0
  37. package/lib/module/global.d.js +8 -0
  38. package/lib/module/global.d.js.map +1 -0
  39. package/lib/module/hooks/useEventCallback.js +13 -0
  40. package/lib/module/hooks/useEventCallback.js.map +1 -0
  41. package/lib/module/index.js +291 -0
  42. package/lib/module/index.js.map +1 -0
  43. package/lib/module/native.js +233 -0
  44. package/lib/module/native.js.map +1 -0
  45. package/lib/module/package.json +1 -0
  46. package/lib/module/store.js +53 -0
  47. package/lib/module/store.js.map +1 -0
  48. package/lib/module/types.js +62 -0
  49. package/lib/module/types.js.map +1 -0
  50. package/lib/module/wrap.js +171 -0
  51. package/lib/module/wrap.js.map +1 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/DefaultResolver.d.ts +10 -0
  54. package/lib/typescript/src/DefaultResolver.d.ts.map +1 -0
  55. package/lib/typescript/src/NativeInstantpayCodePush.d.ts +100 -0
  56. package/lib/typescript/src/NativeInstantpayCodePush.d.ts.map +1 -0
  57. package/lib/typescript/src/checkForUpdate.d.ts +29 -0
  58. package/lib/typescript/src/checkForUpdate.d.ts.map +1 -0
  59. package/lib/typescript/src/error.d.ts +124 -0
  60. package/lib/typescript/src/error.d.ts.map +1 -0
  61. package/lib/typescript/src/fetchUpdateInfo.d.ts +8 -0
  62. package/lib/typescript/src/fetchUpdateInfo.d.ts.map +1 -0
  63. package/lib/typescript/src/hooks/useEventCallback.d.ts +5 -0
  64. package/lib/typescript/src/hooks/useEventCallback.d.ts.map +1 -0
  65. package/lib/typescript/src/index.d.ts +203 -0
  66. package/lib/typescript/src/index.d.ts.map +1 -0
  67. package/lib/typescript/src/native.d.ts +128 -0
  68. package/lib/typescript/src/native.d.ts.map +1 -0
  69. package/lib/typescript/src/store.d.ts +11 -0
  70. package/lib/typescript/src/store.d.ts.map +1 -0
  71. package/lib/typescript/src/types.d.ts +174 -0
  72. package/lib/typescript/src/types.d.ts.map +1 -0
  73. package/lib/typescript/src/wrap.d.ts +179 -0
  74. package/lib/typescript/src/wrap.d.ts.map +1 -0
  75. package/package.json +174 -0
  76. package/src/DefaultResolver.ts +36 -0
  77. package/src/NativeInstantpayCodePush.ts +111 -0
  78. package/src/checkForUpdate.ts +122 -0
  79. package/src/error.ts +159 -0
  80. package/src/fetchUpdateInfo.ts +47 -0
  81. package/src/global.d.ts +23 -0
  82. package/src/hooks/useEventCallback.ts +30 -0
  83. package/src/index.tsx +379 -0
  84. package/src/native.ts +280 -0
  85. package/src/store.ts +69 -0
  86. package/src/types.ts +227 -0
  87. 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
+ }