@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.
@@ -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 android.net.NetworkRequest
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 = UIDTConstants.JOB_ID_BASE + (configId.hashCode() and 0x7FFFFFFF) % 10000
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
- // Build network request - require internet connectivity.
84
- // Remove NET_CAPABILITY_NOT_VPN (added by Builder default) so that VPN networks
85
- // (e.g. Proton VPN, full-tunnel VPNs) are accepted. Without this, the JobScheduler
86
- // only considers non-VPN networks; a kill-switch VPN blocks that traffic and the
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 = UIDTConstants.JOB_ID_BASE + (configId.hashCode() and 0x7FFFFFFF) % 10000
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
+ }