@kesha-antonov/react-native-background-downloader 4.1.2-alpha.0 → 4.1.3

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.
@@ -22,12 +22,14 @@ import com.facebook.react.bridge.Arguments
22
22
  import com.facebook.react.bridge.Promise
23
23
  import com.facebook.react.bridge.ReactApplicationContext
24
24
  import com.facebook.react.bridge.ReadableMap
25
+ import com.facebook.react.bridge.ReadableType
25
26
  import com.facebook.react.bridge.WritableArray
26
27
  import com.facebook.react.bridge.WritableMap
27
28
  import com.facebook.react.modules.core.DeviceEventManagerModule
28
29
  import com.google.gson.Gson
29
30
  import com.google.gson.reflect.TypeToken
30
31
  import com.tencent.mmkv.MMKV
32
+ import org.json.JSONObject
31
33
  import java.io.File
32
34
  import java.net.HttpURLConnection
33
35
  import java.net.URL
@@ -38,829 +40,847 @@ import java.util.concurrent.Future
38
40
 
39
41
  class RNBackgroundDownloaderModuleImpl(private val reactContext: ReactApplicationContext) {
40
42
 
41
- companion object {
42
- const val NAME = "RNBackgroundDownloader"
43
-
44
- // Library version
45
- private const val VERSION = "3.2.6"
46
- private const val USER_AGENT = "ReactNative-BackgroundDownloader/$VERSION"
47
-
48
- // Task state constants
49
- private const val TASK_RUNNING = 0
50
- private const val TASK_SUSPENDED = 1
51
- private const val TASK_CANCELING = 2
52
- private const val TASK_COMPLETED = 3
53
-
54
- // Error code constants
55
- private const val ERR_STORAGE_FULL = 0
56
- private const val ERR_NO_INTERNET = 1
57
- private const val ERR_NO_WRITE_PERMISSION = 2
58
- private const val ERR_FILE_NOT_FOUND = 3
59
- private const val ERR_OTHERS = 100
60
-
61
- // Network timeout constants (milliseconds)
62
- private const val REDIRECT_CONNECT_TIMEOUT_MS = 10000 // 10 seconds
63
- private const val REDIRECT_READ_TIMEOUT_MS = 10000 // 10 seconds
64
-
65
- // Progress reporting constants
66
- private const val PROGRESS_REPORT_THRESHOLD = 0.01 // 1% change
67
-
68
- // HTTP header constants
69
- private const val KEEP_ALIVE_HEADER_VALUE = "timeout=600, max=1000"
70
-
71
- private val stateMap = mapOf(
72
- DownloadManager.STATUS_FAILED to TASK_CANCELING,
73
- DownloadManager.STATUS_PAUSED to TASK_SUSPENDED,
74
- DownloadManager.STATUS_PENDING to TASK_RUNNING,
75
- DownloadManager.STATUS_RUNNING to TASK_RUNNING,
76
- DownloadManager.STATUS_SUCCESSFUL to TASK_COMPLETED
77
- )
78
-
79
- private var mmkv: MMKV? = null
80
- private lateinit var sharedPreferences: SharedPreferences
81
- private var isMMKVAvailable = false
82
- private val sharedLock = Any()
83
- }
84
-
85
- private val cachedExecutorPool: ExecutorService = Executors.newCachedThreadPool()
86
- private val fixedExecutorPool: ExecutorService = Executors.newFixedThreadPool(1)
87
- private val downloader: Downloader
88
- private var downloadReceiver: BroadcastReceiver? = null
89
- private var downloadIdToConfig = mutableMapOf<Long, RNBGDTaskConfig>()
90
- private val configIdToDownloadId = mutableMapOf<String, Long>()
91
- private val configIdToPercent = mutableMapOf<String, Double>()
92
- private val configIdToLastBytes = mutableMapOf<String, Long>()
93
- private val configIdToProgressFuture = mutableMapOf<String, Future<OnProgressState?>>()
94
- private val progressReports = mutableMapOf<String, WritableMap>()
95
- private var progressInterval = 0
96
- private var progressMinBytes: Long = 0
97
- private var lastProgressReportedAt = Date()
98
- private lateinit var ee: DeviceEventManagerModule.RCTDeviceEventEmitter
99
-
100
- init {
101
- // Initialize SharedPreferences as fallback
102
- sharedPreferences = reactContext.getSharedPreferences(NAME + "_prefs", Context.MODE_PRIVATE)
103
-
104
- // Try to initialize MMKV with comprehensive error handling
105
- try {
106
- MMKV.initialize(reactContext)
107
- mmkv = MMKV.mmkvWithID(NAME)
108
- isMMKVAvailable = true
109
- Log.d(NAME, "MMKV initialized successfully")
110
- } catch (e: UnsatisfiedLinkError) {
111
- Log.e(NAME, "Failed to initialize MMKV (libmmkv.so not found): ${e.message}")
112
- Log.w(NAME, "This may be due to unsupported architecture (x86/ARMv7). Using SharedPreferences fallback.")
113
- Log.w(NAME, "Download persistence across app restarts will use basic storage.")
114
- mmkv = null
115
- isMMKVAvailable = false
116
- } catch (e: NoClassDefFoundError) {
117
- Log.e(NAME, "MMKV classes not found: ${e.message}")
118
- Log.w(NAME, "MMKV library not available on this architecture. Using SharedPreferences fallback.")
119
- mmkv = null
120
- isMMKVAvailable = false
121
- } catch (e: Exception) {
122
- Log.e(NAME, "Failed to initialize MMKV: ${e.message}")
123
- Log.w(NAME, "Using SharedPreferences fallback for persistence.")
124
- mmkv = null
125
- isMMKVAvailable = false
126
- }
127
-
128
- loadDownloadIdToConfigMap()
129
- loadConfigMap()
130
-
131
- downloader = Downloader(reactContext)
43
+ companion object {
44
+ const val NAME = "RNBackgroundDownloader"
45
+
46
+ // Library version
47
+ private const val VERSION = "4.1.3"
48
+ private const val USER_AGENT = "ReactNative-BackgroundDownloader/$VERSION"
49
+
50
+ // Task state constants
51
+ private const val TASK_RUNNING = 0
52
+ private const val TASK_SUSPENDED = 1
53
+ private const val TASK_CANCELING = 2
54
+ private const val TASK_COMPLETED = 3
55
+
56
+ // Error code constants
57
+ private const val ERR_STORAGE_FULL = 0
58
+ private const val ERR_NO_INTERNET = 1
59
+ private const val ERR_NO_WRITE_PERMISSION = 2
60
+ private const val ERR_FILE_NOT_FOUND = 3
61
+ private const val ERR_OTHERS = 100
62
+
63
+ // Network timeout constants (milliseconds)
64
+ private const val REDIRECT_CONNECT_TIMEOUT_MS = 10000 // 10 seconds
65
+ private const val REDIRECT_READ_TIMEOUT_MS = 10000 // 10 seconds
66
+
67
+ // Progress reporting constants
68
+ private const val PROGRESS_REPORT_THRESHOLD = 0.01 // 1% change
69
+
70
+ // HTTP header constants
71
+ private const val KEEP_ALIVE_HEADER_VALUE = "timeout=600, max=1000"
72
+
73
+ private val stateMap = mapOf(
74
+ DownloadManager.STATUS_FAILED to TASK_CANCELING,
75
+ DownloadManager.STATUS_PAUSED to TASK_SUSPENDED,
76
+ DownloadManager.STATUS_PENDING to TASK_RUNNING,
77
+ DownloadManager.STATUS_RUNNING to TASK_RUNNING,
78
+ DownloadManager.STATUS_SUCCESSFUL to TASK_COMPLETED
79
+ )
80
+
81
+ private var mmkv: MMKV? = null
82
+ private lateinit var sharedPreferences: SharedPreferences
83
+ private var isMMKVAvailable = false
84
+ private val sharedLock = Any()
85
+ }
86
+
87
+ private val cachedExecutorPool: ExecutorService = Executors.newCachedThreadPool()
88
+ private val fixedExecutorPool: ExecutorService = Executors.newFixedThreadPool(1)
89
+ private val downloader: Downloader
90
+ private var downloadReceiver: BroadcastReceiver? = null
91
+ private var downloadIdToConfig = mutableMapOf<Long, RNBGDTaskConfig>()
92
+ private val configIdToDownloadId = mutableMapOf<String, Long>()
93
+ private val configIdToPercent = mutableMapOf<String, Double>()
94
+ private val configIdToLastBytes = mutableMapOf<String, Long>()
95
+ private val configIdToProgressFuture = mutableMapOf<String, Future<OnProgressState?>>()
96
+ private val progressReports = mutableMapOf<String, WritableMap>()
97
+ private var progressInterval = 0
98
+ private var progressMinBytes: Long = 0
99
+ private var lastProgressReportedAt = Date()
100
+ private lateinit var ee: DeviceEventManagerModule.RCTDeviceEventEmitter
101
+
102
+ init {
103
+ // Initialize SharedPreferences as fallback
104
+ sharedPreferences = reactContext.getSharedPreferences(NAME + "_prefs", Context.MODE_PRIVATE)
105
+
106
+ // Try to initialize MMKV with comprehensive error handling
107
+ try {
108
+ MMKV.initialize(reactContext)
109
+ mmkv = MMKV.mmkvWithID(NAME)
110
+ isMMKVAvailable = true
111
+ Log.d(NAME, "MMKV initialized successfully")
112
+ } catch (e: UnsatisfiedLinkError) {
113
+ Log.e(NAME, "Failed to initialize MMKV (libmmkv.so not found): ${e.message}")
114
+ Log.w(NAME, "This may be due to unsupported architecture (x86/ARMv7). Using SharedPreferences fallback.")
115
+ Log.w(NAME, "Download persistence across app restarts will use basic storage.")
116
+ mmkv = null
117
+ isMMKVAvailable = false
118
+ } catch (e: NoClassDefFoundError) {
119
+ Log.e(NAME, "MMKV classes not found: ${e.message}")
120
+ Log.w(NAME, "MMKV library not available on this architecture. Using SharedPreferences fallback.")
121
+ mmkv = null
122
+ isMMKVAvailable = false
123
+ } catch (e: Exception) {
124
+ Log.e(NAME, "Failed to initialize MMKV: ${e.message}")
125
+ Log.w(NAME, "Using SharedPreferences fallback for persistence.")
126
+ mmkv = null
127
+ isMMKVAvailable = false
132
128
  }
133
129
 
134
- fun getConstants(): Map<String, Any>? {
135
- val constants = mutableMapOf<String, Any>()
136
-
137
- val externalDirectory = reactContext.getExternalFilesDir(null)
138
- constants["documents"] = externalDirectory?.absolutePath ?: reactContext.filesDir.absolutePath
139
-
140
- constants["TaskRunning"] = TASK_RUNNING
141
- constants["TaskSuspended"] = TASK_SUSPENDED
142
- constants["TaskCanceling"] = TASK_CANCELING
143
- constants["TaskCompleted"] = TASK_COMPLETED
130
+ loadDownloadIdToConfigMap()
131
+ loadConfigMap()
144
132
 
145
- // Expose storage type information for debugging/monitoring
146
- constants["isMMKVAvailable"] = isMMKVAvailable
147
- constants["storageType"] = if (isMMKVAvailable) "MMKV" else "SharedPreferences"
148
-
149
- return constants
150
- }
133
+ downloader = Downloader(reactContext)
134
+ }
151
135
 
152
- fun initialize() {
153
- ee = reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
154
- registerDownloadReceiver()
136
+ fun getConstants(): Map<String, Any>? {
137
+ val constants = mutableMapOf<String, Any>()
155
138
 
156
- for ((downloadId, config) in downloadIdToConfig) {
157
- resumeTasks(downloadId, config)
158
- }
159
- }
139
+ val externalDirectory = reactContext.getExternalFilesDir(null)
140
+ constants["documents"] = externalDirectory?.absolutePath ?: reactContext.filesDir.absolutePath
160
141
 
161
- fun invalidate() {
162
- unregisterDownloadReceiver()
163
- }
142
+ constants["TaskRunning"] = TASK_RUNNING
143
+ constants["TaskSuspended"] = TASK_SUSPENDED
144
+ constants["TaskCanceling"] = TASK_CANCELING
145
+ constants["TaskCompleted"] = TASK_COMPLETED
164
146
 
165
- private fun registerDownloadReceiver() {
166
- val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
147
+ // Expose storage type information for debugging/monitoring
148
+ constants["isMMKVAvailable"] = isMMKVAvailable
149
+ constants["storageType"] = if (isMMKVAvailable) "MMKV" else "SharedPreferences"
167
150
 
168
- downloadReceiver = object : BroadcastReceiver() {
169
- override fun onReceive(context: Context, intent: Intent) {
170
- val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
171
- val config = downloadIdToConfig[downloadId]
151
+ return constants
152
+ }
172
153
 
173
- if (config != null) {
174
- val downloadStatus = downloader.checkDownloadStatus(downloadId)
175
- val status = downloadStatus.getInt("status")
176
- val localUri = downloadStatus.getString("localUri")
154
+ fun initialize() {
155
+ ee = reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
156
+ registerDownloadReceiver()
177
157
 
178
- stopTaskProgress(config.id)
179
-
180
- synchronized(sharedLock) {
181
- when (status) {
182
- DownloadManager.STATUS_SUCCESSFUL -> {
183
- onSuccessfulDownload(config, downloadStatus)
184
- }
185
- DownloadManager.STATUS_FAILED -> {
186
- onFailedDownload(config, downloadStatus)
187
- }
188
- }
189
-
190
- if (localUri != null) {
191
- // Prevent memory leaks from MediaScanner.
192
- // Download successful, clean task after media scanning.
193
- val paths = arrayOf(localUri)
194
- MediaScannerConnection.scanFile(context, paths, null) { _, _ ->
195
- stopTask(config.id)
196
- }
197
- } else {
198
- // Download failed, clean task.
199
- stopTask(config.id)
200
- }
201
- }
202
- }
158
+ for ((downloadId, config) in downloadIdToConfig) {
159
+ resumeTasks(downloadId, config)
160
+ }
161
+ }
162
+
163
+ fun invalidate() {
164
+ unregisterDownloadReceiver()
165
+ }
166
+
167
+ private fun registerDownloadReceiver() {
168
+ val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
169
+
170
+ downloadReceiver = object : BroadcastReceiver() {
171
+ override fun onReceive(context: Context, intent: Intent) {
172
+ val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
173
+ val config = downloadIdToConfig[downloadId]
174
+
175
+ if (config != null) {
176
+ val downloadStatus = downloader.checkDownloadStatus(downloadId)
177
+ val status = downloadStatus.getInt("status")
178
+ val localUri = downloadStatus.getString("localUri")
179
+
180
+ stopTaskProgress(config.id)
181
+
182
+ synchronized(sharedLock) {
183
+ when (status) {
184
+ DownloadManager.STATUS_SUCCESSFUL -> {
185
+ onSuccessfulDownload(config, downloadStatus)
186
+ }
187
+ DownloadManager.STATUS_FAILED -> {
188
+ onFailedDownload(config, downloadStatus)
189
+ }
203
190
  }
204
- }
205
191
 
206
- compatRegisterReceiver(reactContext, downloadReceiver!!, filter, true)
207
- }
208
-
209
- // TAKEN FROM
210
- // https://github.com/facebook/react-native/pull/38256/files\#diff-d5e21477eeadeb0c536d5870f487a8528f9a16ae928c397fec7b255805cc8ad3
211
- private fun compatRegisterReceiver(
212
- context: Context,
213
- receiver: BroadcastReceiver,
214
- filter: IntentFilter,
215
- exported: Boolean
216
- ) {
217
- if (Build.VERSION.SDK_INT >= 34 && context.applicationInfo.targetSdkVersion >= 34) {
218
- context.registerReceiver(
219
- receiver,
220
- filter,
221
- if (exported) Context.RECEIVER_EXPORTED else Context.RECEIVER_NOT_EXPORTED
222
- )
223
- } else {
224
- context.registerReceiver(receiver, filter)
192
+ if (localUri != null) {
193
+ // Prevent memory leaks from MediaScanner.
194
+ // Download successful, clean task after media scanning.
195
+ val paths = arrayOf(localUri)
196
+ MediaScannerConnection.scanFile(context, paths, null) { _, _ ->
197
+ stopTask(config.id)
198
+ }
199
+ } else {
200
+ // Download failed, clean task.
201
+ stopTask(config.id)
202
+ }
203
+ }
225
204
  }
205
+ }
226
206
  }
227
207
 
228
- private fun unregisterDownloadReceiver() {
229
- downloadReceiver?.let {
230
- reactContext.unregisterReceiver(it)
231
- downloadReceiver = null
232
- }
208
+ compatRegisterReceiver(reactContext, downloadReceiver!!, filter, true)
209
+ }
210
+
211
+ // TAKEN FROM
212
+ // https://github.com/facebook/react-native/pull/38256/files\#diff-d5e21477eeadeb0c536d5870f487a8528f9a16ae928c397fec7b255805cc8ad3
213
+ private fun compatRegisterReceiver(
214
+ context: Context,
215
+ receiver: BroadcastReceiver,
216
+ filter: IntentFilter,
217
+ exported: Boolean
218
+ ) {
219
+ if (Build.VERSION.SDK_INT >= 34 && context.applicationInfo.targetSdkVersion >= 34) {
220
+ context.registerReceiver(
221
+ receiver,
222
+ filter,
223
+ if (exported) Context.RECEIVER_EXPORTED else Context.RECEIVER_NOT_EXPORTED
224
+ )
225
+ } else {
226
+ context.registerReceiver(receiver, filter)
233
227
  }
228
+ }
234
229
 
235
- private fun resumeTasks(downloadId: Long, config: RNBGDTaskConfig) {
236
- Thread {
237
- try {
238
- var bytesDownloaded: Long = 0
239
- var bytesTotal: Long = 0
240
-
241
- if (!config.reportedBegin) {
242
- val onBeginCallable = OnBegin(config, this::onBeginDownload)
243
- val onBeginFuture = cachedExecutorPool.submit(onBeginCallable)
244
- val onBeginState = onBeginFuture.get()
245
- bytesTotal = onBeginState.expectedBytes
246
-
247
- config.reportedBegin = true
248
- downloadIdToConfig[downloadId] = config
249
- saveDownloadIdToConfigMap()
250
- }
251
-
252
- val onProgressCallable = OnProgress(
253
- config,
254
- downloader,
255
- downloadId,
256
- bytesDownloaded,
257
- bytesTotal,
258
- this::onProgressDownload
259
- )
260
- val onProgressFuture = cachedExecutorPool.submit(onProgressCallable)
261
- configIdToProgressFuture[config.id] = onProgressFuture
262
- } catch (e: Exception) {
263
- Log.e(NAME, "resumeTasks: ${Log.getStackTraceString(e)}")
264
- }
265
- }.start()
230
+ private fun unregisterDownloadReceiver() {
231
+ downloadReceiver?.let {
232
+ reactContext.unregisterReceiver(it)
233
+ downloadReceiver = null
266
234
  }
267
-
268
- private fun removeTaskFromMap(downloadId: Long) {
269
- synchronized(sharedLock) {
270
- val config = downloadIdToConfig[downloadId]
271
-
272
- if (config != null) {
273
- configIdToDownloadId.remove(config.id)
274
- configIdToPercent.remove(config.id)
275
- configIdToLastBytes.remove(config.id)
276
- downloadIdToConfig.remove(downloadId)
277
- saveDownloadIdToConfigMap()
278
- }
279
- }
235
+ }
236
+
237
+ private fun resumeTasks(downloadId: Long, config: RNBGDTaskConfig) {
238
+ Thread {
239
+ try {
240
+ var bytesDownloaded: Long = 0
241
+ var bytesTotal: Long = 0
242
+
243
+ if (!config.reportedBegin) {
244
+ val onBeginCallable = OnBegin(config, this::onBeginDownload)
245
+ val onBeginFuture = cachedExecutorPool.submit(onBeginCallable)
246
+ val onBeginState = onBeginFuture.get()
247
+ bytesTotal = onBeginState.expectedBytes
248
+
249
+ config.reportedBegin = true
250
+ downloadIdToConfig[downloadId] = config
251
+ saveDownloadIdToConfigMap()
252
+ }
253
+
254
+ val onProgressCallable = OnProgress(
255
+ config,
256
+ downloader,
257
+ downloadId,
258
+ bytesDownloaded,
259
+ bytesTotal,
260
+ this::onProgressDownload
261
+ )
262
+ val onProgressFuture = cachedExecutorPool.submit(onProgressCallable)
263
+ configIdToProgressFuture[config.id] = onProgressFuture
264
+ } catch (e: Exception) {
265
+ Log.e(NAME, "resumeTasks: ${Log.getStackTraceString(e)}")
266
+ }
267
+ }.start()
268
+ }
269
+
270
+ private fun removeTaskFromMap(downloadId: Long) {
271
+ synchronized(sharedLock) {
272
+ val config = downloadIdToConfig[downloadId]
273
+
274
+ if (config != null) {
275
+ configIdToDownloadId.remove(config.id)
276
+ configIdToPercent.remove(config.id)
277
+ configIdToLastBytes.remove(config.id)
278
+ downloadIdToConfig.remove(downloadId)
279
+ saveDownloadIdToConfigMap()
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Resolve redirects for a URL up to maxRedirects limit
286
+ * @param originalUrl The original URL to follow
287
+ * @param maxRedirects Maximum number of redirects to follow (0 means no redirect resolution)
288
+ * @param headers Headers to include in redirect resolution requests
289
+ * @return The final resolved URL, or original URL if maxRedirects is 0 or resolution fails
290
+ */
291
+ private fun resolveRedirects(originalUrl: String, maxRedirects: Int, headers: ReadableMap?): String {
292
+ if (maxRedirects <= 0) {
293
+ return originalUrl
280
294
  }
281
295
 
282
- /**
283
- * Resolve redirects for a URL up to maxRedirects limit
284
- * @param originalUrl The original URL to follow
285
- * @param maxRedirects Maximum number of redirects to follow (0 means no redirect resolution)
286
- * @param headers Headers to include in redirect resolution requests
287
- * @return The final resolved URL, or original URL if maxRedirects is 0 or resolution fails
288
- */
289
- private fun resolveRedirects(originalUrl: String, maxRedirects: Int, headers: ReadableMap?): String {
290
- if (maxRedirects <= 0) {
291
- return originalUrl
292
- }
293
-
294
- try {
295
- var currentUrl = originalUrl
296
- var redirectCount = 0
297
-
298
- while (redirectCount < maxRedirects) {
299
- val url = URL(currentUrl)
300
- val connection = url.openConnection() as HttpURLConnection
301
-
302
- // Add headers to the redirect resolution request
303
- headers?.let {
304
- val iterator = it.keySetIterator()
305
- while (iterator.hasNextKey()) {
306
- val headerKey = iterator.nextKey()
307
- connection.setRequestProperty(headerKey, it.getString(headerKey))
308
- }
309
- }
296
+ try {
297
+ var currentUrl = originalUrl
298
+ var redirectCount = 0
310
299
 
311
- // Add default headers for consistency with DownloadManager
312
- connection.setRequestProperty("Connection", "keep-alive")
313
- connection.setRequestProperty("Keep-Alive", KEEP_ALIVE_HEADER_VALUE)
314
- if (!hasUserAgentHeader(headers)) {
315
- connection.setRequestProperty("User-Agent", USER_AGENT)
316
- }
300
+ while (redirectCount < maxRedirects) {
301
+ val url = URL(currentUrl)
302
+ val connection = url.openConnection() as HttpURLConnection
317
303
 
318
- connection.instanceFollowRedirects = false
319
- connection.requestMethod = "HEAD" // Use HEAD to avoid downloading content
320
- connection.connectTimeout = REDIRECT_CONNECT_TIMEOUT_MS
321
- connection.readTimeout = REDIRECT_READ_TIMEOUT_MS
304
+ // Add headers to the redirect resolution request
305
+ headers?.let {
306
+ val iterator = it.keySetIterator()
307
+ while (iterator.hasNextKey()) {
308
+ val headerKey = iterator.nextKey()
309
+ connection.setRequestProperty(headerKey, it.getString(headerKey))
310
+ }
311
+ }
322
312
 
323
- val responseCode = connection.responseCode
313
+ // Add default headers for consistency with DownloadManager
314
+ connection.setRequestProperty("Connection", "keep-alive")
315
+ connection.setRequestProperty("Keep-Alive", KEEP_ALIVE_HEADER_VALUE)
316
+ if (!hasUserAgentHeader(headers)) {
317
+ connection.setRequestProperty("User-Agent", USER_AGENT)
318
+ }
324
319
 
325
- if (responseCode in 300..399) {
326
- // This is a redirect
327
- val location = connection.getHeaderField("Location")
328
- if (location == null) {
329
- Log.w(NAME, "Redirect response without Location header at: $currentUrl")
330
- break
331
- }
320
+ connection.instanceFollowRedirects = false
321
+ connection.requestMethod = "HEAD" // Use HEAD to avoid downloading content
322
+ connection.connectTimeout = REDIRECT_CONNECT_TIMEOUT_MS
323
+ connection.readTimeout = REDIRECT_READ_TIMEOUT_MS
332
324
 
333
- // Handle relative URLs
334
- currentUrl = when {
335
- location.startsWith("/") -> {
336
- val baseUrl = URL(currentUrl)
337
- "${baseUrl.protocol}://${baseUrl.host}$location"
338
- }
339
- !location.startsWith("http") -> {
340
- val baseUrl = URL(currentUrl)
341
- "${baseUrl.protocol}://${baseUrl.host}/$location"
342
- }
343
- else -> location
344
- }
325
+ val responseCode = connection.responseCode
345
326
 
346
- Log.d(NAME, "Redirect ${redirectCount + 1}/$maxRedirects: $currentUrl -> $location")
347
- redirectCount++
348
- } else {
349
- // Not a redirect, we've found the final URL
350
- break
351
- }
327
+ if (responseCode in 300..399) {
328
+ // This is a redirect
329
+ val location = connection.getHeaderField("Location")
330
+ if (location == null) {
331
+ Log.w(NAME, "Redirect response without Location header at: $currentUrl")
332
+ break
333
+ }
352
334
 
353
- connection.disconnect()
335
+ // Handle relative URLs
336
+ currentUrl = when {
337
+ location.startsWith("/") -> {
338
+ val baseUrl = URL(currentUrl)
339
+ "${baseUrl.protocol}://${baseUrl.host}$location"
354
340
  }
355
-
356
- if (redirectCount >= maxRedirects) {
357
- Log.w(
358
- NAME,
359
- "Reached maximum redirects ($maxRedirects) for URL: $originalUrl. Final URL: $currentUrl"
360
- )
361
- } else {
362
- Log.d(NAME, "Resolved URL after $redirectCount redirects: $currentUrl")
341
+ !location.startsWith("http") -> {
342
+ val baseUrl = URL(currentUrl)
343
+ "${baseUrl.protocol}://${baseUrl.host}/$location"
363
344
  }
345
+ else -> location
346
+ }
364
347
 
365
- return currentUrl
366
-
367
- } catch (e: Exception) {
368
- Log.e(NAME, "Failed to resolve redirects for URL: $originalUrl. Error: ${e.message}")
369
- // Return original URL if redirect resolution fails
370
- return originalUrl
371
- }
372
- }
373
-
374
- fun download(options: ReadableMap) {
375
- val id = options.getString("id")
376
- var url = options.getString("url")
377
- val destination = options.getString("destination")
378
- val headers = options.getMap("headers")
379
- val metadata = options.getString("metadata")
380
- val notificationTitle = options.getString("notificationTitle")
381
-
382
- val progressIntervalScope = options.getInt("progressInterval")
383
- if (progressIntervalScope > 0) {
384
- progressInterval = progressIntervalScope
385
- saveConfigMap()
386
- }
387
-
388
- val progressMinBytesScope = options.getDouble("progressMinBytes")
389
- if (progressMinBytesScope > 0) {
390
- progressMinBytes = progressMinBytesScope.toLong()
391
- saveConfigMap()
392
- }
393
-
394
- val isAllowedOverRoaming = options.getBoolean("isAllowedOverRoaming")
395
- val isAllowedOverMetered = options.getBoolean("isAllowedOverMetered")
396
- val isNotificationVisible = options.getBoolean("isNotificationVisible")
397
-
398
- // Get maxRedirects parameter
399
- var maxRedirects = 0
400
- if (options.hasKey("maxRedirects")) {
401
- maxRedirects = options.getInt("maxRedirects")
402
- }
403
-
404
- if (id == null || url == null || destination == null) {
405
- Log.e(NAME, "download: id, url and destination must be set.")
406
- return
348
+ Log.d(NAME, "Redirect ${redirectCount + 1}/$maxRedirects: $currentUrl -> $location")
349
+ redirectCount++
350
+ } else {
351
+ // Not a redirect, we've found the final URL
352
+ break
407
353
  }
408
354
 
409
- // Resolve redirects if maxRedirects is specified
410
- if (maxRedirects > 0) {
411
- Log.d(NAME, "Resolving redirects for URL: $url (maxRedirects: $maxRedirects)")
412
- url = resolveRedirects(url, maxRedirects, headers)
413
- Log.d(NAME, "Final resolved URL: $url")
414
- }
355
+ connection.disconnect()
356
+ }
415
357
 
416
- val request = DownloadManager.Request(Uri.parse(url))
417
- request.setAllowedOverRoaming(isAllowedOverRoaming)
418
- request.setAllowedOverMetered(isAllowedOverMetered)
419
- request.setNotificationVisibility(
420
- if (isNotificationVisible) DownloadManager.Request.VISIBILITY_VISIBLE
421
- else DownloadManager.Request.VISIBILITY_HIDDEN
358
+ if (redirectCount >= maxRedirects) {
359
+ Log.w(
360
+ NAME,
361
+ "Reached maximum redirects ($maxRedirects) for URL: $originalUrl. Final URL: $currentUrl"
422
362
  )
423
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
424
- request.setRequiresCharging(false)
425
- }
426
-
427
- notificationTitle?.let { request.setTitle(it) }
363
+ } else {
364
+ Log.d(NAME, "Resolved URL after $redirectCount redirects: $currentUrl")
365
+ }
428
366
 
429
- // Add default headers to improve connection handling for slow-responding URLs
430
- // These headers encourage longer connections and help prevent premature timeouts
431
- request.addRequestHeader("Connection", "keep-alive")
432
- request.addRequestHeader("Keep-Alive", KEEP_ALIVE_HEADER_VALUE)
433
-
434
- // Add a proper User-Agent to improve server compatibility
435
- if (!hasUserAgentHeader(headers)) {
436
- request.addRequestHeader("User-Agent", USER_AGENT)
437
- }
367
+ return currentUrl
438
368
 
439
- headers?.let {
440
- val iterator = it.keySetIterator()
441
- while (iterator.hasNextKey()) {
442
- val headerKey = iterator.nextKey()
443
- request.addRequestHeader(headerKey, it.getString(headerKey))
444
- }
445
- }
369
+ } catch (e: Exception) {
370
+ Log.e(NAME, "Failed to resolve redirects for URL: $originalUrl. Error: ${e.message}")
371
+ // Return original URL if redirect resolution fails
372
+ return originalUrl
373
+ }
374
+ }
375
+
376
+ fun download(options: ReadableMap) {
377
+ val id = options.getString("id")
378
+ var url = options.getString("url")
379
+ val destination = options.getString("destination")
380
+ val headers = options.getMap("headers")
381
+ // Handle metadata - it should be a string, but handle object case defensively
382
+ val metadata = if (options.hasKey("metadata")) {
383
+ when (options.getType("metadata")) {
384
+ ReadableType.String -> options.getString("metadata")
385
+ ReadableType.Map -> {
386
+ // If passed as object, convert to JSON string
387
+ try {
388
+ val map = options.getMap("metadata")
389
+ Arguments.toBundle(map)?.let {
390
+ JSONObject(it.toString()).toString()
391
+ } ?: "{}"
392
+ } catch (e: Exception) {
393
+ Log.w(NAME, "Failed to convert metadata map to string: ${e.message}")
394
+ "{}"
395
+ }
396
+ }
397
+ else -> null
398
+ }
399
+ } else null
400
+ val notificationTitle = options.getString("notificationTitle")
401
+
402
+ val progressIntervalScope = options.getInt("progressInterval")
403
+ if (progressIntervalScope > 0) {
404
+ progressInterval = progressIntervalScope
405
+ saveConfigMap()
406
+ }
446
407
 
447
- val uuid = (System.currentTimeMillis() and 0xfffffff).toInt()
448
- val extension = MimeTypeMap.getFileExtensionFromUrl(destination)
449
- val filename = "$uuid.$extension"
450
- request.setDestinationInExternalFilesDir(reactContext, null, filename)
408
+ val progressMinBytesScope = options.getDouble("progressMinBytes")
409
+ if (progressMinBytesScope > 0) {
410
+ progressMinBytes = progressMinBytesScope.toLong()
411
+ saveConfigMap()
412
+ }
451
413
 
452
- val downloadId = downloader.download(request)
453
- val config = RNBGDTaskConfig(id, url, destination, metadata ?: "{}", notificationTitle)
414
+ val isAllowedOverRoaming = options.getBoolean("isAllowedOverRoaming")
415
+ val isAllowedOverMetered = options.getBoolean("isAllowedOverMetered")
416
+ val isNotificationVisible = options.getBoolean("isNotificationVisible")
454
417
 
455
- synchronized(sharedLock) {
456
- configIdToDownloadId[id] = downloadId
457
- configIdToPercent[id] = 0.0
458
- downloadIdToConfig[downloadId] = config
459
- saveDownloadIdToConfigMap()
460
- resumeTasks(downloadId, config)
461
- }
418
+ // Get maxRedirects parameter
419
+ var maxRedirects = 0
420
+ if (options.hasKey("maxRedirects")) {
421
+ maxRedirects = options.getInt("maxRedirects")
462
422
  }
463
423
 
464
- // Pause functionality is not supported by Android DownloadManager.
465
- // This method will throw an UnsupportedOperationException to clearly indicate
466
- // that pause is not available on Android platform.
467
- fun pauseTask(configId: String) {
468
- synchronized(sharedLock) {
469
- val downloadId = configIdToDownloadId[configId]
470
- if (downloadId != null) {
471
- try {
472
- downloader.pause(downloadId)
473
- } catch (e: UnsupportedOperationException) {
474
- Log.w("RNBackgroundDownloader", "pauseTask: ${e.message}")
475
- // Note: We don't rethrow the exception to avoid crashing the JS thread.
476
- // The limitation is already documented and expected.
477
- }
478
- }
479
- }
424
+ if (id == null || url == null || destination == null) {
425
+ Log.e(NAME, "download: id, url and destination must be set.")
426
+ return
480
427
  }
481
428
 
482
- // Resume functionality is not supported by Android DownloadManager.
483
- // This method will throw an UnsupportedOperationException to clearly indicate
484
- // that resume is not available on Android platform.
485
- fun resumeTask(configId: String) {
486
- synchronized(sharedLock) {
487
- val downloadId = configIdToDownloadId[configId]
488
- if (downloadId != null) {
489
- try {
490
- downloader.resume(downloadId)
491
- } catch (e: UnsupportedOperationException) {
492
- Log.w("RNBackgroundDownloader", "resumeTask: ${e.message}")
493
- // Note: We don't rethrow the exception to avoid crashing the JS thread.
494
- // The limitation is already documented and expected.
495
- }
496
- }
497
- }
429
+ // Resolve redirects if maxRedirects is specified
430
+ if (maxRedirects > 0) {
431
+ Log.d(NAME, "Resolving redirects for URL: $url (maxRedirects: $maxRedirects)")
432
+ url = resolveRedirects(url, maxRedirects, headers)
433
+ Log.d(NAME, "Final resolved URL: $url")
498
434
  }
499
435
 
500
- fun stopTask(configId: String) {
501
- synchronized(sharedLock) {
502
- val downloadId = configIdToDownloadId[configId]
503
- if (downloadId != null) {
504
- stopTaskProgress(configId)
505
- removeTaskFromMap(downloadId)
506
- downloader.cancel(downloadId)
507
- }
508
- }
436
+ val request = DownloadManager.Request(Uri.parse(url))
437
+ request.setAllowedOverRoaming(isAllowedOverRoaming)
438
+ request.setAllowedOverMetered(isAllowedOverMetered)
439
+ request.setNotificationVisibility(
440
+ if (isNotificationVisible) DownloadManager.Request.VISIBILITY_VISIBLE
441
+ else DownloadManager.Request.VISIBILITY_HIDDEN
442
+ )
443
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
444
+ request.setRequiresCharging(false)
509
445
  }
510
446
 
511
- fun completeHandler(configId: String) {
512
- // Firebase Performance compatibility: Add defensive programming to prevent crashes
513
- // when Firebase Performance SDK is installed and uses bytecode instrumentation
447
+ notificationTitle?.let { request.setTitle(it) }
514
448
 
515
- Log.d(NAME, "completeHandler called with configId: $configId")
449
+ // Add default headers to improve connection handling for slow-responding URLs
450
+ // These headers encourage longer connections and help prevent premature timeouts
451
+ request.addRequestHeader("Connection", "keep-alive")
452
+ request.addRequestHeader("Keep-Alive", KEEP_ALIVE_HEADER_VALUE)
516
453
 
517
- // Defensive programming: Validate parameters
518
- if (configId.isEmpty()) {
519
- Log.w(NAME, "completeHandler: Invalid configId provided")
520
- return
521
- }
522
-
523
- try {
524
- // Currently this method doesn't have any implementation on Android
525
- // as completion handlers are handled differently than iOS.
526
- // This defensive structure ensures Firebase Performance compatibility.
527
- Log.d(NAME, "completeHandler executed successfully for configId: $configId")
528
-
529
- } catch (e: Exception) {
530
- // Catch any potential exceptions that might be thrown due to Firebase Performance
531
- // bytecode instrumentation interfering with method dispatch
532
- Log.e(NAME, "completeHandler: Exception occurred: ${Log.getStackTraceString(e)}")
533
- }
454
+ // Add a proper User-Agent to improve server compatibility
455
+ if (!hasUserAgentHeader(headers)) {
456
+ request.addRequestHeader("User-Agent", USER_AGENT)
534
457
  }
535
458
 
536
- fun getExistingDownloadTasks(promise: Promise) {
537
- val foundTasks = Arguments.createArray()
538
-
539
- synchronized(sharedLock) {
540
- val query = DownloadManager.Query()
541
- try {
542
- downloader.downloadManager.query(query)?.use { cursor ->
543
- if (cursor.moveToFirst()) {
544
- do {
545
- val downloadStatus = downloader.getDownloadStatus(cursor)
546
- val downloadId = downloadStatus.getString("downloadId")?.toLong()
547
-
548
- if (downloadId != null && downloadIdToConfig.containsKey(downloadId)) {
549
- val config = downloadIdToConfig[downloadId]
550
-
551
- if (config != null) {
552
- val status = downloadStatus.getInt("status")
553
- // Handle completed downloads that weren't processed
554
- if (status == DownloadManager.STATUS_SUCCESSFUL) {
555
- val localUri = downloadStatus.getString("localUri")
556
- if (localUri != null) {
557
- try {
558
- val future = setFileChangesBeforeCompletion(localUri, config.destination)
559
- future.get()
560
- } catch (e: Exception) {
561
- Log.e(NAME, "Error moving completed download file: ${e.message}")
562
- // Continue with normal processing even if file move fails
563
- }
564
- }
565
- }
566
-
567
- val params = Arguments.createMap()
568
-
569
- params.putString("id", config.id)
570
- params.putString("metadata", config.metadata)
571
- val state = stateMap[status] ?: 0
572
- params.putInt("state", state)
573
-
574
- val bytesDownloaded = downloadStatus.getDouble("bytesDownloaded")
575
- params.putDouble("bytesDownloaded", bytesDownloaded)
576
- val bytesTotal = downloadStatus.getDouble("bytesTotal")
577
- params.putDouble("bytesTotal", bytesTotal)
578
- val percent = if (bytesTotal > 0) bytesDownloaded / bytesTotal else 0.0
579
-
580
- foundTasks.pushMap(params)
581
- configIdToDownloadId[config.id] = downloadId
582
- configIdToPercent[config.id] = percent
583
- }
584
- } else if (downloadId != null) {
585
- downloader.cancel(downloadId)
586
- }
587
- } while (cursor.moveToNext())
588
- }
589
- }
590
- } catch (e: Exception) {
591
- Log.e(NAME, "getExistingDownloadTasks: ${Log.getStackTraceString(e)}")
592
- }
593
- }
594
-
595
- promise.resolve(foundTasks)
459
+ headers?.let {
460
+ val iterator = it.keySetIterator()
461
+ while (iterator.hasNextKey()) {
462
+ val headerKey = iterator.nextKey()
463
+ request.addRequestHeader(headerKey, it.getString(headerKey))
464
+ }
596
465
  }
597
466
 
598
- fun addListener(eventName: String) {
599
- }
467
+ val uuid = (System.currentTimeMillis() and 0xfffffff).toInt()
468
+ val extension = MimeTypeMap.getFileExtensionFromUrl(destination)
469
+ val filename = "$uuid.$extension"
470
+ request.setDestinationInExternalFilesDir(reactContext, null, filename)
600
471
 
601
- fun removeListeners(count: Int) {
602
- }
472
+ val downloadId = downloader.download(request)
473
+ val config = RNBGDTaskConfig(id, url, destination, metadata ?: "{}", notificationTitle)
603
474
 
604
- private fun onBeginDownload(configId: String, headers: WritableMap, expectedBytes: Long) {
605
- val params = Arguments.createMap()
606
- params.putString("id", configId)
607
- params.putMap("headers", headers)
608
- params.putDouble("expectedBytes", expectedBytes.toDouble())
609
- ee.emit("downloadBegin", params)
475
+ synchronized(sharedLock) {
476
+ configIdToDownloadId[id] = downloadId
477
+ configIdToPercent[id] = 0.0
478
+ downloadIdToConfig[downloadId] = config
479
+ saveDownloadIdToConfigMap()
480
+ resumeTasks(downloadId, config)
610
481
  }
611
-
612
- private fun onProgressDownload(configId: String, bytesDownloaded: Long, bytesTotal: Long) {
613
- val existPercent = configIdToPercent[configId]
614
- val existLastBytes = configIdToLastBytes[configId]
615
- val prevPercent = existPercent ?: 0.0
616
- val prevBytes = existLastBytes ?: 0L
617
- val percent = if (bytesTotal > 0.0) bytesDownloaded.toDouble() / bytesTotal else 0.0
618
-
619
- // Check if we should report progress based on percentage OR bytes threshold
620
- val percentThresholdMet = percent - prevPercent > PROGRESS_REPORT_THRESHOLD
621
- // Only check bytes threshold if progressMinBytes > 0
622
- val bytesThresholdMet = progressMinBytes > 0 && (bytesDownloaded - prevBytes >= progressMinBytes)
623
-
624
- // Report progress if either threshold is met, or if total bytes unknown (for realtime streams)
625
- if (percentThresholdMet || bytesThresholdMet || bytesTotal <= 0) {
626
- val params = Arguments.createMap()
627
- params.putString("id", configId)
628
- params.putDouble("bytesDownloaded", bytesDownloaded.toDouble())
629
- params.putDouble("bytesTotal", bytesTotal.toDouble())
630
- progressReports[configId] = params
631
- configIdToPercent[configId] = percent
632
- configIdToLastBytes[configId] = bytesDownloaded
482
+ }
483
+
484
+ // Pause functionality is not supported by Android DownloadManager.
485
+ // This method will throw an UnsupportedOperationException to clearly indicate
486
+ // that pause is not available on Android platform.
487
+ fun pauseTask(configId: String) {
488
+ synchronized(sharedLock) {
489
+ val downloadId = configIdToDownloadId[configId]
490
+ if (downloadId != null) {
491
+ try {
492
+ downloader.pause(downloadId)
493
+ } catch (e: UnsupportedOperationException) {
494
+ Log.w("RNBackgroundDownloader", "pauseTask: ${e.message}")
495
+ // Note: We don't rethrow the exception to avoid crashing the JS thread.
496
+ // The limitation is already documented and expected.
633
497
  }
634
-
635
- val now = Date()
636
- val isReportTimeDifference = now.time - lastProgressReportedAt.time > progressInterval
637
- val isReportNotEmpty = progressReports.isNotEmpty()
638
- if (isReportTimeDifference && isReportNotEmpty) {
639
- // Extra steps to avoid map always consumed errors.
640
- val reportsList = progressReports.values.toList()
641
- val reportsArray = Arguments.createArray()
642
- for (report in reportsList) {
643
- reportsArray.pushMap(report.copy())
644
- }
645
- ee.emit("downloadProgress", reportsArray)
646
- lastProgressReportedAt = now
647
- progressReports.clear()
498
+ }
499
+ }
500
+ }
501
+
502
+ // Resume functionality is not supported by Android DownloadManager.
503
+ // This method will throw an UnsupportedOperationException to clearly indicate
504
+ // that resume is not available on Android platform.
505
+ fun resumeTask(configId: String) {
506
+ synchronized(sharedLock) {
507
+ val downloadId = configIdToDownloadId[configId]
508
+ if (downloadId != null) {
509
+ try {
510
+ downloader.resume(downloadId)
511
+ } catch (e: UnsupportedOperationException) {
512
+ Log.w("RNBackgroundDownloader", "resumeTask: ${e.message}")
513
+ // Note: We don't rethrow the exception to avoid crashing the JS thread.
514
+ // The limitation is already documented and expected.
648
515
  }
516
+ }
517
+ }
518
+ }
519
+
520
+ fun stopTask(configId: String) {
521
+ synchronized(sharedLock) {
522
+ val downloadId = configIdToDownloadId[configId]
523
+ if (downloadId != null) {
524
+ stopTaskProgress(configId)
525
+ removeTaskFromMap(downloadId)
526
+ downloader.cancel(downloadId)
527
+ }
649
528
  }
529
+ }
650
530
 
651
- private fun onSuccessfulDownload(config: RNBGDTaskConfig, downloadStatus: WritableMap) {
652
- val localUri = downloadStatus.getString("localUri")
531
+ fun completeHandler(configId: String) {
532
+ // Firebase Performance compatibility: Add defensive programming to prevent crashes
533
+ // when Firebase Performance SDK is installed and uses bytecode instrumentation
653
534
 
654
- // TODO: We need to move it to a more suitable location.
655
- // Maybe somewhere in downloadReceiver?
656
- // Feedback if any error occurs after downloading the file.
657
- try {
658
- val future = setFileChangesBeforeCompletion(localUri!!, config.destination)
659
- future.get()
660
- } catch (e: Exception) {
661
- val newDownloadStatus = Arguments.createMap()
662
- newDownloadStatus.putString("downloadId", downloadStatus.getString("downloadId"))
663
- newDownloadStatus.putInt("status", DownloadManager.STATUS_FAILED)
664
- newDownloadStatus.putInt("reason", DownloadManager.ERROR_UNKNOWN)
665
- newDownloadStatus.putString("reasonText", e.message)
666
- onFailedDownload(config, newDownloadStatus)
667
- return
668
- }
535
+ Log.d(NAME, "completeHandler called with configId: $configId")
669
536
 
670
- val params = Arguments.createMap()
671
- params.putString("id", config.id)
672
- params.putString("location", config.destination)
673
- params.putDouble("bytesDownloaded", downloadStatus.getDouble("bytesDownloaded"))
674
- params.putDouble("bytesTotal", downloadStatus.getDouble("bytesTotal"))
675
- ee.emit("downloadComplete", params)
537
+ // Defensive programming: Validate parameters
538
+ if (configId.isEmpty()) {
539
+ Log.w(NAME, "completeHandler: Invalid configId provided")
540
+ return
676
541
  }
677
542
 
678
- private fun onFailedDownload(config: RNBGDTaskConfig, downloadStatus: WritableMap) {
679
- Log.e(
680
- NAME, "onFailedDownload: " +
681
- "${downloadStatus.getInt("status")}:" +
682
- "${downloadStatus.getInt("reason")}:" +
683
- downloadStatus.getString("reasonText")
684
- )
543
+ try {
544
+ // Currently this method doesn't have any implementation on Android
545
+ // as completion handlers are handled differently than iOS.
546
+ // This defensive structure ensures Firebase Performance compatibility.
547
+ Log.d(NAME, "completeHandler executed successfully for configId: $configId")
685
548
 
686
- val reason = downloadStatus.getInt("reason")
687
- var reasonText = downloadStatus.getString("reasonText")
549
+ } catch (e: Exception) {
550
+ // Catch any potential exceptions that might be thrown due to Firebase Performance
551
+ // bytecode instrumentation interfering with method dispatch
552
+ Log.e(NAME, "completeHandler: Exception occurred: ${Log.getStackTraceString(e)}")
553
+ }
554
+ }
688
555
 
689
- // Enhanced handling for ERROR_CANNOT_RESUME (1008)
690
- if (reason == DownloadManager.ERROR_CANNOT_RESUME) {
691
- Log.w(
692
- NAME, "ERROR_CANNOT_RESUME detected for download: ${config.id}" +
693
- ". This is a known Android DownloadManager issue with larger files. " +
694
- "Consider restarting the download or using smaller file segments."
695
- )
556
+ fun getExistingDownloadTasks(promise: Promise) {
557
+ val foundTasks = Arguments.createArray()
696
558
 
697
- // Clean up the failed download entry
698
- removeTaskFromMap(downloadStatus.getString("downloadId")?.toLong() ?: 0L)
559
+ synchronized(sharedLock) {
560
+ val query = DownloadManager.Query()
561
+ try {
562
+ downloader.downloadManager.query(query)?.use { cursor ->
563
+ if (cursor.moveToFirst()) {
564
+ do {
565
+ val downloadStatus = downloader.getDownloadStatus(cursor)
566
+ val downloadId = downloadStatus.getString("downloadId")?.toLong()
699
567
 
700
- // Provide more helpful error message
701
- reasonText =
702
- "ERROR_CANNOT_RESUME - Unable to resume download. This may occur with large files due to Android DownloadManager limitations. Try restarting the download."
703
- }
568
+ if (downloadId != null && downloadIdToConfig.containsKey(downloadId)) {
569
+ val config = downloadIdToConfig[downloadId]
704
570
 
705
- val params = Arguments.createMap()
706
- params.putString("id", config.id)
707
- params.putInt("errorCode", reason)
708
- params.putString("error", reasonText)
709
- ee.emit("downloadFailed", params)
710
- }
711
-
712
- private fun saveDownloadIdToConfigMap() {
713
- synchronized(sharedLock) {
714
- try {
715
- val gson = Gson()
716
- // Create a defensive copy to prevent ConcurrentModificationException
717
- // when Gson iterates over the map while another thread modifies it
718
- val mapCopy = HashMap(downloadIdToConfig)
719
- val str = gson.toJson(mapCopy)
720
-
721
- if (isMMKVAvailable && mmkv != null) {
722
- mmkv!!.encode("${NAME}_downloadIdToConfig", str)
723
- Log.d(NAME, "Saved download config to MMKV")
724
- } else {
725
- sharedPreferences.edit()
726
- .putString("${NAME}_downloadIdToConfig", str)
727
- .apply()
728
- Log.d(NAME, "Saved download config to SharedPreferences fallback")
729
- }
730
- } catch (e: Exception) {
731
- Log.e(NAME, "Failed to save download config: ${e.message}")
732
- }
733
- }
734
- }
571
+ if (config != null) {
572
+ val status = downloadStatus.getInt("status")
573
+ // Handle completed downloads that weren't processed
574
+ if (status == DownloadManager.STATUS_SUCCESSFUL) {
575
+ val localUri = downloadStatus.getString("localUri")
576
+ if (localUri != null) {
577
+ try {
578
+ val future = setFileChangesBeforeCompletion(localUri, config.destination)
579
+ future.get()
580
+ } catch (e: Exception) {
581
+ Log.e(NAME, "Error moving completed download file: ${e.message}")
582
+ // Continue with normal processing even if file move fails
583
+ }
584
+ }
585
+ }
735
586
 
736
- private fun loadDownloadIdToConfigMap() {
737
- synchronized(sharedLock) {
738
- downloadIdToConfig = mutableMapOf()
587
+ val params = Arguments.createMap()
739
588
 
740
- try {
741
- val str = if (isMMKVAvailable && mmkv != null) {
742
- mmkv!!.decodeString("${NAME}_downloadIdToConfig")?.also {
743
- Log.d(NAME, "Loaded download config from MMKV")
744
- }
745
- } else {
746
- sharedPreferences.getString("${NAME}_downloadIdToConfig", null)?.also {
747
- Log.d(NAME, "Loaded download config from SharedPreferences fallback")
748
- }
749
- }
589
+ params.putString("id", config.id)
590
+ params.putString("metadata", config.metadata)
591
+ val state = stateMap[status] ?: 0
592
+ params.putInt("state", state)
750
593
 
751
- if (str != null) {
752
- val gson = Gson()
753
- val mapType = object : TypeToken<Map<Long, RNBGDTaskConfig>>() {}.type
754
- downloadIdToConfig = gson.fromJson(str, mapType)
755
- } else {
756
- Log.d(NAME, "No existing download config found, starting with empty map")
757
- }
758
- } catch (e: Exception) {
759
- Log.e(NAME, "Failed to load download config: ${e.message}")
760
- downloadIdToConfig = mutableMapOf()
761
- }
762
- }
763
- }
594
+ val bytesDownloaded = downloadStatus.getDouble("bytesDownloaded")
595
+ params.putDouble("bytesDownloaded", bytesDownloaded)
596
+ val bytesTotal = downloadStatus.getDouble("bytesTotal")
597
+ params.putDouble("bytesTotal", bytesTotal)
598
+ val percent = if (bytesTotal > 0) bytesDownloaded / bytesTotal else 0.0
764
599
 
765
- private fun saveConfigMap() {
766
- synchronized(sharedLock) {
767
- try {
768
- if (isMMKVAvailable && mmkv != null) {
769
- mmkv!!.encode("${NAME}_progressInterval", progressInterval)
770
- mmkv!!.encode("${NAME}_progressMinBytes", progressMinBytes)
771
- Log.d(NAME, "Saved config to MMKV")
772
- } else {
773
- sharedPreferences.edit()
774
- .putInt("${NAME}_progressInterval", progressInterval)
775
- .putLong("${NAME}_progressMinBytes", progressMinBytes)
776
- .apply()
777
- Log.d(NAME, "Saved config to SharedPreferences fallback")
600
+ foundTasks.pushMap(params)
601
+ configIdToDownloadId[config.id] = downloadId
602
+ configIdToPercent[config.id] = percent
778
603
  }
779
- } catch (e: Exception) {
780
- Log.e(NAME, "Failed to save config: ${e.message}")
781
- }
604
+ } else if (downloadId != null) {
605
+ downloader.cancel(downloadId)
606
+ }
607
+ } while (cursor.moveToNext())
608
+ }
782
609
  }
610
+ } catch (e: Exception) {
611
+ Log.e(NAME, "getExistingDownloadTasks: ${Log.getStackTraceString(e)}")
612
+ }
783
613
  }
784
614
 
785
- private fun loadConfigMap() {
786
- synchronized(sharedLock) {
787
- try {
788
- if (isMMKVAvailable && mmkv != null) {
789
- val progressIntervalScope = mmkv!!.decodeInt("${NAME}_progressInterval")
790
- if (progressIntervalScope > 0) {
791
- progressInterval = progressIntervalScope
792
- }
793
- val progressMinBytesScope = mmkv!!.decodeLong("${NAME}_progressMinBytes")
794
- if (progressMinBytesScope > 0) {
795
- progressMinBytes = progressMinBytesScope
796
- }
797
- Log.d(NAME, "Loaded config from MMKV")
798
- } else {
799
- val progressIntervalScope = sharedPreferences.getInt("${NAME}_progressInterval", 0)
800
- if (progressIntervalScope > 0) {
801
- progressInterval = progressIntervalScope
802
- }
803
- val progressMinBytesScope = sharedPreferences.getLong("${NAME}_progressMinBytes", 0)
804
- if (progressMinBytesScope > 0) {
805
- progressMinBytes = progressMinBytesScope
806
- }
807
- Log.d(NAME, "Loaded config from SharedPreferences fallback")
808
- }
809
- } catch (e: Exception) {
810
- Log.e(NAME, "Failed to load config: ${e.message}")
811
- }
812
- }
615
+ promise.resolve(foundTasks)
616
+ }
617
+
618
+ fun addListener(eventName: String) {
619
+ }
620
+
621
+ fun removeListeners(count: Int) {
622
+ }
623
+
624
+ private fun onBeginDownload(configId: String, headers: WritableMap, expectedBytes: Long) {
625
+ val params = Arguments.createMap()
626
+ params.putString("id", configId)
627
+ params.putMap("headers", headers)
628
+ params.putDouble("expectedBytes", expectedBytes.toDouble())
629
+ ee.emit("downloadBegin", params)
630
+ }
631
+
632
+ private fun onProgressDownload(configId: String, bytesDownloaded: Long, bytesTotal: Long) {
633
+ val existPercent = configIdToPercent[configId]
634
+ val existLastBytes = configIdToLastBytes[configId]
635
+ val prevPercent = existPercent ?: 0.0
636
+ val prevBytes = existLastBytes ?: 0L
637
+ val percent = if (bytesTotal > 0.0) bytesDownloaded.toDouble() / bytesTotal else 0.0
638
+
639
+ // Check if we should report progress based on percentage OR bytes threshold
640
+ val percentThresholdMet = percent - prevPercent > PROGRESS_REPORT_THRESHOLD
641
+ // Only check bytes threshold if progressMinBytes > 0
642
+ val bytesThresholdMet = progressMinBytes > 0 && (bytesDownloaded - prevBytes >= progressMinBytes)
643
+
644
+ // Report progress if either threshold is met, or if total bytes unknown (for realtime streams)
645
+ if (percentThresholdMet || bytesThresholdMet || bytesTotal <= 0) {
646
+ val params = Arguments.createMap()
647
+ params.putString("id", configId)
648
+ params.putDouble("bytesDownloaded", bytesDownloaded.toDouble())
649
+ params.putDouble("bytesTotal", bytesTotal.toDouble())
650
+ progressReports[configId] = params
651
+ configIdToPercent[configId] = percent
652
+ configIdToLastBytes[configId] = bytesDownloaded
813
653
  }
814
654
 
815
- private fun stopTaskProgress(configId: String) {
816
- val onProgressFuture = configIdToProgressFuture[configId]
817
- if (onProgressFuture != null) {
818
- onProgressFuture.cancel(true)
819
- configIdToPercent.remove(configId)
820
- configIdToLastBytes.remove(configId)
821
- configIdToProgressFuture.remove(configId)
822
- }
655
+ val now = Date()
656
+ val isReportTimeDifference = now.time - lastProgressReportedAt.time > progressInterval
657
+ val isReportNotEmpty = progressReports.isNotEmpty()
658
+ if (isReportTimeDifference && isReportNotEmpty) {
659
+ // Extra steps to avoid map always consumed errors.
660
+ val reportsList = progressReports.values.toList()
661
+ val reportsArray = Arguments.createArray()
662
+ for (report in reportsList) {
663
+ reportsArray.pushMap(report.copy())
664
+ }
665
+ ee.emit("downloadProgress", reportsArray)
666
+ lastProgressReportedAt = now
667
+ progressReports.clear()
668
+ }
669
+ }
670
+
671
+ private fun onSuccessfulDownload(config: RNBGDTaskConfig, downloadStatus: WritableMap) {
672
+ val localUri = downloadStatus.getString("localUri")
673
+
674
+ // TODO: We need to move it to a more suitable location.
675
+ // Maybe somewhere in downloadReceiver?
676
+ // Feedback if any error occurs after downloading the file.
677
+ try {
678
+ val future = setFileChangesBeforeCompletion(localUri!!, config.destination)
679
+ future.get()
680
+ } catch (e: Exception) {
681
+ val newDownloadStatus = Arguments.createMap()
682
+ newDownloadStatus.putString("downloadId", downloadStatus.getString("downloadId"))
683
+ newDownloadStatus.putInt("status", DownloadManager.STATUS_FAILED)
684
+ newDownloadStatus.putInt("reason", DownloadManager.ERROR_UNKNOWN)
685
+ newDownloadStatus.putString("reasonText", e.message)
686
+ onFailedDownload(config, newDownloadStatus)
687
+ return
823
688
  }
824
689
 
825
- private fun setFileChangesBeforeCompletion(targetSrc: String, destinationSrc: String): Future<Boolean> {
826
- return fixedExecutorPool.submit<Boolean> {
827
- val file = File(targetSrc)
828
- val destination = File(destinationSrc)
829
- var destinationParent: File? = null
830
- try {
831
- if (file.exists()) {
832
- FileUtils.rm(destination)
833
- destinationParent = FileUtils.mkdirParent(destination)
834
- FileUtils.mv(file, destination)
835
- }
836
- } catch (e: Exception) {
837
- FileUtils.rm(file)
838
- FileUtils.rm(destination)
839
- FileUtils.rm(destinationParent)
840
- throw Exception(e)
841
- }
690
+ val params = Arguments.createMap()
691
+ params.putString("id", config.id)
692
+ params.putString("location", config.destination)
693
+ params.putDouble("bytesDownloaded", downloadStatus.getDouble("bytesDownloaded"))
694
+ params.putDouble("bytesTotal", downloadStatus.getDouble("bytesTotal"))
695
+ ee.emit("downloadComplete", params)
696
+ }
697
+
698
+ private fun onFailedDownload(config: RNBGDTaskConfig, downloadStatus: WritableMap) {
699
+ Log.e(
700
+ NAME, "onFailedDownload: " +
701
+ "${downloadStatus.getInt("status")}:" +
702
+ "${downloadStatus.getInt("reason")}:" +
703
+ downloadStatus.getString("reasonText")
704
+ )
705
+
706
+ val reason = downloadStatus.getInt("reason")
707
+ var reasonText = downloadStatus.getString("reasonText")
708
+
709
+ // Enhanced handling for ERROR_CANNOT_RESUME (1008)
710
+ if (reason == DownloadManager.ERROR_CANNOT_RESUME) {
711
+ Log.w(
712
+ NAME, "ERROR_CANNOT_RESUME detected for download: ${config.id}" +
713
+ ". This is a known Android DownloadManager issue with larger files. " +
714
+ "Consider restarting the download or using smaller file segments."
715
+ )
716
+
717
+ // Clean up the failed download entry
718
+ removeTaskFromMap(downloadStatus.getString("downloadId")?.toLong() ?: 0L)
719
+
720
+ // Provide more helpful error message
721
+ reasonText =
722
+ "ERROR_CANNOT_RESUME - Unable to resume download. This may occur with large files due to Android DownloadManager limitations. Try restarting the download."
723
+ }
842
724
 
843
- true
844
- }
725
+ val params = Arguments.createMap()
726
+ params.putString("id", config.id)
727
+ params.putInt("errorCode", reason)
728
+ params.putString("error", reasonText)
729
+ ee.emit("downloadFailed", params)
730
+ }
731
+
732
+ private fun saveDownloadIdToConfigMap() {
733
+ synchronized(sharedLock) {
734
+ try {
735
+ val gson = Gson()
736
+ // Create a defensive copy to prevent ConcurrentModificationException
737
+ // when Gson iterates over the map while another thread modifies it
738
+ val mapCopy = HashMap(downloadIdToConfig)
739
+ val str = gson.toJson(mapCopy)
740
+
741
+ if (isMMKVAvailable && mmkv != null) {
742
+ mmkv!!.encode("${NAME}_downloadIdToConfig", str)
743
+ Log.d(NAME, "Saved download config to MMKV")
744
+ } else {
745
+ sharedPreferences.edit()
746
+ .putString("${NAME}_downloadIdToConfig", str)
747
+ .apply()
748
+ Log.d(NAME, "Saved download config to SharedPreferences fallback")
749
+ }
750
+ } catch (e: Exception) {
751
+ Log.e(NAME, "Failed to save download config: ${e.message}")
752
+ }
845
753
  }
754
+ }
846
755
 
847
- /**
848
- * Check if the provided headers already contain a User-Agent header
849
- * (case-insensitive)
850
- */
851
- private fun hasUserAgentHeader(headers: ReadableMap?): Boolean {
852
- if (headers == null) {
853
- return false
756
+ private fun loadDownloadIdToConfigMap() {
757
+ synchronized(sharedLock) {
758
+ downloadIdToConfig = mutableMapOf()
759
+
760
+ try {
761
+ val str = if (isMMKVAvailable && mmkv != null) {
762
+ mmkv!!.decodeString("${NAME}_downloadIdToConfig")?.also {
763
+ Log.d(NAME, "Loaded download config from MMKV")
764
+ }
765
+ } else {
766
+ sharedPreferences.getString("${NAME}_downloadIdToConfig", null)?.also {
767
+ Log.d(NAME, "Loaded download config from SharedPreferences fallback")
768
+ }
854
769
  }
855
770
 
856
- val iterator = headers.keySetIterator()
857
- while (iterator.hasNextKey()) {
858
- val headerKey = iterator.nextKey()
859
- if (headerKey.lowercase() == "user-agent") {
860
- return true
861
- }
771
+ if (str != null) {
772
+ val gson = Gson()
773
+ val mapType = object : TypeToken<Map<Long, RNBGDTaskConfig>>() {}.type
774
+ downloadIdToConfig = gson.fromJson(str, mapType)
775
+ } else {
776
+ Log.d(NAME, "No existing download config found, starting with empty map")
862
777
  }
778
+ } catch (e: Exception) {
779
+ Log.e(NAME, "Failed to load download config: ${e.message}")
780
+ downloadIdToConfig = mutableMapOf()
781
+ }
782
+ }
783
+ }
784
+
785
+ private fun saveConfigMap() {
786
+ synchronized(sharedLock) {
787
+ try {
788
+ if (isMMKVAvailable && mmkv != null) {
789
+ mmkv!!.encode("${NAME}_progressInterval", progressInterval)
790
+ mmkv!!.encode("${NAME}_progressMinBytes", progressMinBytes)
791
+ Log.d(NAME, "Saved config to MMKV")
792
+ } else {
793
+ sharedPreferences.edit()
794
+ .putInt("${NAME}_progressInterval", progressInterval)
795
+ .putLong("${NAME}_progressMinBytes", progressMinBytes)
796
+ .apply()
797
+ Log.d(NAME, "Saved config to SharedPreferences fallback")
798
+ }
799
+ } catch (e: Exception) {
800
+ Log.e(NAME, "Failed to save config: ${e.message}")
801
+ }
802
+ }
803
+ }
804
+
805
+ private fun loadConfigMap() {
806
+ synchronized(sharedLock) {
807
+ try {
808
+ if (isMMKVAvailable && mmkv != null) {
809
+ val progressIntervalScope = mmkv!!.decodeInt("${NAME}_progressInterval")
810
+ if (progressIntervalScope > 0) {
811
+ progressInterval = progressIntervalScope
812
+ }
813
+ val progressMinBytesScope = mmkv!!.decodeLong("${NAME}_progressMinBytes")
814
+ if (progressMinBytesScope > 0) {
815
+ progressMinBytes = progressMinBytesScope
816
+ }
817
+ Log.d(NAME, "Loaded config from MMKV")
818
+ } else {
819
+ val progressIntervalScope = sharedPreferences.getInt("${NAME}_progressInterval", 0)
820
+ if (progressIntervalScope > 0) {
821
+ progressInterval = progressIntervalScope
822
+ }
823
+ val progressMinBytesScope = sharedPreferences.getLong("${NAME}_progressMinBytes", 0)
824
+ if (progressMinBytesScope > 0) {
825
+ progressMinBytes = progressMinBytesScope
826
+ }
827
+ Log.d(NAME, "Loaded config from SharedPreferences fallback")
828
+ }
829
+ } catch (e: Exception) {
830
+ Log.e(NAME, "Failed to load config: ${e.message}")
831
+ }
832
+ }
833
+ }
834
+
835
+ private fun stopTaskProgress(configId: String) {
836
+ val onProgressFuture = configIdToProgressFuture[configId]
837
+ if (onProgressFuture != null) {
838
+ onProgressFuture.cancel(true)
839
+ configIdToPercent.remove(configId)
840
+ configIdToLastBytes.remove(configId)
841
+ configIdToProgressFuture.remove(configId)
842
+ }
843
+ }
844
+
845
+ private fun setFileChangesBeforeCompletion(targetSrc: String, destinationSrc: String): Future<Boolean> {
846
+ return fixedExecutorPool.submit<Boolean> {
847
+ val file = File(targetSrc)
848
+ val destination = File(destinationSrc)
849
+ var destinationParent: File? = null
850
+ try {
851
+ if (file.exists()) {
852
+ FileUtils.rm(destination)
853
+ destinationParent = FileUtils.mkdirParent(destination)
854
+ FileUtils.mv(file, destination)
855
+ }
856
+ } catch (e: Exception) {
857
+ FileUtils.rm(file)
858
+ FileUtils.rm(destination)
859
+ FileUtils.rm(destinationParent)
860
+ throw Exception(e)
861
+ }
862
+
863
+ true
864
+ }
865
+ }
866
+
867
+ /**
868
+ * Check if the provided headers already contain a User-Agent header
869
+ * (case-insensitive)
870
+ */
871
+ private fun hasUserAgentHeader(headers: ReadableMap?): Boolean {
872
+ if (headers == null) {
873
+ return false
874
+ }
863
875
 
864
- return false
876
+ val iterator = headers.keySetIterator()
877
+ while (iterator.hasNextKey()) {
878
+ val headerKey = iterator.nextKey()
879
+ if (headerKey.lowercase() == "user-agent") {
880
+ return true
881
+ }
865
882
  }
883
+
884
+ return false
885
+ }
866
886
  }