@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
package com.eko
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
5
|
+
import android.net.Network
|
|
6
|
+
import com.eko.utils.NetworkRequestUtils
|
|
7
|
+
import java.util.concurrent.ExecutorService
|
|
8
|
+
import java.util.concurrent.Executors
|
|
9
|
+
import java.util.concurrent.RejectedExecutionException
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Enforces isAllowedOverMetered=false for downloads that run outside a scheduler
|
|
13
|
+
* (the foreground-service path on Android < 14, and the UIDT fallback).
|
|
14
|
+
*
|
|
15
|
+
* Unmetered-only downloads are parked in a waiting set and started when a
|
|
16
|
+
* ConnectivityManager callback reports an unmetered network; the transfer is
|
|
17
|
+
* bound to that specific network, auto-paused back into the waiting set when
|
|
18
|
+
* the network is lost or becomes metered, and auto-resumed from its byte offset
|
|
19
|
+
* when an unmetered network returns - DownloadManager's "queued for WiFi"
|
|
20
|
+
* semantics.
|
|
21
|
+
*
|
|
22
|
+
* All state is guarded by a single lock. The lock serializes transitions
|
|
23
|
+
* between "waiting" and "running" against user pause/cancel and network
|
|
24
|
+
* callbacks, so a download can never be started and paused concurrently, and
|
|
25
|
+
* the callback can never be unregistered while a transition is in flight.
|
|
26
|
+
* Callback bodies hop to a dedicated single thread: network callbacks share
|
|
27
|
+
* the process-wide ConnectivityThread, which must never be blocked by socket
|
|
28
|
+
* teardown or notification IPC; a single thread also preserves the
|
|
29
|
+
* onAvailable/onLost ordering.
|
|
30
|
+
*/
|
|
31
|
+
class UnmeteredNetworkGate(
|
|
32
|
+
private val context: Context,
|
|
33
|
+
private val resumableDownloader: ResumableDownloader,
|
|
34
|
+
private val host: Host
|
|
35
|
+
) {
|
|
36
|
+
|
|
37
|
+
companion object {
|
|
38
|
+
private const val TAG = "UnmeteredNetworkGate"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The service-side pieces the gate needs to start a transfer. */
|
|
42
|
+
interface Host {
|
|
43
|
+
/**
|
|
44
|
+
* Build an event listener for the download, or null when its job is gone
|
|
45
|
+
* (cancelled concurrently) and the waiting entry should be dropped.
|
|
46
|
+
*/
|
|
47
|
+
fun createGateListener(id: String): ResumableDownloader.DownloadListener?
|
|
48
|
+
|
|
49
|
+
/** A gated transfer is about to start (e.g. acquire the wake lock). */
|
|
50
|
+
fun onGatedTransferStarting()
|
|
51
|
+
|
|
52
|
+
/** Gate state changed in a way the user-facing notification should reflect. */
|
|
53
|
+
fun onGateStateChanged()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private val lock = Any()
|
|
57
|
+
// Downloads waiting for an unmetered network before (re)starting
|
|
58
|
+
private val waiting = mutableSetOf<String>()
|
|
59
|
+
// Gated downloads currently transferring, keyed to the network they are bound to
|
|
60
|
+
private val running = mutableMapOf<String, Network>()
|
|
61
|
+
// Unmetered networks currently known to be available
|
|
62
|
+
private val unmeteredNetworks = mutableSetOf<Network>()
|
|
63
|
+
@Volatile
|
|
64
|
+
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
|
65
|
+
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
66
|
+
|
|
67
|
+
private val connectivityManager by lazy {
|
|
68
|
+
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Park a NEW unmetered-only download. `prepare` runs under the gate lock after
|
|
73
|
+
* stale bookkeeping for the id is cleared and must register the download's
|
|
74
|
+
* waiting state (see ResumableDownloader.prepareWaitingDownload). The download
|
|
75
|
+
* starts immediately when an unmetered network is already connected.
|
|
76
|
+
*
|
|
77
|
+
* Returns false when the network callback can't be registered - the gate can't
|
|
78
|
+
* work then and the caller must fail the download instead of stranding it.
|
|
79
|
+
*/
|
|
80
|
+
fun parkNewDownload(id: String, prepare: () -> Unit): Boolean {
|
|
81
|
+
synchronized(lock) {
|
|
82
|
+
clearLocked(id)
|
|
83
|
+
prepare()
|
|
84
|
+
if (!ensureCallbackLocked()) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
waiting.add(id)
|
|
88
|
+
// The onAvailable replay only fires when the callback is first
|
|
89
|
+
// registered - drain explicitly for networks we already know about
|
|
90
|
+
unmeteredNetworks.firstOrNull()?.let { startWaitingLocked(it) }
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Park a paused unmetered-only download for resume. Returns false when the
|
|
97
|
+
* download is not paused (already transferring - re-adding it would corrupt
|
|
98
|
+
* the gate state) or the network callback can't be registered.
|
|
99
|
+
*/
|
|
100
|
+
fun parkForResume(id: String): Boolean {
|
|
101
|
+
synchronized(lock) {
|
|
102
|
+
val state = resumableDownloader.getState(id) ?: return false
|
|
103
|
+
if (!state.isPaused.get()) {
|
|
104
|
+
RNBackgroundDownloaderModuleImpl.logW(TAG, "Download $id is not paused")
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
if (!ensureCallbackLocked()) {
|
|
108
|
+
RNBackgroundDownloaderModuleImpl.logE(TAG, "Cannot register unmetered network callback for $id")
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Resume of $id waits for an unmetered network")
|
|
112
|
+
waiting.add(id)
|
|
113
|
+
// See parkNewDownload: drain explicitly for already-known networks
|
|
114
|
+
unmeteredNetworks.firstOrNull()?.let { startWaitingLocked(it) }
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Remove the download from the gate and run `block` while still holding the
|
|
121
|
+
* gate lock. Used for user pause/cancel so an in-flight onAvailable drain
|
|
122
|
+
* can't restart the download between the bookkeeping removal and the
|
|
123
|
+
* downloader call (which would silently undo the pause).
|
|
124
|
+
*/
|
|
125
|
+
fun <T> withGateCleared(id: String, block: () -> T): T {
|
|
126
|
+
synchronized(lock) {
|
|
127
|
+
clearLocked(id)
|
|
128
|
+
return block()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remove the download from the gate bookkeeping (terminal state or restart). */
|
|
133
|
+
fun clear(id: String) {
|
|
134
|
+
synchronized(lock) {
|
|
135
|
+
clearLocked(id)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** IDs currently parked waiting for an unmetered network (for the notification). */
|
|
140
|
+
fun waitingIds(): Set<String> {
|
|
141
|
+
synchronized(lock) {
|
|
142
|
+
return waiting.toSet()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* If a gated download errored because its bound network died or became metered,
|
|
148
|
+
* move it back into the waiting set (silently - no error is surfaced) and return
|
|
149
|
+
* true. Returns false for genuine errors (HTTP failures, or IO errors while the
|
|
150
|
+
* bound network is verifiably still unmetered) so they propagate normally.
|
|
151
|
+
*
|
|
152
|
+
* Runs on the download thread, which is about to exit - blocking it briefly is
|
|
153
|
+
* fine. The grace re-check is needed because the socket abort from a network
|
|
154
|
+
* loss/metering change usually lands before ConnectivityService updates the
|
|
155
|
+
* network's capabilities and delivers onLost.
|
|
156
|
+
*/
|
|
157
|
+
fun regateAfterNetworkLoss(id: String, errorCode: Int): Boolean {
|
|
158
|
+
// Only IO/exception-type errors (-1) qualify; positive codes are HTTP errors
|
|
159
|
+
if (errorCode != -1) return false
|
|
160
|
+
val state = resumableDownloader.getState(id) ?: return false
|
|
161
|
+
if (state.isAllowedOverMetered) return false
|
|
162
|
+
|
|
163
|
+
val boundNetwork = synchronized(lock) {
|
|
164
|
+
running[id]
|
|
165
|
+
?: // onLost already moved this download back to the waiting set between the
|
|
166
|
+
// socket error and this classification - suppress the error, it's handled
|
|
167
|
+
return waiting.contains(id)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var lostUnmetered = !isNetworkUnmetered(boundNetwork)
|
|
171
|
+
if (!lostUnmetered) {
|
|
172
|
+
// Capabilities still look fine - give the connectivity stack a moment to
|
|
173
|
+
// catch up with reality, then trust both the capability query and the
|
|
174
|
+
// callback-maintained set of unmetered networks. Runs unlocked on the
|
|
175
|
+
// exiting download thread.
|
|
176
|
+
try {
|
|
177
|
+
Thread.sleep(DownloadConstants.UNMETERED_RECHECK_DELAY_MS)
|
|
178
|
+
} catch (e: InterruptedException) {
|
|
179
|
+
Thread.currentThread().interrupt()
|
|
180
|
+
}
|
|
181
|
+
lostUnmetered = !isNetworkUnmetered(boundNetwork) ||
|
|
182
|
+
synchronized(lock) { !unmeteredNetworks.contains(boundNetwork) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
synchronized(lock) {
|
|
186
|
+
// Re-validate under the lock: the grace period may have raced a cancel, a
|
|
187
|
+
// user pause, or an onLost-driven re-gate that already restarted this
|
|
188
|
+
// download on another network. Mutate only if we still own the binding.
|
|
189
|
+
val currentState = resumableDownloader.getState(id)
|
|
190
|
+
?: return true // cancelled during the grace period - nothing to report
|
|
191
|
+
if (currentState.isCancelled.get()) return true
|
|
192
|
+
if (waiting.contains(id)) return true // already re-gated by onLost
|
|
193
|
+
val currentNetwork = running[id]
|
|
194
|
+
?: return true // released by a concurrent pause/cancel - error is stale
|
|
195
|
+
if (currentNetwork != boundNetwork) {
|
|
196
|
+
// Restarted on another network during the grace period - the error from
|
|
197
|
+
// the old connection is stale; the new transfer reports for itself
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
if (!lostUnmetered) {
|
|
201
|
+
// The network is genuinely fine - this is a real error (server, disk...)
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
running.remove(id)
|
|
206
|
+
currentState.network = null
|
|
207
|
+
// Paused-like state so the gate can restart it via resume()
|
|
208
|
+
currentState.isPaused.set(true)
|
|
209
|
+
waiting.add(id)
|
|
210
|
+
ensureCallbackLocked()
|
|
211
|
+
// If another unmetered network is already up, restart right away
|
|
212
|
+
unmeteredNetworks.firstOrNull()?.let { startWaitingLocked(it) }
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Tear the gate down (service destroy). */
|
|
218
|
+
fun shutdown() {
|
|
219
|
+
synchronized(lock) {
|
|
220
|
+
waiting.clear()
|
|
221
|
+
running.clear()
|
|
222
|
+
maybeUnregisterCallbackLocked()
|
|
223
|
+
}
|
|
224
|
+
executor.shutdownNow()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Register the shared callback for unmetered networks (idempotent). Caller must
|
|
229
|
+
* hold the lock. Returns false when registration failed.
|
|
230
|
+
*
|
|
231
|
+
* registerNetworkCallback immediately replays onAvailable for networks that
|
|
232
|
+
* already satisfy the request, which arms the initial drain.
|
|
233
|
+
*/
|
|
234
|
+
private fun ensureCallbackLocked(): Boolean {
|
|
235
|
+
if (networkCallback != null) return true
|
|
236
|
+
|
|
237
|
+
val callback = object : ConnectivityManager.NetworkCallback() {
|
|
238
|
+
override fun onAvailable(network: Network) {
|
|
239
|
+
runOnExecutor {
|
|
240
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Unmetered network available: $network")
|
|
241
|
+
synchronized(lock) {
|
|
242
|
+
unmeteredNetworks.add(network)
|
|
243
|
+
startWaitingLocked(network)
|
|
244
|
+
}
|
|
245
|
+
host.onGateStateChanged()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
override fun onLost(network: Network) {
|
|
250
|
+
// Also delivered when a network stops satisfying the request
|
|
251
|
+
// (e.g. WiFi becomes metered), not only on disconnect
|
|
252
|
+
runOnExecutor {
|
|
253
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Unmetered network lost: $network")
|
|
254
|
+
synchronized(lock) {
|
|
255
|
+
unmeteredNetworks.remove(network)
|
|
256
|
+
pauseRunningOnLocked(network)
|
|
257
|
+
}
|
|
258
|
+
host.onGateStateChanged()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return try {
|
|
264
|
+
connectivityManager.registerNetworkCallback(NetworkRequestUtils.internetRequest(requireUnmetered = true), callback)
|
|
265
|
+
networkCallback = callback
|
|
266
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Registered unmetered network callback")
|
|
267
|
+
true
|
|
268
|
+
} catch (e: Exception) {
|
|
269
|
+
RNBackgroundDownloaderModuleImpl.logE(TAG, "Failed to register network callback: ${e.message}")
|
|
270
|
+
false
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private fun runOnExecutor(block: () -> Unit) {
|
|
275
|
+
try {
|
|
276
|
+
executor.execute(block)
|
|
277
|
+
} catch (e: RejectedExecutionException) {
|
|
278
|
+
// Gate is being shut down - its state is going away with it
|
|
279
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Gate executor rejected task (shutting down)")
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Caller must hold the lock. */
|
|
284
|
+
private fun maybeUnregisterCallbackLocked() {
|
|
285
|
+
if (waiting.isNotEmpty() || running.isNotEmpty()) return
|
|
286
|
+
val callback = networkCallback ?: return
|
|
287
|
+
networkCallback = null
|
|
288
|
+
unmeteredNetworks.clear()
|
|
289
|
+
try {
|
|
290
|
+
connectivityManager.unregisterNetworkCallback(callback)
|
|
291
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Unregistered unmetered network callback")
|
|
292
|
+
} catch (e: Exception) {
|
|
293
|
+
RNBackgroundDownloaderModuleImpl.logW(TAG, "Failed to unregister network callback: ${e.message}")
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Caller must hold the lock. */
|
|
298
|
+
private fun clearLocked(id: String) {
|
|
299
|
+
waiting.remove(id)
|
|
300
|
+
running.remove(id)
|
|
301
|
+
// Drop the network binding so a later non-gated restart of the same ID
|
|
302
|
+
// doesn't inherit a stale network
|
|
303
|
+
resumableDownloader.getState(id)?.network = null
|
|
304
|
+
maybeUnregisterCallbackLocked()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Start every waiting unmetered-only download on the given network.
|
|
309
|
+
* Caller must hold the lock; callers refresh the notification afterwards.
|
|
310
|
+
*/
|
|
311
|
+
private fun startWaitingLocked(network: Network) {
|
|
312
|
+
for (id in waiting.toList()) {
|
|
313
|
+
val state = resumableDownloader.getState(id)
|
|
314
|
+
val listener = host.createGateListener(id)
|
|
315
|
+
if (state == null || listener == null) {
|
|
316
|
+
// Cancelled concurrently (cancel runs under the lock) - drop the entry
|
|
317
|
+
RNBackgroundDownloaderModuleImpl.logW(TAG, "Gated download $id has no job/state, dropping")
|
|
318
|
+
waiting.remove(id)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
waiting.remove(id)
|
|
323
|
+
host.onGatedTransferStarting()
|
|
324
|
+
state.network = network
|
|
325
|
+
// Record the binding before resuming so an immediate transfer error
|
|
326
|
+
// classifies correctly in regateAfterNetworkLoss
|
|
327
|
+
running[id] = network
|
|
328
|
+
if (resumableDownloader.resume(id, listener)) {
|
|
329
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Started gated download $id on unmetered network $network")
|
|
330
|
+
} else {
|
|
331
|
+
RNBackgroundDownloaderModuleImpl.logW(TAG, "Failed to start gated download $id")
|
|
332
|
+
running.remove(id)
|
|
333
|
+
state.network = null
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pause gated downloads bound to a network that is no longer unmetered/available
|
|
340
|
+
* and put them back into the waiting set. If another unmetered network is still
|
|
341
|
+
* available they restart on it immediately. Caller must hold the lock.
|
|
342
|
+
*/
|
|
343
|
+
private fun pauseRunningOnLocked(network: Network) {
|
|
344
|
+
var movedToWaiting = false
|
|
345
|
+
for ((id, boundNetwork) in running.entries.toList()) {
|
|
346
|
+
if (boundNetwork != network) continue
|
|
347
|
+
running.remove(id)
|
|
348
|
+
RNBackgroundDownloaderModuleImpl.logD(TAG, "Unmetered network lost, pausing gated download $id")
|
|
349
|
+
resumableDownloader.pause(id)
|
|
350
|
+
resumableDownloader.getState(id)?.network = null
|
|
351
|
+
// Back to waiting: auto-restarts when an unmetered network is available again
|
|
352
|
+
waiting.add(id)
|
|
353
|
+
movedToWaiting = true
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (movedToWaiting) {
|
|
357
|
+
// Another unmetered network may still be up (e.g. ethernet next to WiFi) -
|
|
358
|
+
// its onAvailable already fired, so re-drain the waiting set explicitly
|
|
359
|
+
unmeteredNetworks.firstOrNull()?.let { startWaitingLocked(it) }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private fun isNetworkUnmetered(network: Network): Boolean {
|
|
364
|
+
return NetworkRequestUtils.isUnmetered(connectivityManager.getNetworkCapabilities(network))
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -6,7 +6,7 @@ import android.app.job.JobScheduler
|
|
|
6
6
|
import android.app.job.JobService
|
|
7
7
|
import android.content.ComponentName
|
|
8
8
|
import android.content.Context
|
|
9
|
-
import
|
|
9
|
+
import com.eko.utils.NetworkRequestUtils
|
|
10
10
|
import android.os.Build
|
|
11
11
|
import android.os.PersistableBundle
|
|
12
12
|
import androidx.annotation.RequiresApi
|
|
@@ -31,6 +31,68 @@ object UIDTJobManager {
|
|
|
31
31
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Derive the stable job ID for a download config ID. */
|
|
35
|
+
private fun jobIdFor(configId: String): Int =
|
|
36
|
+
UIDTConstants.JOB_ID_BASE + (configId.hashCode() and 0x7FFFFFFF) % 10000
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Everything needed to persist a scheduled-but-not-yet-running job as a
|
|
40
|
+
* paused download after cancelling it.
|
|
41
|
+
*/
|
|
42
|
+
data class PendingJobInfo(
|
|
43
|
+
val url: String,
|
|
44
|
+
val destination: String,
|
|
45
|
+
val startByte: Long,
|
|
46
|
+
val totalBytes: Long,
|
|
47
|
+
val metadata: String,
|
|
48
|
+
val isAllowedOverMetered: Boolean,
|
|
49
|
+
val headers: Map<String, String>
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cancel a UIDT job that is scheduled but not yet running - e.g. held pending
|
|
54
|
+
* by its unmetered-network constraint - and return the info needed to persist
|
|
55
|
+
* it as a paused download. A pending job has no entry in the registry's
|
|
56
|
+
* activeJobs (that is only populated in onStartJob), so the regular pause
|
|
57
|
+
* path can't see it. Returns null when the job is running or doesn't exist.
|
|
58
|
+
*/
|
|
59
|
+
fun cancelPendingJob(context: Context, configId: String): PendingJobInfo? {
|
|
60
|
+
if (!isUIDTAvailable()) return null
|
|
61
|
+
if (UIDTJobRegistry.isActiveJob(configId)) return null
|
|
62
|
+
|
|
63
|
+
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
|
|
64
|
+
val jobId = jobIdFor(configId)
|
|
65
|
+
val pendingJob = jobScheduler.getPendingJob(jobId) ?: return null
|
|
66
|
+
|
|
67
|
+
val extras = pendingJob.extras
|
|
68
|
+
// Guard against job-ID hash collisions between different config IDs
|
|
69
|
+
if (extras.getString(UIDTConstants.KEY_DOWNLOAD_ID) != configId) return null
|
|
70
|
+
val url = extras.getString(UIDTConstants.KEY_URL) ?: return null
|
|
71
|
+
val destination = extras.getString(UIDTConstants.KEY_DESTINATION) ?: return null
|
|
72
|
+
|
|
73
|
+
// Prefer the persisted resume state (more recent than the extras)
|
|
74
|
+
val resumeState = UIDTJobRegistry.loadResumeState(context, configId)
|
|
75
|
+
val headers = resumeState?.first
|
|
76
|
+
?: UIDTJobRegistry.pendingHeaders[configId]
|
|
77
|
+
?: emptyMap()
|
|
78
|
+
val startByte = maxOf(resumeState?.second ?: 0L, extras.getLong(UIDTConstants.KEY_START_BYTE, 0))
|
|
79
|
+
|
|
80
|
+
jobScheduler.cancel(jobId)
|
|
81
|
+
UIDTJobRegistry.pendingHeaders.remove(configId)
|
|
82
|
+
UIDTJobRegistry.clearResumeState(context, configId)
|
|
83
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Cancelled pending UIDT job for $configId (jobId=$jobId) at byte $startByte")
|
|
84
|
+
|
|
85
|
+
return PendingJobInfo(
|
|
86
|
+
url = url,
|
|
87
|
+
destination = destination,
|
|
88
|
+
startByte = startByte,
|
|
89
|
+
totalBytes = extras.getLong(UIDTConstants.KEY_TOTAL_BYTES, -1),
|
|
90
|
+
metadata = extras.getString(UIDTConstants.KEY_METADATA) ?: "{}",
|
|
91
|
+
isAllowedOverMetered = extras.getBoolean(UIDTConstants.KEY_IS_ALLOWED_OVER_METERED, true),
|
|
92
|
+
headers = headers
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
34
96
|
/**
|
|
35
97
|
* Schedule a UIDT download job.
|
|
36
98
|
*
|
|
@@ -42,6 +104,9 @@ object UIDTJobManager {
|
|
|
42
104
|
* @param startByte Byte position to resume from (0 for new downloads)
|
|
43
105
|
* @param totalBytes Total expected bytes (-1 if unknown)
|
|
44
106
|
* @param metadata JSON metadata with course info for notification grouping
|
|
107
|
+
* @param isAllowedOverMetered Whether the transfer may use metered networks (cellular).
|
|
108
|
+
* When false the job requires an unmetered network, matching
|
|
109
|
+
* DownloadManager.Request.setAllowedOverMetered(false) semantics.
|
|
45
110
|
* @return true if job was scheduled successfully
|
|
46
111
|
*/
|
|
47
112
|
fun scheduleDownload(
|
|
@@ -52,7 +117,8 @@ object UIDTJobManager {
|
|
|
52
117
|
headers: Map<String, String>,
|
|
53
118
|
startByte: Long = 0,
|
|
54
119
|
totalBytes: Long = -1,
|
|
55
|
-
metadata: String = "{}"
|
|
120
|
+
metadata: String = "{}",
|
|
121
|
+
isAllowedOverMetered: Boolean
|
|
56
122
|
): Boolean {
|
|
57
123
|
if (!isUIDTAvailable()) {
|
|
58
124
|
RNBackgroundDownloaderModuleImpl.logW(UIDTConstants.TAG, "UIDT requires Android 14+, falling back to foreground service")
|
|
@@ -62,7 +128,7 @@ object UIDTJobManager {
|
|
|
62
128
|
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
|
|
63
129
|
|
|
64
130
|
// Create unique job ID from config ID
|
|
65
|
-
val jobId =
|
|
131
|
+
val jobId = jobIdFor(configId)
|
|
66
132
|
|
|
67
133
|
// Store headers for later retrieval (PersistableBundle can't store Map<String, String>)
|
|
68
134
|
UIDTJobRegistry.pendingHeaders[configId] = headers
|
|
@@ -78,17 +144,13 @@ object UIDTJobManager {
|
|
|
78
144
|
putLong(UIDTConstants.KEY_START_BYTE, startByte)
|
|
79
145
|
putLong(UIDTConstants.KEY_TOTAL_BYTES, totalBytes)
|
|
80
146
|
putString(UIDTConstants.KEY_METADATA, metadata)
|
|
147
|
+
putBoolean(UIDTConstants.KEY_IS_ALLOWED_OVER_METERED, isAllowedOverMetered)
|
|
81
148
|
}
|
|
82
149
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
// job never starts, causing callbacks to never fire.
|
|
88
|
-
val networkRequest = NetworkRequest.Builder()
|
|
89
|
-
.addCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
90
|
-
.removeCapability(android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
|
91
|
-
.build()
|
|
150
|
+
// When metered networks are not allowed, require an unmetered network so the
|
|
151
|
+
// JobScheduler holds the job until Wi-Fi/ethernet is available - the same
|
|
152
|
+
// behavior as DownloadManager.Request.setAllowedOverMetered(false).
|
|
153
|
+
val networkRequest = NetworkRequestUtils.internetRequest(requireUnmetered = !isAllowedOverMetered)
|
|
92
154
|
|
|
93
155
|
// Build the job with UIDT flag
|
|
94
156
|
val jobInfo = JobInfo.Builder(jobId, ComponentName(context, UIDTDownloadJobService::class.java))
|
|
@@ -106,7 +168,7 @@ object UIDTJobManager {
|
|
|
106
168
|
val success = result == JobScheduler.RESULT_SUCCESS
|
|
107
169
|
|
|
108
170
|
if (success) {
|
|
109
|
-
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Scheduled UIDT job for $configId (jobId=$jobId)")
|
|
171
|
+
RNBackgroundDownloaderModuleImpl.logD(UIDTConstants.TAG, "Scheduled UIDT job for $configId (jobId=$jobId, isAllowedOverMetered=$isAllowedOverMetered)")
|
|
110
172
|
} else {
|
|
111
173
|
RNBackgroundDownloaderModuleImpl.logE(UIDTConstants.TAG, "Failed to schedule UIDT job for $configId")
|
|
112
174
|
UIDTJobRegistry.pendingHeaders.remove(configId)
|
|
@@ -153,7 +215,7 @@ object UIDTJobManager {
|
|
|
153
215
|
}
|
|
154
216
|
|
|
155
217
|
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
|
|
156
|
-
val jobId =
|
|
218
|
+
val jobId = jobIdFor(configId)
|
|
157
219
|
|
|
158
220
|
jobScheduler.cancel(jobId)
|
|
159
221
|
UIDTJobRegistry.pendingHeaders.remove(configId)
|
|
@@ -93,6 +93,7 @@ object UIDTConstants {
|
|
|
93
93
|
const val KEY_START_BYTE = "start_byte"
|
|
94
94
|
const val KEY_TOTAL_BYTES = "total_bytes"
|
|
95
95
|
const val KEY_METADATA = "metadata"
|
|
96
|
+
const val KEY_IS_ALLOWED_OVER_METERED = "is_allowed_over_metered"
|
|
96
97
|
|
|
97
98
|
// Notification channel for UIDT jobs (visible notifications)
|
|
98
99
|
const val NOTIFICATION_CHANNEL_ID = "uidt_download_channel"
|
|
@@ -225,6 +226,16 @@ object UIDTJobRegistry {
|
|
|
225
226
|
return jobState.resumableDownloader.getState(configId)
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Read the metered-network permission of an active job from its persisted extras.
|
|
231
|
+
* Returns null when the job is not active. The extras survive process death, so
|
|
232
|
+
* this is the source of truth when the module's in-memory map has been lost.
|
|
233
|
+
*/
|
|
234
|
+
fun getJobIsAllowedOverMetered(configId: String): Boolean? {
|
|
235
|
+
val jobState = activeJobs[configId] ?: return null
|
|
236
|
+
return jobState.params.extras.getBoolean(UIDTConstants.KEY_IS_ALLOWED_OVER_METERED, true)
|
|
237
|
+
}
|
|
238
|
+
|
|
228
239
|
/**
|
|
229
240
|
* Update aggregate progress for a group.
|
|
230
241
|
*/
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package com.eko.utils
|
|
2
|
+
|
|
3
|
+
import android.net.NetworkCapabilities
|
|
4
|
+
import android.net.NetworkRequest
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single source of truth for what "acceptable network" means for downloads.
|
|
8
|
+
* Used by both enforcement mechanisms of isAllowedOverMetered - the UIDT
|
|
9
|
+
* JobScheduler constraint (Android 14+) and the foreground-service unmetered
|
|
10
|
+
* gate (Android < 14) - so the two paths can't drift apart.
|
|
11
|
+
*/
|
|
12
|
+
object NetworkRequestUtils {
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A request for an internet-capable network, optionally restricted to
|
|
16
|
+
* unmetered networks (matching DownloadManager.Request.setAllowedOverMetered
|
|
17
|
+
* semantics).
|
|
18
|
+
*
|
|
19
|
+
* NET_CAPABILITY_NOT_VPN (added by Builder default) is removed so that VPN
|
|
20
|
+
* networks (e.g. Proton VPN, full-tunnel VPNs) are accepted. Without this,
|
|
21
|
+
* only non-VPN networks are considered; a kill-switch VPN blocks that
|
|
22
|
+
* traffic and the transfer never starts.
|
|
23
|
+
*/
|
|
24
|
+
fun internetRequest(requireUnmetered: Boolean): NetworkRequest {
|
|
25
|
+
val builder = NetworkRequest.Builder()
|
|
26
|
+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
27
|
+
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
|
28
|
+
if (requireUnmetered) {
|
|
29
|
+
builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
|
30
|
+
}
|
|
31
|
+
return builder.build()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the capabilities describe a network that satisfies
|
|
36
|
+
* internetRequest(requireUnmetered = true).
|
|
37
|
+
*/
|
|
38
|
+
fun isUnmetered(capabilities: NetworkCapabilities?): Boolean {
|
|
39
|
+
return capabilities != null &&
|
|
40
|
+
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
41
|
+
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
|
42
|
+
}
|
|
43
|
+
}
|