@kesha-antonov/react-native-background-downloader 4.5.6 → 4.5.8
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/README.md +5 -1
- package/android/src/main/java/com/eko/DownloadConstants.kt +7 -0
- package/android/src/main/java/com/eko/Downloader.kt +80 -111
- package/android/src/main/java/com/eko/RNBGDTaskConfig.kt +4 -1
- package/android/src/main/java/com/eko/RNBackgroundDownloaderModuleImpl.kt +97 -17
- package/android/src/main/java/com/eko/ResumableDownloadService.kt +153 -37
- package/android/src/main/java/com/eko/ResumableDownloader.kt +66 -12
- package/android/src/main/java/com/eko/UIDTDownloadJobService.kt +25 -4
- package/android/src/main/java/com/eko/UnmeteredNetworkGate.kt +366 -0
- package/android/src/main/java/com/eko/uidt/UIDTJobManager.kt +76 -14
- package/android/src/main/java/com/eko/uidt/UIDTJobState.kt +11 -0
- package/android/src/main/java/com/eko/utils/NetworkRequestUtils.kt +43 -0
- package/android/src/main/java/com/eko/utils/StorageManager.kt +32 -45
- package/package.json +2 -2
|
@@ -42,6 +42,7 @@ class ResumableDownloadService : Service() {
|
|
|
42
42
|
const val EXTRA_HEADERS = "headers"
|
|
43
43
|
const val EXTRA_START_BYTE = "start_byte"
|
|
44
44
|
const val EXTRA_TOTAL_BYTES = "total_bytes"
|
|
45
|
+
const val EXTRA_IS_ALLOWED_OVER_METERED = "is_allowed_over_metered"
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
private val binder = LocalBinder()
|
|
@@ -61,6 +62,26 @@ class ResumableDownloadService : Service() {
|
|
|
61
62
|
// Shared ResumableDownloader instance
|
|
62
63
|
val resumableDownloader = ResumableDownloader()
|
|
63
64
|
|
|
65
|
+
// Enforces isAllowedOverMetered=false on this path (no scheduler to hold the
|
|
66
|
+
// transfer): parks downloads until an unmetered network is available, binds
|
|
67
|
+
// the transfer to it, auto-pauses on loss and auto-resumes
|
|
68
|
+
private val unmeteredGate by lazy {
|
|
69
|
+
UnmeteredNetworkGate(this, resumableDownloader, object : UnmeteredNetworkGate.Host {
|
|
70
|
+
override fun createGateListener(id: String): ResumableDownloader.DownloadListener? {
|
|
71
|
+
val job = activeDownloads[id] ?: return null
|
|
72
|
+
return createValidatingListener(id, job.sessionToken, downloadGeneration[id] ?: 0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override fun onGatedTransferStarting() {
|
|
76
|
+
acquireWakeLock()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override fun onGateStateChanged() {
|
|
80
|
+
updateNotification()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
inner class LocalBinder : Binder() {
|
|
65
86
|
fun getService(): ResumableDownloadService = this@ResumableDownloadService
|
|
66
87
|
}
|
|
@@ -70,16 +91,19 @@ class ResumableDownloadService : Service() {
|
|
|
70
91
|
* before delegating events to the actual listener. This prevents stale events
|
|
71
92
|
* from old download sessions from being processed.
|
|
72
93
|
*
|
|
94
|
+
* On a valid terminal event (complete/error) the service's job entry is always
|
|
95
|
+
* removed - the session/generation check already guarantees only the current
|
|
96
|
+
* session's listener can get here, and a leaked entry would keep the foreground
|
|
97
|
+
* service (and its notification) alive forever via stopServiceIfIdle().
|
|
98
|
+
*
|
|
73
99
|
* @param id The download ID
|
|
74
100
|
* @param sessionToken The session token from when the download was started
|
|
75
101
|
* @param generation The generation counter from when the download was started
|
|
76
|
-
* @param cleanupOnTerminal Whether to clean up state on complete/error (true for start, false for resume since job already exists)
|
|
77
102
|
*/
|
|
78
103
|
private fun createValidatingListener(
|
|
79
104
|
id: String,
|
|
80
105
|
sessionToken: Long,
|
|
81
|
-
generation: Long
|
|
82
|
-
cleanupOnTerminal: Boolean = true
|
|
106
|
+
generation: Long
|
|
83
107
|
): ResumableDownloader.DownloadListener {
|
|
84
108
|
return object : ResumableDownloader.DownloadListener {
|
|
85
109
|
|
|
@@ -128,11 +152,7 @@ class ResumableDownloadService : Service() {
|
|
|
128
152
|
override fun onComplete(id: String, location: String, bytesDownloaded: Long, bytesTotal: Long) {
|
|
129
153
|
if (isValid()) {
|
|
130
154
|
listener?.onComplete(id, location, bytesDownloaded, bytesTotal)
|
|
131
|
-
|
|
132
|
-
activeDownloads.remove(id)
|
|
133
|
-
lastProgressLogTime.remove(id)
|
|
134
|
-
}
|
|
135
|
-
stopServiceIfIdle()
|
|
155
|
+
cleanupTerminalDownload(id)
|
|
136
156
|
} else {
|
|
137
157
|
logStale("onComplete")
|
|
138
158
|
}
|
|
@@ -140,12 +160,22 @@ class ResumableDownloadService : Service() {
|
|
|
140
160
|
|
|
141
161
|
override fun onError(id: String, error: String, errorCode: Int) {
|
|
142
162
|
if (isValid()) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
163
|
+
// A gated download whose bound network died raises a socket error before
|
|
164
|
+
// the ConnectivityManager onLost callback arrives - reclassify it as
|
|
165
|
+
// "waiting for unmetered network" instead of failing the download
|
|
166
|
+
if (unmeteredGate.regateAfterNetworkLoss(id, errorCode)) {
|
|
167
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Gated download $id lost its unmetered network mid-transfer, re-waiting (suppressed error: $error)")
|
|
168
|
+
updateNotification()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
// The grace period inside regateAfterNetworkLoss may have raced a
|
|
172
|
+
// cancel or restart - re-check validity before reporting the error
|
|
173
|
+
if (!isValid()) {
|
|
174
|
+
logStale("onError (after network grace check)")
|
|
175
|
+
return
|
|
147
176
|
}
|
|
148
|
-
|
|
177
|
+
listener?.onError(id, error, errorCode)
|
|
178
|
+
cleanupTerminalDownload(id)
|
|
149
179
|
} else {
|
|
150
180
|
logStale("onError")
|
|
151
181
|
}
|
|
@@ -197,9 +227,10 @@ class ResumableDownloadService : Service() {
|
|
|
197
227
|
val headers = getHeadersFromIntent(intent)
|
|
198
228
|
val startByte = intent.getLongExtra(EXTRA_START_BYTE, 0)
|
|
199
229
|
val totalBytes = intent.getLongExtra(EXTRA_TOTAL_BYTES, -1)
|
|
230
|
+
val isAllowedOverMetered = intent.getBooleanExtra(EXTRA_IS_ALLOWED_OVER_METERED, true)
|
|
200
231
|
|
|
201
232
|
if (id != null && url != null && destination != null) {
|
|
202
|
-
startDownloadInternal(id, url, destination, headers, startByte, totalBytes)
|
|
233
|
+
startDownloadInternal(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered)
|
|
203
234
|
}
|
|
204
235
|
}
|
|
205
236
|
ACTION_PAUSE_DOWNLOAD -> {
|
|
@@ -223,31 +254,59 @@ class ResumableDownloadService : Service() {
|
|
|
223
254
|
ACTION_STOP_SERVICE -> {
|
|
224
255
|
stopServiceIfIdle()
|
|
225
256
|
}
|
|
257
|
+
else -> {
|
|
258
|
+
// Downloader starts the service with a blank intent and delivers the actual
|
|
259
|
+
// work over the binder. After Context.startForegroundService() the service
|
|
260
|
+
// MUST call startForeground() promptly or the system kills the app - do it
|
|
261
|
+
// here instead of relying on the bound startDownload call arriving in time.
|
|
262
|
+
startForegroundWithNotification()
|
|
263
|
+
}
|
|
226
264
|
}
|
|
227
265
|
|
|
228
|
-
|
|
266
|
+
// NOT_STICKY: all download state is in-memory (recovery goes through the
|
|
267
|
+
// module's persisted snapshots on the next app launch), so a sticky restart
|
|
268
|
+
// with a null intent would only produce an idle foreground service whose
|
|
269
|
+
// notification nothing ever clears.
|
|
270
|
+
return START_NOT_STICKY
|
|
229
271
|
}
|
|
230
272
|
|
|
231
273
|
override fun onDestroy() {
|
|
232
274
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "Service destroyed")
|
|
275
|
+
unmeteredGate.shutdown()
|
|
233
276
|
releaseWakeLock()
|
|
234
277
|
executorService.shutdownNow()
|
|
278
|
+
isForeground = false
|
|
235
279
|
super.onDestroy()
|
|
236
280
|
}
|
|
237
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Shared terminal cleanup for a download that completed or failed.
|
|
284
|
+
* Must always run on a valid terminal event or the service job entry leaks and
|
|
285
|
+
* keeps the foreground service (and its notification) alive forever.
|
|
286
|
+
*/
|
|
287
|
+
private fun cleanupTerminalDownload(id: String) {
|
|
288
|
+
activeDownloads.remove(id)
|
|
289
|
+
lastProgressLogTime.remove(id)
|
|
290
|
+
unmeteredGate.clear(id)
|
|
291
|
+
stopServiceIfIdle()
|
|
292
|
+
}
|
|
293
|
+
|
|
238
294
|
fun setDownloadListener(listener: ResumableDownloader.DownloadListener?) {
|
|
239
295
|
this.listener = listener
|
|
240
296
|
}
|
|
241
297
|
|
|
298
|
+
// isAllowedOverMetered is deliberately not defaulted: a call site that forgets
|
|
299
|
+
// it must not compile, or the metered restriction silently reverts to allowed
|
|
242
300
|
fun startDownload(
|
|
243
301
|
id: String,
|
|
244
302
|
url: String,
|
|
245
303
|
destination: String,
|
|
246
304
|
headers: Map<String, String>,
|
|
247
305
|
startByte: Long = 0,
|
|
248
|
-
totalBytes: Long = -1
|
|
306
|
+
totalBytes: Long = -1,
|
|
307
|
+
isAllowedOverMetered: Boolean
|
|
249
308
|
) {
|
|
250
|
-
startDownloadInternal(id, url, destination, headers, startByte, totalBytes)
|
|
309
|
+
startDownloadInternal(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered)
|
|
251
310
|
}
|
|
252
311
|
|
|
253
312
|
private fun startDownloadInternal(
|
|
@@ -256,13 +315,13 @@ class ResumableDownloadService : Service() {
|
|
|
256
315
|
destination: String,
|
|
257
316
|
headers: Map<String, String>,
|
|
258
317
|
startByte: Long,
|
|
259
|
-
totalBytes: Long
|
|
318
|
+
totalBytes: Long,
|
|
319
|
+
isAllowedOverMetered: Boolean
|
|
260
320
|
) {
|
|
261
|
-
RNBackgroundDownloaderModuleImpl.logD(TAG, "Starting download: $id from byte $startByte")
|
|
321
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Starting download: $id from byte $startByte (isAllowedOverMetered=$isAllowedOverMetered)")
|
|
262
322
|
|
|
263
323
|
// Start foreground service if not already
|
|
264
324
|
startForegroundWithNotification()
|
|
265
|
-
acquireWakeLock()
|
|
266
325
|
|
|
267
326
|
// Increment generation counter for this download ID
|
|
268
327
|
// This ensures we can detect stale events even if job was removed and re-added
|
|
@@ -273,8 +332,35 @@ class ResumableDownloadService : Service() {
|
|
|
273
332
|
val job = DownloadJob(id, url, destination, headers, startByte, totalBytes)
|
|
274
333
|
activeDownloads[id] = job
|
|
275
334
|
|
|
335
|
+
if (!isAllowedOverMetered) {
|
|
336
|
+
// Park the download and let the unmetered-network gate start it: it starts
|
|
337
|
+
// immediately when an unmetered network is already connected, otherwise it
|
|
338
|
+
// waits like DownloadManager's "queued for WiFi" state. No wake lock while
|
|
339
|
+
// waiting - one is acquired when the transfer actually starts.
|
|
340
|
+
val gated = unmeteredGate.parkNewDownload(id) {
|
|
341
|
+
resumableDownloader.prepareWaitingDownload(id, url, destination, headers, startByte, totalBytes)
|
|
342
|
+
}
|
|
343
|
+
if (!gated) {
|
|
344
|
+
// The constraint can't be enforced (network callback registration
|
|
345
|
+
// failed) - fail the download loudly instead of stranding it forever
|
|
346
|
+
RNBackgroundDownloaderModuleImpl.logE(TAG, "Cannot register unmetered network callback, failing download $id")
|
|
347
|
+
activeDownloads.remove(id)
|
|
348
|
+
resumableDownloader.cancel(id)
|
|
349
|
+
listener?.onError(id, "Cannot enforce isAllowedOverMetered=false: network callback registration failed", -1)
|
|
350
|
+
stopServiceIfIdle()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
updateNotification()
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Clear stale gate bookkeeping from a previous gated download with this ID
|
|
358
|
+
unmeteredGate.clear(id)
|
|
359
|
+
|
|
360
|
+
acquireWakeLock()
|
|
361
|
+
|
|
276
362
|
// Create a validating listener wrapper
|
|
277
|
-
val serviceListener = createValidatingListener(id, job.sessionToken, generation
|
|
363
|
+
val serviceListener = createValidatingListener(id, job.sessionToken, generation)
|
|
278
364
|
|
|
279
365
|
// Start the download using ResumableDownloader
|
|
280
366
|
resumableDownloader.startDownload(
|
|
@@ -284,13 +370,19 @@ class ResumableDownloadService : Service() {
|
|
|
284
370
|
headers = headers,
|
|
285
371
|
listener = serviceListener,
|
|
286
372
|
startByte = startByte,
|
|
287
|
-
totalBytes = totalBytes
|
|
373
|
+
totalBytes = totalBytes,
|
|
374
|
+
isAllowedOverMetered = isAllowedOverMetered
|
|
288
375
|
)
|
|
289
376
|
}
|
|
290
377
|
|
|
291
378
|
fun pauseDownload(id: String): Boolean {
|
|
292
379
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "Pausing download: $id")
|
|
293
|
-
|
|
380
|
+
// Atomic with the gate: a user pause takes the download out of the unmetered
|
|
381
|
+
// gate under the gate lock, so a concurrent onAvailable drain can't claim it
|
|
382
|
+
// and restart the transfer the user just paused
|
|
383
|
+
val result = unmeteredGate.withGateCleared(id) {
|
|
384
|
+
resumableDownloader.pause(id)
|
|
385
|
+
}
|
|
294
386
|
if (result) {
|
|
295
387
|
updateNotification()
|
|
296
388
|
// Don't stop service - keep it alive for potential resume
|
|
@@ -307,20 +399,36 @@ class ResumableDownloadService : Service() {
|
|
|
307
399
|
val generation = downloadGeneration[id] ?: 0
|
|
308
400
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "Resuming $id with session=$sessionToken, generation=$generation")
|
|
309
401
|
|
|
310
|
-
// Create a validating listener wrapper (don't cleanup since job already exists)
|
|
311
|
-
val serviceListener = createValidatingListener(id, sessionToken, generation, cleanupOnTerminal = false)
|
|
312
|
-
|
|
313
402
|
// Make sure service is in foreground
|
|
314
403
|
startForegroundWithNotification()
|
|
404
|
+
|
|
405
|
+
// Unmetered-only downloads resume through the network gate so the transfer
|
|
406
|
+
// only starts (and stays) on an unmetered network
|
|
407
|
+
val state = resumableDownloader.getState(id)
|
|
408
|
+
if (state != null && !state.isAllowedOverMetered) {
|
|
409
|
+
val gated = unmeteredGate.parkForResume(id)
|
|
410
|
+
if (gated) {
|
|
411
|
+
updateNotification()
|
|
412
|
+
}
|
|
413
|
+
return gated
|
|
414
|
+
}
|
|
415
|
+
|
|
315
416
|
acquireWakeLock()
|
|
316
417
|
|
|
418
|
+
// Create a validating listener wrapper
|
|
419
|
+
val serviceListener = createValidatingListener(id, sessionToken, generation)
|
|
420
|
+
|
|
317
421
|
return resumableDownloader.resume(id, serviceListener)
|
|
318
422
|
}
|
|
319
423
|
|
|
320
424
|
fun cancelDownload(id: String): Boolean {
|
|
321
425
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "Cancelling download: $id")
|
|
322
|
-
|
|
323
|
-
|
|
426
|
+
// Atomic with the gate so an in-flight onAvailable drain can't restart the
|
|
427
|
+
// download between the bookkeeping removal and the downloader cancel
|
|
428
|
+
val result = unmeteredGate.withGateCleared(id) {
|
|
429
|
+
activeDownloads.remove(id)
|
|
430
|
+
resumableDownloader.cancel(id)
|
|
431
|
+
}
|
|
324
432
|
stopServiceIfIdle()
|
|
325
433
|
return result
|
|
326
434
|
}
|
|
@@ -329,7 +437,11 @@ class ResumableDownloadService : Service() {
|
|
|
329
437
|
|
|
330
438
|
fun getState(id: String) = resumableDownloader.getState(id)
|
|
331
439
|
|
|
440
|
+
@Volatile
|
|
441
|
+
private var isForeground = false
|
|
442
|
+
|
|
332
443
|
private fun startForegroundWithNotification() {
|
|
444
|
+
if (isForeground) return
|
|
333
445
|
val notification = createNotification()
|
|
334
446
|
try {
|
|
335
447
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
@@ -337,6 +449,7 @@ class ResumableDownloadService : Service() {
|
|
|
337
449
|
} else {
|
|
338
450
|
startForeground(DownloadConstants.NOTIFICATION_ID, notification)
|
|
339
451
|
}
|
|
452
|
+
isForeground = true
|
|
340
453
|
} catch (e: Exception) {
|
|
341
454
|
RNBackgroundDownloaderModuleImpl.logE(TAG, "Failed to start foreground service: ${e.message}")
|
|
342
455
|
}
|
|
@@ -359,15 +472,17 @@ class ResumableDownloadService : Service() {
|
|
|
359
472
|
|
|
360
473
|
private fun createNotification(): Notification {
|
|
361
474
|
val activeCount = activeDownloads.size
|
|
362
|
-
|
|
363
|
-
val
|
|
364
|
-
|
|
365
|
-
val
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
475
|
+
// Downloads held by the unmetered-network gate are paused-like but shown separately
|
|
476
|
+
val waitingIds = unmeteredGate.waitingIds()
|
|
477
|
+
val waitingCount = activeDownloads.keys.count { waitingIds.contains(it) }
|
|
478
|
+
val pausedCount = activeDownloads.keys.count { resumableDownloader.isPaused(it) && !waitingIds.contains(it) }
|
|
479
|
+
val runningCount = activeCount - pausedCount - waitingCount
|
|
480
|
+
|
|
481
|
+
val parts = mutableListOf<String>()
|
|
482
|
+
if (runningCount > 0) parts.add("$runningCount downloading")
|
|
483
|
+
if (waitingCount > 0) parts.add("$waitingCount waiting for unmetered network")
|
|
484
|
+
if (pausedCount > 0) parts.add("$pausedCount paused")
|
|
485
|
+
val contentText = if (parts.isEmpty()) "Download service running" else parts.joinToString(", ")
|
|
371
486
|
|
|
372
487
|
// Use download icon when actively downloading, pause icon when all paused
|
|
373
488
|
val icon = if (runningCount > 0) {
|
|
@@ -425,6 +540,7 @@ class ResumableDownloadService : Service() {
|
|
|
425
540
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "No active downloads, stopping service")
|
|
426
541
|
releaseWakeLock()
|
|
427
542
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
543
|
+
isForeground = false
|
|
428
544
|
stopSelf()
|
|
429
545
|
} else {
|
|
430
546
|
RNBackgroundDownloaderModuleImpl.logD(TAG, "Service has active downloads, keeping alive")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.eko
|
|
2
2
|
|
|
3
|
+
import android.net.Network
|
|
3
4
|
import com.eko.utils.HeaderUtils
|
|
4
5
|
import java.io.File
|
|
5
6
|
import java.io.FileOutputStream
|
|
@@ -34,7 +35,12 @@ class ResumableDownloader {
|
|
|
34
35
|
@Volatile var inputStream: InputStream? = null,
|
|
35
36
|
var hasReportedBegin: Boolean = false,
|
|
36
37
|
// Session counter to detect stale threads after pause/resume
|
|
37
|
-
val sessionId: AtomicLong = AtomicLong(0)
|
|
38
|
+
val sessionId: AtomicLong = AtomicLong(0),
|
|
39
|
+
// Whether this download may transfer over metered networks
|
|
40
|
+
val isAllowedOverMetered: Boolean = true,
|
|
41
|
+
// When set, the HTTP connection is opened on this specific network so an
|
|
42
|
+
// unmetered-only download can never leak onto a metered default network
|
|
43
|
+
@Volatile var network: Network? = null
|
|
38
44
|
)
|
|
39
45
|
|
|
40
46
|
private val activeDownloads = ConcurrentHashMap<String, DownloadState>()
|
|
@@ -51,6 +57,8 @@ class ResumableDownloader {
|
|
|
51
57
|
* @param startByte The byte position to start from (for resuming paused DownloadManager downloads)
|
|
52
58
|
* @param totalBytes The total bytes if known (for resuming)
|
|
53
59
|
*/
|
|
60
|
+
// isAllowedOverMetered is deliberately not defaulted: a call site that forgets
|
|
61
|
+
// it must not compile, or the metered restriction silently reverts to allowed
|
|
54
62
|
fun startDownload(
|
|
55
63
|
id: String,
|
|
56
64
|
url: String,
|
|
@@ -58,8 +66,54 @@ class ResumableDownloader {
|
|
|
58
66
|
headers: Map<String, String>,
|
|
59
67
|
listener: DownloadListener,
|
|
60
68
|
startByte: Long = 0,
|
|
61
|
-
totalBytes: Long = -1
|
|
69
|
+
totalBytes: Long = -1,
|
|
70
|
+
isAllowedOverMetered: Boolean,
|
|
71
|
+
network: Network? = null
|
|
62
72
|
) {
|
|
73
|
+
val state = registerNewDownload(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered)
|
|
74
|
+
state.network = network
|
|
75
|
+
|
|
76
|
+
val currentSessionId = state.sessionId.get()
|
|
77
|
+
val thread = Thread {
|
|
78
|
+
downloadWithResume(state, listener, currentSessionId)
|
|
79
|
+
}
|
|
80
|
+
state.thread = thread
|
|
81
|
+
thread.start()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register an unmetered-only download in a paused/waiting state WITHOUT
|
|
86
|
+
* starting the transfer. Used by the unmetered-network gate on Android < 14:
|
|
87
|
+
* the state is visible to pause/cancel/getState immediately, and the actual
|
|
88
|
+
* transfer is started later via resume() once a suitable network is available.
|
|
89
|
+
*/
|
|
90
|
+
fun prepareWaitingDownload(
|
|
91
|
+
id: String,
|
|
92
|
+
url: String,
|
|
93
|
+
destination: String,
|
|
94
|
+
headers: Map<String, String>,
|
|
95
|
+
startByte: Long = 0,
|
|
96
|
+
totalBytes: Long = -1
|
|
97
|
+
): DownloadState {
|
|
98
|
+
val state = registerNewDownload(id, url, destination, headers, startByte, totalBytes, isAllowedOverMetered = false)
|
|
99
|
+
// Paused-like state so resume() can start the first transfer
|
|
100
|
+
state.isPaused.set(true)
|
|
101
|
+
return state
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cancel any existing download with the same ID, clean up a stale destination
|
|
106
|
+
* file, then create and register a fresh DownloadState (no thread started).
|
|
107
|
+
*/
|
|
108
|
+
private fun registerNewDownload(
|
|
109
|
+
id: String,
|
|
110
|
+
url: String,
|
|
111
|
+
destination: String,
|
|
112
|
+
headers: Map<String, String>,
|
|
113
|
+
startByte: Long,
|
|
114
|
+
totalBytes: Long,
|
|
115
|
+
isAllowedOverMetered: Boolean
|
|
116
|
+
): DownloadState {
|
|
63
117
|
// Cancel any existing download with the same ID first
|
|
64
118
|
val existingState = activeDownloads[id]
|
|
65
119
|
if (existingState != null) {
|
|
@@ -93,7 +147,8 @@ class ResumableDownloader {
|
|
|
93
147
|
url = url,
|
|
94
148
|
destination = destination,
|
|
95
149
|
headers = headers,
|
|
96
|
-
bytesTotal = totalBytes
|
|
150
|
+
bytesTotal = totalBytes,
|
|
151
|
+
isAllowedOverMetered = isAllowedOverMetered
|
|
97
152
|
)
|
|
98
153
|
|
|
99
154
|
// Set initial bytes downloaded (only for explicit resume with startByte > 0)
|
|
@@ -112,13 +167,7 @@ class ResumableDownloader {
|
|
|
112
167
|
}
|
|
113
168
|
|
|
114
169
|
activeDownloads[id] = state
|
|
115
|
-
|
|
116
|
-
val currentSessionId = state.sessionId.get()
|
|
117
|
-
val thread = Thread {
|
|
118
|
-
downloadWithResume(state, listener, currentSessionId)
|
|
119
|
-
}
|
|
120
|
-
state.thread = thread
|
|
121
|
-
thread.start()
|
|
170
|
+
return state
|
|
122
171
|
}
|
|
123
172
|
|
|
124
173
|
fun pause(id: String): Boolean {
|
|
@@ -278,7 +327,10 @@ class ResumableDownloader {
|
|
|
278
327
|
}
|
|
279
328
|
|
|
280
329
|
val url = URL(state.url)
|
|
281
|
-
|
|
330
|
+
// When the download is bound to a specific network (unmetered-network gate),
|
|
331
|
+
// open the connection on that network so bytes can't leak onto the metered
|
|
332
|
+
// default network. Otherwise use the default network.
|
|
333
|
+
connection = (state.network?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
|
|
282
334
|
// Store connection reference so it can be disconnected on cancel
|
|
283
335
|
state.connection = connection
|
|
284
336
|
connection.connectTimeout = DownloadConstants.CONNECT_TIMEOUT_MS
|
|
@@ -549,7 +601,9 @@ class ResumableDownloader {
|
|
|
549
601
|
connection = this.connection,
|
|
550
602
|
inputStream = this.inputStream,
|
|
551
603
|
hasReportedBegin = this.hasReportedBegin,
|
|
552
|
-
sessionId = this.sessionId
|
|
604
|
+
sessionId = this.sessionId,
|
|
605
|
+
isAllowedOverMetered = this.isAllowedOverMetered,
|
|
606
|
+
network = this.network
|
|
553
607
|
)
|
|
554
608
|
}
|
|
555
609
|
}
|
|
@@ -53,8 +53,9 @@ class UIDTDownloadJobService : JobService() {
|
|
|
53
53
|
headers: Map<String, String>,
|
|
54
54
|
startByte: Long = 0,
|
|
55
55
|
totalBytes: Long = -1,
|
|
56
|
-
metadata: String = "{}"
|
|
57
|
-
|
|
56
|
+
metadata: String = "{}",
|
|
57
|
+
isAllowedOverMetered: Boolean
|
|
58
|
+
): Boolean = UIDTJobManager.scheduleDownload(context, configId, url, destination, headers, startByte, totalBytes, metadata, isAllowedOverMetered)
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Cancel a scheduled UIDT job.
|
|
@@ -76,11 +77,23 @@ class UIDTDownloadJobService : JobService() {
|
|
|
76
77
|
*/
|
|
77
78
|
fun getJobDownloadState(configId: String): ResumableDownloader.DownloadState? = UIDTJobRegistry.getJobDownloadState(configId)
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Get the metered-network permission of an active UIDT job (null if not active).
|
|
82
|
+
*/
|
|
83
|
+
fun getJobIsAllowedOverMetered(configId: String): Boolean? = UIDTJobRegistry.getJobIsAllowedOverMetered(configId)
|
|
84
|
+
|
|
79
85
|
/**
|
|
80
86
|
* Pause an active UIDT download.
|
|
81
87
|
*/
|
|
82
88
|
fun pauseJob(context: Context, configId: String): Boolean = UIDTJobManager.pauseJob(context, configId)
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Cancel a scheduled-but-not-running UIDT job (e.g. held pending by its
|
|
92
|
+
* unmetered-network constraint) and return its persistable info.
|
|
93
|
+
*/
|
|
94
|
+
fun cancelPendingJob(context: Context, configId: String): UIDTJobManager.PendingJobInfo? =
|
|
95
|
+
UIDTJobManager.cancelPendingJob(context, configId)
|
|
96
|
+
|
|
84
97
|
/**
|
|
85
98
|
* Resume a paused UIDT download.
|
|
86
99
|
*/
|
|
@@ -150,6 +163,7 @@ class UIDTDownloadJobService : JobService() {
|
|
|
150
163
|
val destination = extras.getString(UIDTConstants.KEY_DESTINATION) ?: return false
|
|
151
164
|
val startByteFromExtras = extras.getLong(UIDTConstants.KEY_START_BYTE, 0)
|
|
152
165
|
val totalBytes = extras.getLong(UIDTConstants.KEY_TOTAL_BYTES, -1)
|
|
166
|
+
val isAllowedOverMetered = extras.getBoolean(UIDTConstants.KEY_IS_ALLOWED_OVER_METERED, true)
|
|
153
167
|
|
|
154
168
|
// Extract group info from metadata (for notification grouping)
|
|
155
169
|
val metadataJson = extras.getString(UIDTConstants.KEY_METADATA) ?: "{}"
|
|
@@ -223,7 +237,12 @@ class UIDTDownloadJobService : JobService() {
|
|
|
223
237
|
// Create listener that will notify completion
|
|
224
238
|
val jobListener = createJobListener(configId, params, groupId, groupName)
|
|
225
239
|
|
|
226
|
-
// Start the download asynchronously
|
|
240
|
+
// Start the download asynchronously. The network constraint is enforced by
|
|
241
|
+
// the JobScheduler; the flag is passed so the DownloadState stays truthful
|
|
242
|
+
// (it is a fallback source for pause/snapshot persistence in the module).
|
|
243
|
+
// Bind the transfer to the network that satisfied the job's constraint -
|
|
244
|
+
// otherwise the sockets use the DEFAULT network, which can be metered
|
|
245
|
+
// cellular even while the satisfying unmetered network is connected.
|
|
227
246
|
resumableDownloader.startDownload(
|
|
228
247
|
id = configId,
|
|
229
248
|
url = url,
|
|
@@ -231,7 +250,9 @@ class UIDTDownloadJobService : JobService() {
|
|
|
231
250
|
headers = headers,
|
|
232
251
|
listener = jobListener,
|
|
233
252
|
startByte = startByte,
|
|
234
|
-
totalBytes = totalBytes
|
|
253
|
+
totalBytes = totalBytes,
|
|
254
|
+
isAllowedOverMetered = isAllowedOverMetered,
|
|
255
|
+
network = params.network
|
|
235
256
|
)
|
|
236
257
|
|
|
237
258
|
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Started UIDT download: $configId from byte $startByte")
|