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,329 @@
1
+ package com.instantpaycodepush
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.util.Log
6
+ import kotlinx.coroutines.Dispatchers
7
+ import kotlinx.coroutines.withContext
8
+
9
+
10
+ /**
11
+ * Core implementation class for HotUpdater functionality
12
+ */
13
+ class IpayCodePushImpl {
14
+ private val context: Context
15
+ private val bundleStorage: BundleStorageService
16
+ private val preferences: PreferencesService
17
+
18
+ /**
19
+ * Primary constructor with dependency injection (for testing)
20
+ */
21
+ constructor(
22
+ context: Context,
23
+ bundleStorage: BundleStorageService,
24
+ preferences: PreferencesService,
25
+ ) {
26
+ this.context = context.applicationContext
27
+ this.bundleStorage = bundleStorage
28
+ this.preferences = preferences
29
+ }
30
+
31
+ /**
32
+ * Convenience constructor for simple usage
33
+ */
34
+ constructor(context: Context) : this(
35
+ context = context,
36
+ bundleStorage = createBundleStorage(context),
37
+ preferences = createPreferences(context),
38
+ )
39
+
40
+ companion object {
41
+ private const val CLASS_TAG = "*IpayCodePushImpl"
42
+ private const val DEFAULT_CHANNEL = "production"
43
+
44
+ /**
45
+ * Create BundleStorageService with all dependencies
46
+ */
47
+ private fun createBundleStorage(context: Context): BundleStorageService {
48
+ val appContext = context.applicationContext
49
+ val fileSystem = FileManagerService(appContext)
50
+ val preferences = createPreferences(appContext)
51
+ val downloadService = OkHttpDownloadService()
52
+ val decompressService = DecompressService()
53
+ val isolationKey = getIsolationKey(appContext)
54
+
55
+ return BundleFileStorageService(
56
+ appContext,
57
+ fileSystem,
58
+ downloadService,
59
+ decompressService,
60
+ preferences,
61
+ isolationKey,
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Create PreferencesService with isolation key
67
+ */
68
+ private fun createPreferences(context: Context): PreferencesService {
69
+ val appContext = context.applicationContext
70
+ val isolationKey = getIsolationKey(appContext)
71
+ return VersionedPreferencesService(appContext, isolationKey)
72
+ }
73
+
74
+ /**
75
+ * Gets the complete isolation key for preferences storage
76
+ * @param context Application context
77
+ * @return The isolation key in format: IpayCodePushPrefs_{fingerprintOrVersion}_{channel}
78
+ */
79
+ private fun getIsolationKey(context: Context): String {
80
+ // Get fingerprint hash directly from resources
81
+ val fingerprintId = context.resources.getIdentifier("ipay_code_push_fingerprint_hash", "string", context.packageName)
82
+ val fingerprintHash =
83
+ if (fingerprintId != 0) {
84
+ context.getString(fingerprintId).takeIf { it.isNotEmpty() }
85
+ } else {
86
+ null
87
+ }
88
+
89
+ // Get app version and channel
90
+ val appVersion = getAppVersion(context) ?: "unknown"
91
+ val appChannel = getChannel(context)
92
+
93
+ // Include both fingerprint hash and app version for complete isolation
94
+ val baseKey =
95
+ if (!fingerprintHash.isNullOrEmpty()) {
96
+ "${fingerprintHash}_$appVersion"
97
+ } else {
98
+ appVersion
99
+ }
100
+
101
+ return "IpayCodePushPrefs_${baseKey}_$appChannel"
102
+ }
103
+
104
+ fun getAppVersion(context: Context): String? =
105
+ try {
106
+ val packageInfo =
107
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
108
+ context.packageManager.getPackageInfo(
109
+ context.packageName,
110
+ android.content.pm.PackageManager.PackageInfoFlags
111
+ .of(0),
112
+ )
113
+ } else {
114
+ @Suppress("DEPRECATION")
115
+ context.packageManager.getPackageInfo(context.packageName, 0)
116
+ }
117
+ packageInfo.versionName
118
+ } catch (e: Exception) {
119
+ null
120
+ }
121
+
122
+ fun getChannel(context: Context): String {
123
+ val id = context.resources.getIdentifier("ipay_code_push_channel", "string", context.packageName)
124
+ return if (id != 0) {
125
+ context.getString(id).takeIf { it.isNotEmpty() } ?: DEFAULT_CHANNEL
126
+ } else {
127
+ DEFAULT_CHANNEL
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Get minimum bundle ID string
133
+ * @return The minimum bundle ID string
134
+ */
135
+ fun getMinBundleId(): String {
136
+ if (BuildConfig.DEBUG) {
137
+ return "00000000-0000-0000-0000-000000000000"
138
+ }
139
+
140
+ return BuildConfig.MIN_BUNDLE_ID.takeIf { it != "null" } ?: generateMinBundleIdFromBuildTimestamp()
141
+ }
142
+
143
+ /**
144
+ * Generates a bundle ID based on build timestamp
145
+ * @return The generated minimum bundle ID string
146
+ */
147
+ private fun generateMinBundleIdFromBuildTimestamp(): String =
148
+ try {
149
+ val buildTimestampMs = BuildConfig.BUILD_TIMESTAMP
150
+ val bytes =
151
+ ByteArray(16).apply {
152
+ this[0] = ((buildTimestampMs shr 40) and 0xFF).toByte()
153
+ this[1] = ((buildTimestampMs shr 32) and 0xFF).toByte()
154
+ this[2] = ((buildTimestampMs shr 24) and 0xFF).toByte()
155
+ this[3] = ((buildTimestampMs shr 16) and 0xFF).toByte()
156
+ this[4] = ((buildTimestampMs shr 8) and 0xFF).toByte()
157
+ this[5] = (buildTimestampMs and 0xFF).toByte()
158
+ this[6] = 0x70.toByte()
159
+ this[7] = 0x00.toByte()
160
+ this[8] = 0x80.toByte()
161
+ this[9] = 0x00.toByte()
162
+ this[10] = 0x00.toByte()
163
+ this[11] = 0x00.toByte()
164
+ this[12] = 0x00.toByte()
165
+ this[13] = 0x00.toByte()
166
+ this[14] = 0x00.toByte()
167
+ this[15] = 0x00.toByte()
168
+ }
169
+ String.format(
170
+ "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
171
+ bytes[0].toInt() and 0xFF,
172
+ bytes[1].toInt() and 0xFF,
173
+ bytes[2].toInt() and 0xFF,
174
+ bytes[3].toInt() and 0xFF,
175
+ bytes[4].toInt() and 0xFF,
176
+ bytes[5].toInt() and 0xFF,
177
+ bytes[6].toInt() and 0xFF,
178
+ bytes[7].toInt() and 0xFF,
179
+ bytes[8].toInt() and 0xFF,
180
+ bytes[9].toInt() and 0xFF,
181
+ bytes[10].toInt() and 0xFF,
182
+ bytes[11].toInt() and 0xFF,
183
+ bytes[12].toInt() and 0xFF,
184
+ bytes[13].toInt() and 0xFF,
185
+ bytes[14].toInt() and 0xFF,
186
+ bytes[15].toInt() and 0xFF,
187
+ )
188
+ } catch (e: Exception) {
189
+ "00000000-0000-0000-0000-000000000000"
190
+ }
191
+
192
+ /**
193
+ * Gets the current fingerprint hash
194
+ * @param context Application context
195
+ * @return The fingerprint hash or null if not set
196
+ */
197
+ fun getFingerprintHash(context: Context): String? {
198
+ val id = context.resources.getIdentifier("ipay_code_push_fingerprint_hash", "string", context.packageName)
199
+ return if (id != 0) {
200
+ context.getString(id).takeIf { it.isNotEmpty() }
201
+ } else {
202
+ null
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Gets the app version
209
+ * @param context Application context
210
+ * @return App version name or null if not available
211
+ */
212
+ fun getAppVersion(): String? =
213
+ try {
214
+ val packageInfo =
215
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
216
+ context.packageManager.getPackageInfo(
217
+ context.packageName,
218
+ android.content.pm.PackageManager.PackageInfoFlags
219
+ .of(0),
220
+ )
221
+ } else {
222
+ @Suppress("DEPRECATION")
223
+ context.packageManager.getPackageInfo(context.packageName, 0)
224
+ }
225
+ packageInfo.versionName
226
+ } catch (e: Exception) {
227
+ null
228
+ }
229
+
230
+ /**
231
+ * Gets the current fingerprint hash
232
+ * @return The fingerprint hash or null if not set
233
+ */
234
+ fun getFingerprintHash(): String? {
235
+ val id = context.resources.getIdentifier("ipay_code_push_fingerprint_hash", "string", context.packageName)
236
+ return if (id != 0) {
237
+ context.getString(id).takeIf { it.isNotEmpty() }
238
+ } else {
239
+ null
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Gets the current update channel
245
+ * @return The channel name or null if not set
246
+ */
247
+ fun getChannel(): String {
248
+ val id = context.resources.getIdentifier("ipay_code_push_channel", "string", context.packageName)
249
+ return if (id != 0) {
250
+ context.getString(id).takeIf { it.isNotEmpty() } ?: DEFAULT_CHANNEL
251
+ } else {
252
+ DEFAULT_CHANNEL
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Gets the path to the bundle file
258
+ * @return The path to the bundle file
259
+ */
260
+ fun getJSBundleFile(): String = bundleStorage.getBundleURL()
261
+
262
+ /**
263
+ * Updates the bundle from the specified URL
264
+ * @param bundleId ID of the bundle to update
265
+ * @param fileUrl URL of the bundle file to download (or null to reset)
266
+ * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
267
+ * @param progressCallback Callback for download progress updates
268
+ * @throws IpayCodePushException if the update fails
269
+ */
270
+ suspend fun updateBundle(
271
+ bundleId: String,
272
+ fileUrl: String?,
273
+ fileHash: String?,
274
+ progressCallback: (Double) -> Unit,
275
+ ) {
276
+ bundleStorage.updateBundle(bundleId, fileUrl, fileHash, progressCallback)
277
+ }
278
+
279
+ /**
280
+ * Reloads the React Native application
281
+ * @param activity Current activity (optional)
282
+ */
283
+ suspend fun reload(activity: Activity? = null) {
284
+ val reactIntegrationManager = ReactIntegrationManager(context)
285
+ val application = activity?.application ?: return
286
+
287
+ try {
288
+ val reactApplication = reactIntegrationManager.getReactApplication(application)
289
+ val bundleURL = getJSBundleFile()
290
+
291
+ // Perform reload (suspends until safe to reload on new arch)
292
+ withContext(Dispatchers.Main) {
293
+ reactIntegrationManager.setJSBundle(reactApplication, bundleURL)
294
+ reactIntegrationManager.reload(reactApplication)
295
+ }
296
+ } catch (e: Exception) {
297
+ CommonHelper.logPrint(CLASS_TAG, "Failed to reload application $e")
298
+ Log.e(CLASS_TAG, "Failed to reload application", e)
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Notifies the system that the app has successfully started with the given bundle.
304
+ * If the bundle matches the staging bundle, it promotes to stable.
305
+ * @param bundleId The ID of the currently running bundle
306
+ * @return Map containing status and optional crashedBundleId
307
+ */
308
+ fun notifyAppReady(bundleId: String): Map<String, Any?> = bundleStorage.notifyAppReady(bundleId)
309
+
310
+ /**
311
+ * Gets the crashed bundle history.
312
+ * @return List of crashed bundle IDs
313
+ */
314
+ fun getCrashHistory(): List<String> = bundleStorage.getCrashHistory().bundles.map { it.bundleId }
315
+
316
+ /**
317
+ * Clears the crashed bundle history.
318
+ * @return true if clearing was successful
319
+ */
320
+ fun clearCrashHistory(): Boolean = bundleStorage.clearCrashHistory()
321
+
322
+ /**
323
+ * Gets the base URL for the current active bundle directory.
324
+ * Returns the file:// URL to the bundle directory with trailing slash.
325
+ * This is used for Expo DOM components to construct full asset paths.
326
+ * @return Base URL string (e.g., "file:///data/.../bundle-store/abc123/") or empty string
327
+ */
328
+ fun getBaseURL(): String = bundleStorage.getBaseURL()
329
+ }
@@ -0,0 +1,283 @@
1
+ package com.instantpaycodepush
2
+
3
+ import android.util.Log
4
+ import kotlinx.coroutines.Dispatchers
5
+ import kotlinx.coroutines.delay
6
+ import kotlinx.coroutines.withContext
7
+
8
+ import okhttp3.OkHttpClient
9
+ import okhttp3.Request
10
+ import okhttp3.Response
11
+ import okhttp3.ResponseBody
12
+
13
+ import okio.Buffer
14
+ import okio.BufferedSource
15
+ import okio.ForwardingSource
16
+ import okio.Source
17
+ import okio.buffer
18
+
19
+ import java.io.File
20
+ import java.io.IOException
21
+ import java.net.SocketTimeoutException
22
+ import java.net.URL
23
+ import java.net.UnknownHostException
24
+ import java.util.concurrent.TimeUnit
25
+
26
+ /**
27
+ * Exception for incomplete downloads with size information
28
+ */
29
+ class IncompleteDownloadException(
30
+ val expectedSize: Long,
31
+ val actualSize: Long,
32
+ ) : IOException("Download incomplete: received $actualSize bytes, expected $expectedSize bytes")
33
+
34
+
35
+ /**
36
+ * Result wrapper for download operations
37
+ */
38
+ sealed class DownloadResult {
39
+ data class Success(
40
+ val file: File,
41
+ ) : DownloadResult()
42
+
43
+ data class Error(
44
+ val exception: Exception,
45
+ ) : DownloadResult()
46
+ }
47
+
48
+ /**
49
+ * Interface for download operations
50
+ */
51
+ interface DownloadService {
52
+ /**
53
+ * Downloads a file from a URL
54
+ * @param fileUrl The URL to download from
55
+ * @param destination The local file to save to
56
+ * @param fileSizeCallback Optional callback called when file size is known
57
+ * @param progressCallback Callback for download progress updates
58
+ * @return Result indicating success or failure
59
+ */
60
+ suspend fun downloadFile(
61
+ fileUrl: URL,
62
+ destination: File,
63
+ fileSizeCallback: ((Long) -> Unit)? = null,
64
+ progressCallback: (Double) -> Unit,
65
+ ): DownloadResult
66
+ }
67
+
68
+ /**
69
+ * Progress tracking wrapper for OkHttp ResponseBody
70
+ */
71
+ private class ProgressResponseBody(
72
+ private val responseBody: ResponseBody,
73
+ private val progressCallback: (Double) -> Unit,
74
+ ) : ResponseBody() {
75
+ private var bufferedSource: BufferedSource? = null
76
+
77
+ override fun contentType() = responseBody.contentType()
78
+
79
+ override fun contentLength() = responseBody.contentLength()
80
+
81
+ override fun source(): BufferedSource {
82
+ if (bufferedSource == null) {
83
+ bufferedSource = source(responseBody.source()).buffer()
84
+ }
85
+ return bufferedSource!!
86
+ }
87
+
88
+ private fun source(source: Source): Source =
89
+ object : ForwardingSource(source) {
90
+ var totalBytesRead = 0L
91
+ var lastProgressTime = System.currentTimeMillis()
92
+
93
+ override fun read(
94
+ sink: Buffer,
95
+ byteCount: Long,
96
+ ): Long {
97
+ val bytesRead = super.read(sink, byteCount)
98
+ totalBytesRead += if (bytesRead != -1L) bytesRead else 0
99
+ val currentTime = System.currentTimeMillis()
100
+
101
+ if (currentTime - lastProgressTime >= 100) {
102
+ val progress = totalBytesRead.toDouble() / contentLength()
103
+ progressCallback.invoke(progress)
104
+ lastProgressTime = currentTime
105
+ }
106
+ return bytesRead
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * OkHttp-based implementation of DownloadService with resume support
113
+ */
114
+ class OkHttpDownloadService : DownloadService {
115
+
116
+ companion object {
117
+ private const val CLASS_TAG = "*OkHttpDownloadService"
118
+ private const val MAX_RETRIES = 3
119
+ private const val INITIAL_RETRY_DELAY_MS = 1000L
120
+ private const val TIMEOUT_SECONDS = 30L
121
+ }
122
+
123
+ private val client =
124
+ OkHttpClient
125
+ .Builder()
126
+ .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
127
+ .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
128
+ .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
129
+ .build()
130
+
131
+ override suspend fun downloadFile(
132
+ fileUrl: URL,
133
+ destination: File,
134
+ fileSizeCallback: ((Long) -> Unit)?,
135
+ progressCallback: (Double) -> Unit,
136
+ ): DownloadResult =
137
+ withContext(Dispatchers.IO) {
138
+ var attempt = 0
139
+ var lastException: Exception? = null
140
+
141
+ while (attempt < MAX_RETRIES) {
142
+ try {
143
+ return@withContext attemptDownload(
144
+ fileUrl,
145
+ destination,
146
+ fileSizeCallback,
147
+ progressCallback,
148
+ )
149
+ } catch (e: Exception) {
150
+ lastException = e
151
+ attempt++
152
+
153
+ if (attempt < MAX_RETRIES && isRetryableException(e)) {
154
+ val delayMs = INITIAL_RETRY_DELAY_MS * (1 shl (attempt - 1))
155
+ CommonHelper.logPrint(
156
+ CLASS_TAG,
157
+ "Download failed (attempt $attempt/$MAX_RETRIES): ${e.message}. Retrying in ${delayMs}ms...",
158
+ )
159
+ delay(delayMs)
160
+ } else {
161
+ CommonHelper.logPrint(CLASS_TAG, "Download failed: ${e.message}")
162
+ break
163
+ }
164
+ }
165
+ }
166
+
167
+ DownloadResult.Error(lastException ?: Exception("Download failed after $MAX_RETRIES attempts"))
168
+ }
169
+
170
+ private suspend fun attemptDownload(
171
+ fileUrl: URL,
172
+ destination: File,
173
+ fileSizeCallback: ((Long) -> Unit)?,
174
+ progressCallback: (Double) -> Unit,
175
+ ): DownloadResult =
176
+ withContext(Dispatchers.IO) {
177
+ // Make sure parent directories exist
178
+ destination.parentFile?.mkdirs()
179
+
180
+ // Delete any existing partial file to start fresh
181
+ if (destination.exists()) {
182
+ CommonHelper.logPrint(CLASS_TAG, "Deleting existing file, starting fresh download")
183
+ destination.delete()
184
+ }
185
+
186
+ val request = Request.Builder().url(fileUrl).build()
187
+ val response: Response
188
+
189
+ try {
190
+ response = client.newCall(request).execute()
191
+ } catch (e: Exception) {
192
+ CommonHelper.logPrint(CLASS_TAG, "Failed to execute request: ${e.message}")
193
+ return@withContext DownloadResult.Error(e)
194
+ }
195
+
196
+ if (!response.isSuccessful) {
197
+ val errorMsg = "HTTP error ${response.code}: ${response.message}"
198
+ CommonHelper.logPrint(CLASS_TAG, errorMsg)
199
+ response.close()
200
+ return@withContext DownloadResult.Error(Exception(errorMsg))
201
+ }
202
+
203
+ val body = response.body
204
+ if (body == null) {
205
+ response.close()
206
+ return@withContext DownloadResult.Error(Exception("Response body is null"))
207
+ }
208
+
209
+ // Get total file size
210
+ val totalSize = body.contentLength()
211
+
212
+ if (totalSize > 0) {
213
+ // Notify file size to caller for disk space check
214
+ fileSizeCallback?.invoke(totalSize)
215
+ CommonHelper.logPrint(CLASS_TAG, "Starting download: $totalSize bytes")
216
+ } else {
217
+ CommonHelper.logPrint(CLASS_TAG, "Content-Length not available ($totalSize), proceeding without disk space check")
218
+ }
219
+
220
+ try {
221
+ // Wrap response body with progress tracking
222
+ val progressBody =
223
+ ProgressResponseBody(body) { progress ->
224
+ progressCallback.invoke(progress)
225
+ }
226
+
227
+ // Write to file
228
+ progressBody.source().use { source ->
229
+ destination.outputStream().use { output ->
230
+ val buffer = ByteArray(8 * 1024)
231
+ var bytesRead: Int
232
+
233
+ while (source.read(buffer).also { bytesRead = it } != -1) {
234
+ output.write(buffer, 0, bytesRead)
235
+ }
236
+ }
237
+ }
238
+
239
+ response.close()
240
+
241
+ // Verify file size
242
+ val finalSize = destination.length()
243
+ if (finalSize != totalSize) {
244
+ CommonHelper.logPrint(CLASS_TAG, "Download incomplete: $finalSize / $totalSize bytes")
245
+
246
+ // Delete incomplete file
247
+ destination.delete()
248
+ return@withContext DownloadResult.Error(
249
+ IncompleteDownloadException(
250
+ expectedSize = totalSize,
251
+ actualSize = finalSize,
252
+ ),
253
+ )
254
+ }
255
+
256
+ CommonHelper.logPrint(CLASS_TAG, "Download completed successfully: $finalSize bytes")
257
+ progressCallback.invoke(1.0)
258
+ DownloadResult.Success(destination)
259
+ } catch (e: Exception) {
260
+ response.close()
261
+ CommonHelper.logPrint(CLASS_TAG, "Failed to download data: ${e.message}")
262
+
263
+ // Delete incomplete file
264
+ if (destination.exists()) {
265
+ destination.delete()
266
+ }
267
+ DownloadResult.Error(e)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Check if exception is retryable
273
+ */
274
+ private fun isRetryableException(e: Exception): Boolean =
275
+ when (e) {
276
+ is SocketTimeoutException,
277
+ is UnknownHostException,
278
+ is IOException,
279
+ -> true
280
+
281
+ else -> false
282
+ }
283
+ }