Line data Source code
1 : import 'package:flutter/foundation.dart';
2 : import '../worker.dart';
3 :
4 : export 'request_signing.dart';
5 :
6 : /// What to do if the destination file already exists.
7 : enum DuplicatePolicy {
8 : /// Overwrite the existing file (default).
9 : overwrite,
10 :
11 : /// Rename the new file to avoid collision (e.g. `file_1.zip`).
12 : rename,
13 :
14 : /// Skip the download entirely and report success.
15 : skip,
16 : }
17 :
18 : /// HTTP download worker configuration.
19 : ///
20 : /// Supports automatic resume from last downloaded byte on network failure,
21 : /// and optional checksum verification for download integrity.
22 : @immutable
23 : final class HttpDownloadWorker extends Worker {
24 14 : const HttpDownloadWorker({
25 : required this.url,
26 : required this.savePath,
27 : this.headers = const {},
28 : this.timeout = const Duration(minutes: 5),
29 : this.enableResume = true,
30 : this.expectedChecksum,
31 : this.checksumAlgorithm = 'SHA-256',
32 : this.useBackgroundSession = false,
33 : this.showNotification = false,
34 : this.notificationTitle,
35 : this.notificationBody,
36 : this.skipExisting = false,
37 : this.allowPause = false,
38 : this.cookies,
39 : this.authToken,
40 : this.authHeaderTemplate = 'Bearer {accessToken}',
41 : this.onDuplicate = DuplicatePolicy.overwrite,
42 : this.moveToPublicDownloads = false,
43 : this.saveToGallery = false,
44 : this.extractAfterDownload = false,
45 : this.extractPath,
46 : this.deleteArchiveAfterExtract = false,
47 : this.bandwidthLimitBytesPerSecond,
48 : this.requestSigning,
49 : this.tokenRefresh,
50 : });
51 :
52 : /// The URL to download from.
53 : final String url;
54 :
55 : /// Where to save the downloaded file (absolute path).
56 : final String savePath;
57 :
58 : /// Optional HTTP headers to include in the request.
59 : final Map<String, String> headers;
60 :
61 : /// Request timeout per attempt (default: 5 minutes).
62 : ///
63 : /// NET-027: This is a **per-attempt** timeout. When WorkManager retries a
64 : /// failed task it resets the clock, so the total time across all retries can
65 : /// exceed this value. To bound the total retry window, configure a deadline
66 : /// or max-retry count on the [TaskTrigger] instead.
67 : final Duration timeout;
68 :
69 : /// Enable automatic resume from last downloaded byte on network failure.
70 : ///
71 : /// When enabled (default), if the download is interrupted, the next attempt
72 : /// will resume from the last successfully downloaded byte using HTTP Range
73 : /// requests. The server must support Range requests (returns 206 Partial Content).
74 : /// Falls back to full download if server doesn't support resume.
75 : ///
76 : /// Default: `true`
77 : final bool enableResume;
78 :
79 : /// Expected checksum for download verification (optional).
80 : ///
81 : /// If provided, the downloaded file will be verified against this checksum
82 : /// after download completes. The download fails if checksums don't match.
83 : ///
84 : /// Example: `"a3b2c1d4e5f6..."` (hexadecimal string)
85 : ///
86 : /// Use with [checksumAlgorithm] to specify the hashing algorithm.
87 : final String? expectedChecksum;
88 :
89 : /// Checksum algorithm for verification.
90 : ///
91 : /// Supported algorithms:
92 : /// - `'MD5'` - Fast but not cryptographically secure
93 : /// - `'SHA-1'` - 160-bit hash (deprecated for security)
94 : /// - `'SHA-256'` - 256-bit hash (recommended, default)
95 : /// - `'SHA-512'` - 512-bit hash (most secure)
96 : ///
97 : /// Default: `'SHA-256'`
98 : final String checksumAlgorithm;
99 :
100 : /// Show a system notification with download progress.
101 : ///
102 : /// When `true`, the native side shows a persistent notification while the
103 : /// download is in progress and a completion/failure notification when done.
104 : ///
105 : /// **Android:** Uses a low-priority notification channel with a Cancel button.
106 : /// Requires `POST_NOTIFICATIONS` permission on Android 13+ (API 33+).
107 : ///
108 : /// **iOS:** Uses `UNUserNotificationCenter`. Progress updates are best-effort
109 : /// (iOS suspends the app during background downloads). The completion
110 : /// notification is always shown.
111 : ///
112 : /// Default: `false`
113 : final bool showNotification;
114 :
115 : /// Title for the progress notification.
116 : ///
117 : /// Defaults to the file name derived from [url] on the native side.
118 : final String? notificationTitle;
119 :
120 : /// Body text for the progress notification.
121 : ///
122 : /// Defaults to the download URL on the native side.
123 : final String? notificationBody;
124 :
125 : /// Use background URLSession for downloads (iOS only).
126 : ///
127 : /// **v2.3.0+ iOS Feature:**
128 : /// When enabled, downloads use `URLSessionConfiguration.background` which:
129 : /// - **Survives app termination** - Downloads continue even if app is killed
130 : /// - **No time limits** - Can download for hours (vs 30s foreground limit)
131 : /// - **System-managed** - OS handles network changes and retries
132 : /// - **Battery efficient** - OS schedules transfers optimally
133 : ///
134 : /// **Android:**
135 : /// This parameter has no effect on Android. WorkManager already handles
136 : /// background downloads robustly without special configuration.
137 : ///
138 : /// **When to use:**
139 : /// - ✅ Large files (>10MB) that may take minutes to download
140 : /// - ✅ Downloads that must complete even if user force-quits app
141 : /// - ✅ Downloads on unreliable networks (automatic retry)
142 : /// - ❌ Small files (<1MB) - foreground session is faster
143 : /// - ❌ Immediate downloads that finish in seconds
144 : ///
145 : /// Example:
146 : /// ```dart
147 : /// // Large app update download (survives app termination)
148 : /// worker: NativeWorker.httpDownload(
149 : /// url: 'https://cdn.example.com/app-v2.0.0.apk',
150 : /// savePath: '/downloads/update.apk',
151 : /// useBackgroundSession: true, // 🚀 Survives termination
152 : /// ),
153 : /// ```
154 : ///
155 : /// Default: `false` (backward compatible with existing code)
156 : final bool useBackgroundSession;
157 :
158 : /// Skip the download if the destination file already exists.
159 : ///
160 : /// When `true`, the worker checks whether [savePath] already exists on disk.
161 : /// If it does, the task reports success immediately without issuing any HTTP
162 : /// request. This is useful for incremental download managers or caches
163 : /// where a previously completed download should not be repeated.
164 : ///
165 : /// When `false` (default), the download always proceeds and overwrites any
166 : /// existing file.
167 : ///
168 : /// Default: `false`
169 : final bool skipExisting;
170 :
171 : /// Allow the task to be paused via [NativeWorkManager.pause]. When false
172 : /// (default), the Pause button is hidden from the download notification.
173 : final bool allowPause;
174 :
175 : /// HTTP cookies to include in the download request. Keys are cookie names,
176 : /// values are cookie values.
177 : final Map<String, String>? cookies;
178 :
179 : /// Auth token for the download request. Injected into the header as
180 : /// [authHeaderTemplate] with `{accessToken}` replaced by this value.
181 : final String? authToken;
182 :
183 : /// Template for the `Authorization` header value.
184 : ///
185 : /// `{accessToken}` is replaced with [authToken] at request time.
186 : /// Default: `"Bearer {accessToken}"`
187 : final String authHeaderTemplate;
188 :
189 : /// What to do if the destination file already exists.
190 : final DuplicatePolicy onDuplicate;
191 :
192 : /// Move the completed download into the public Downloads folder.
193 : ///
194 : /// NET-024: Post-processing is **best-effort**. If moving to the public
195 : /// Downloads folder fails (e.g. missing permission, storage full), the main
196 : /// task still reports success and the file remains in its original location.
197 : /// The native log will contain a warning. Check [TaskEvent.resultData] for the
198 : /// final `filePath` to know where the file landed.
199 : final bool moveToPublicDownloads;
200 :
201 : /// Save the completed download to the device gallery (images/videos).
202 : ///
203 : /// NET-024: Same best-effort semantics as [moveToPublicDownloads] — failure
204 : /// does not fail the task; the original file is preserved.
205 : final bool saveToGallery;
206 :
207 : /// Automatically extract the downloaded archive after a successful download.
208 : final bool extractAfterDownload;
209 :
210 : /// Directory to extract the archive into. Defaults to the directory
211 : /// containing [savePath] when null.
212 : final String? extractPath;
213 :
214 : /// Delete the archive file after successful extraction.
215 : final bool deleteArchiveAfterExtract;
216 :
217 : /// Maximum download speed in bytes per second.
218 : ///
219 : /// When set, the download stream is throttled to this rate using a token-bucket
220 : /// algorithm. Useful for limiting bandwidth consumption on metered connections.
221 : ///
222 : /// **Android:** applied via OkHttp response-body wrapping (effective immediately).
223 : /// **iOS:** applied via streaming download on iOS 15+; ignored on iOS 14 (downloads
224 : /// proceed at full speed — no error is raised).
225 : ///
226 : /// Example: `500 * 1024` for 500 KB/s.
227 : ///
228 : /// Default: `null` (no limit).
229 : final int? bandwidthLimitBytesPerSecond;
230 :
231 : /// HMAC-SHA256 request signing configuration.
232 : ///
233 : /// When set, each download request is signed with the specified secret key
234 : /// and the signature is injected as a request header (default: `X-Signature`).
235 : /// An `X-Timestamp` header is also added when [RequestSigning.includeTimestamp] is true.
236 : ///
237 : /// Default: `null` (no signing).
238 : final RequestSigning? requestSigning;
239 :
240 : /// Automatic token refresh configuration.
241 : final TokenRefreshConfig? tokenRefresh;
242 :
243 : // ═══════════════════════════════════════════════════════════════════════════
244 : // BUILDER-STYLE copyWith — avoids parameter explosion at call sites
245 : // ═══════════════════════════════════════════════════════════════════════════
246 :
247 : /// Returns a copy of this worker with the given fields replaced.
248 : ///
249 : /// Enables a fluent builder-style API without a separate builder class:
250 : ///
251 : /// ```dart
252 : /// final base = HttpDownloadWorker(url: '...', savePath: '...');
253 : ///
254 : /// final withNotification = base.copyWith(
255 : /// showNotification: true,
256 : /// notificationTitle: 'Downloading update…',
257 : /// allowPause: true,
258 : /// );
259 : ///
260 : /// final withResume = withNotification.copyWith(
261 : /// enableResume: true,
262 : /// onDuplicate: DuplicatePolicy.rename,
263 : /// );
264 : /// ```
265 1 : HttpDownloadWorker copyWith({
266 : String? url,
267 : String? savePath,
268 : Map<String, String>? headers,
269 : Duration? timeout,
270 : bool? enableResume,
271 : String? expectedChecksum,
272 : String? checksumAlgorithm,
273 : bool? useBackgroundSession,
274 : bool? showNotification,
275 : String? notificationTitle,
276 : String? notificationBody,
277 : bool? skipExisting,
278 : bool? allowPause,
279 : Map<String, String>? cookies,
280 : String? authToken,
281 : String? authHeaderTemplate,
282 : DuplicatePolicy? onDuplicate,
283 : bool? moveToPublicDownloads,
284 : bool? saveToGallery,
285 : bool? extractAfterDownload,
286 : String? extractPath,
287 : bool? deleteArchiveAfterExtract,
288 : int? bandwidthLimitBytesPerSecond,
289 : RequestSigning? requestSigning,
290 : }) {
291 1 : return HttpDownloadWorker(
292 1 : url: url ?? this.url,
293 1 : savePath: savePath ?? this.savePath,
294 1 : headers: headers ?? this.headers,
295 1 : timeout: timeout ?? this.timeout,
296 1 : enableResume: enableResume ?? this.enableResume,
297 1 : expectedChecksum: expectedChecksum ?? this.expectedChecksum,
298 1 : checksumAlgorithm: checksumAlgorithm ?? this.checksumAlgorithm,
299 1 : useBackgroundSession: useBackgroundSession ?? this.useBackgroundSession,
300 1 : showNotification: showNotification ?? this.showNotification,
301 1 : notificationTitle: notificationTitle ?? this.notificationTitle,
302 1 : notificationBody: notificationBody ?? this.notificationBody,
303 1 : skipExisting: skipExisting ?? this.skipExisting,
304 1 : allowPause: allowPause ?? this.allowPause,
305 1 : cookies: cookies ?? this.cookies,
306 1 : authToken: authToken ?? this.authToken,
307 1 : authHeaderTemplate: authHeaderTemplate ?? this.authHeaderTemplate,
308 1 : onDuplicate: onDuplicate ?? this.onDuplicate,
309 : moveToPublicDownloads:
310 1 : moveToPublicDownloads ?? this.moveToPublicDownloads,
311 1 : saveToGallery: saveToGallery ?? this.saveToGallery,
312 1 : extractAfterDownload: extractAfterDownload ?? this.extractAfterDownload,
313 1 : extractPath: extractPath ?? this.extractPath,
314 : deleteArchiveAfterExtract:
315 1 : deleteArchiveAfterExtract ?? this.deleteArchiveAfterExtract,
316 : bandwidthLimitBytesPerSecond:
317 1 : bandwidthLimitBytesPerSecond ?? this.bandwidthLimitBytesPerSecond,
318 1 : requestSigning: requestSigning ?? this.requestSigning,
319 : );
320 : }
321 :
322 : /// Convenience: enable notification with sensible defaults.
323 : ///
324 : /// ```dart
325 : /// worker.withNotification(title: 'Downloading...', allowPause: true)
326 : /// ```
327 1 : HttpDownloadWorker withNotification({
328 : String? title,
329 : String? body,
330 : bool allowPause = false,
331 : }) =>
332 1 : copyWith(
333 : showNotification: true,
334 : notificationTitle: title,
335 : notificationBody: body,
336 : allowPause: allowPause,
337 : );
338 :
339 : /// Convenience: set authentication token.
340 : ///
341 : /// ```dart
342 : /// worker.withAuth(token: myToken)
343 : /// worker.withAuth(token: myApiKey, template: 'ApiKey {accessToken}')
344 : /// ```
345 1 : HttpDownloadWorker withAuth({
346 : required String token,
347 : String template = 'Bearer {accessToken}',
348 : }) =>
349 1 : copyWith(authToken: token, authHeaderTemplate: template);
350 :
351 : /// Convenience: enable resume + skip-existing policy.
352 2 : HttpDownloadWorker withResume({bool skipIfExists = false}) => copyWith(
353 : enableResume: true,
354 : skipExisting: skipIfExists,
355 : );
356 :
357 : /// Convenience: verify download integrity with a checksum.
358 : ///
359 : /// ```dart
360 : /// worker.withChecksum(expected: sha256Hex) // defaults to SHA-256
361 : /// worker.withChecksum(expected: md5Hex, algorithm: 'MD5')
362 : /// ```
363 1 : HttpDownloadWorker withChecksum({
364 : required String expected,
365 : String algorithm = 'SHA-256',
366 : }) =>
367 1 : copyWith(expectedChecksum: expected, checksumAlgorithm: algorithm);
368 :
369 : /// Convenience: limit download speed.
370 : ///
371 : /// ```dart
372 : /// worker.withBandwidthLimit(500 * 1024) // 500 KB/s
373 : /// ```
374 1 : HttpDownloadWorker withBandwidthLimit(int bytesPerSecond) =>
375 1 : copyWith(bandwidthLimitBytesPerSecond: bytesPerSecond);
376 :
377 : /// Convenience: sign requests with HMAC-SHA256.
378 : ///
379 : /// ```dart
380 : /// worker.withSigning(RequestSigning(secretKey: mySecret))
381 : /// ```
382 1 : HttpDownloadWorker withSigning(RequestSigning signing) =>
383 1 : copyWith(requestSigning: signing);
384 :
385 5 : @override
386 : String get workerClassName => 'HttpDownloadWorker';
387 :
388 7 : @override
389 7 : Map<String, dynamic> toMap() => {
390 7 : 'workerType': 'httpDownload',
391 14 : 'url': url,
392 14 : 'savePath': savePath,
393 14 : 'headers': headers,
394 21 : 'timeoutMs': timeout.inMilliseconds,
395 14 : 'enableResume': enableResume,
396 11 : if (expectedChecksum != null) 'expectedChecksum': expectedChecksum,
397 14 : 'checksumAlgorithm': checksumAlgorithm,
398 14 : 'useBackgroundSession': useBackgroundSession,
399 14 : 'skipExisting': skipExisting,
400 14 : 'showNotification': showNotification,
401 7 : if (notificationTitle != null) 'notificationTitle': notificationTitle,
402 7 : if (notificationBody != null) 'notificationBody': notificationBody,
403 14 : 'allowPause': allowPause,
404 7 : if (cookies != null) 'cookies': cookies,
405 7 : if (authToken != null) 'authToken': authToken,
406 14 : 'authHeaderTemplate': authHeaderTemplate,
407 21 : 'onDuplicate': onDuplicate.name,
408 14 : 'moveToPublicDownloads': moveToPublicDownloads,
409 14 : 'saveToGallery': saveToGallery,
410 14 : 'extractAfterDownload': extractAfterDownload,
411 7 : if (extractPath != null) 'extractPath': extractPath,
412 14 : 'deleteArchiveAfterExtract': deleteArchiveAfterExtract,
413 7 : if (bandwidthLimitBytesPerSecond != null)
414 0 : 'bandwidthLimitBytesPerSecond': bandwidthLimitBytesPerSecond,
415 10 : if (requestSigning != null) 'requestSigning': requestSigning!.toMap(),
416 : };
417 : }
|