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,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
|
+
}
|