Line data Source code
1 : import 'dart:async';
2 : import 'dart:convert';
3 : import 'dart:ui';
4 : import 'dart:developer' as developer;
5 :
6 : import 'package:flutter/foundation.dart';
7 : import 'package:flutter/services.dart';
8 : import 'package:flutter/widgets.dart';
9 :
10 : import 'constraints.dart';
11 : import 'enqueue_request.dart';
12 : import 'events.dart';
13 : import 'observability.dart';
14 : import 'platform_interface.dart';
15 : import 'remote_trigger.dart';
16 : import 'middleware.dart';
17 : import 'task_chain.dart';
18 : import 'task_graph.dart';
19 : import 'task_handler.dart';
20 : import 'task_trigger.dart';
21 :
22 : import 'worker.dart';
23 :
24 : /// Main entry point for scheduling native background tasks.
25 : ///
26 : /// NativeWorkManager provides a unified API for scheduling background tasks
27 : /// on both Android and iOS. It uses Kotlin Multiplatform (KMP) under the hood
28 : /// for native performance.
29 : ///
30 : /// ## 🛡️ The Pure 10 Architecture
31 : ///
32 : /// This library is built on the **"Pure Native"** principle — utilizing 100%
33 : /// platform-native APIs without third-party dependencies.
34 : ///
35 : /// - **Reliability (10/10)**: **Native Watchdog** automatically recovers tasks
36 : /// stuck in 'running' state after an app crash or system reboot.
37 : /// - **Persistence (10/10)**: Uses **Atomic File Writing** (iOS) and
38 : /// **SQLite WAL Mode** (Android) to guarantee data integrity.
39 : /// - **Privacy (10/10)**: **Sensitive Data Redaction** recursively strips
40 : /// tokens, passwords, and cookies before persisting tasks to disk.
41 : /// - **Performance (10/10)**: **Zero Flutter Engine overhead** for native workers,
42 : /// consuming ~2MB RAM vs ~50MB for traditional plugins.
43 : ///
44 : /// ## Quick Start
45 : ///
46 : /// ```dart
47 : /// void main() async {
48 : /// WidgetsFlutterBinding.ensureInitialized();
49 : /// // Initialize with optional cleanup and concurrency settings
50 : /// await NativeWorkManager.initialize(
51 : /// maxConcurrentTasks: 4,
52 : /// cleanupAfterDays: 7,
53 : /// );
54 : /// runApp(MyApp());
55 : /// }
56 : ///
57 : /// // Schedule a secure periodic sync
58 : /// await NativeWorkManager.enqueue(
59 : /// taskId: 'daily-sync',
60 : /// trigger: TaskTrigger.periodic(Duration(hours: 24)),
61 : /// worker: NativeWorker.httpSync(
62 : /// url: 'https://api.example.com/sync',
63 : /// headers: {'Authorization': 'Bearer $token'}, // Auto-redacted in store!
64 : /// ),
65 : /// constraints: Constraints.networkRequired,
66 : /// );
67 : /// ```
68 : ///
69 : /// ## Execution Modes
70 : ///
71 : /// ### 🚀 Mode 1: Native Workers (Recommended)
72 : /// Runs directly in Kotlin (Android) or Swift (iOS).
73 : /// **RAM Usage**: ~2MB per task.
74 : /// **Startup Time**: <50ms.
75 : ///
76 : /// ### 🧩 Mode 2: Dart Workers
77 : /// Runs Dart code in a headless isolate.
78 : /// **RAM Usage**: ~50MB per task.
79 : /// **Startup Time**: 1000ms - 2000ms.
80 : /// Includes **Isolate Caching**: The engine stays "warm" for 5 minutes after
81 : /// completion to speed up consecutive tasks.
82 : /// Top-level callback dispatcher for background Dart execution.
83 : ///
84 : /// This function is invoked by the native side when initializing
85 : /// the Flutter Engine for Dart workers. It sets up the MethodChannel
86 : /// and signals that Dart is ready to receive callback invocations.
87 : ///
88 : /// DO NOT call this function directly - it's only for native side.
89 : ///
90 : /// The @pragma annotation prevents the Dart compiler from tree-shaking
91 : /// this function in release builds, which is critical for background execution.
92 0 : @pragma('vm:entry-point')
93 : Future<void> _callbackDispatcher() async {
94 : // Ensure Flutter binding is initialized
95 0 : WidgetsFlutterBinding.ensureInitialized();
96 :
97 : // Setup MethodChannel for receiving callback invocations
98 : const channel = MethodChannel('dev.brewkits/dart_worker_channel');
99 :
100 : // Signal that Dart is ready.
101 : // Await the invokeMethod call and handle errors explicitly — an unawaited future
102 : // would silently swallow any PlatformException, causing the native 10-second
103 : // timeout to fire and destroy the engine.
104 0 : await channel.invokeMethod<void>('dartReady').catchError((Object e) {
105 0 : developer.log('ERROR: dartReady signal failed: $e');
106 : });
107 :
108 : // Handle callback invocations
109 0 : channel.setMethodCallHandler((call) async {
110 0 : if (call.method == 'executeCallback') {
111 : // Guard against null arguments from native side.
112 : // null args indicates a platform channel protocol error or native
113 : // serialization failure — distinct from a worker with no input
114 : // (which arrives as args != null but args['input'] == null).
115 0 : final args = call.arguments as Map?;
116 : if (args == null) {
117 0 : developer.log(
118 : '[NativeWorkManager] CRITICAL: null arguments in executeCallback.\n'
119 : 'This is a platform channel protocol error, not a worker failure.\n'
120 : 'Check native logs (Logcat / Xcode console) for serialization errors.',
121 : level: 1000, // SHOUT — highest severity
122 : );
123 : return false;
124 : }
125 :
126 : // Extract callback handle and input
127 : // DART-005: Use num cast then toInt() to handle both int and int64 from native.
128 0 : final callbackId = args['callbackId'] as String? ?? '<unknown>';
129 0 : final callbackHandleRaw = args['callbackHandle'];
130 0 : final callbackHandle = callbackHandleRaw is int
131 : ? callbackHandleRaw
132 0 : : (callbackHandleRaw is num ? callbackHandleRaw.toInt() : null);
133 : if (callbackHandle == null) {
134 0 : developer.log(
135 : 'ERROR in _callbackDispatcher: missing or invalid callbackHandle '
136 0 : '(got ${callbackHandleRaw.runtimeType})');
137 : return false;
138 : }
139 0 : final inputJson = args['input'] as String?;
140 :
141 : try {
142 : // Convert handle back to callback function
143 0 : final callbackInfo = PluginUtilities.getCallbackFromHandle(
144 0 : CallbackHandle.fromRawHandle(callbackHandle),
145 : );
146 :
147 : if (callbackInfo == null) {
148 0 : throw StateError(
149 : 'Failed to resolve callback handle: $callbackHandle. '
150 : 'Ensure the callback is a top-level or static function.',
151 : );
152 : }
153 :
154 : // Validate function signature before invoking.
155 : // Dart's `as` cast does not check function signatures at cast-time — it
156 : // only fails when the function is called, producing a cryptic TypeError.
157 0 : if (callbackInfo is! DartWorkerCallback) {
158 0 : throw StateError(
159 0 : 'Callback handle $callbackHandle resolved to ${callbackInfo.runtimeType} '
160 : 'but DartWorkerCallback (Future<bool> Function(Map<String, dynamic>?)) was expected. '
161 : 'Ensure the registered function has the correct signature.',
162 : );
163 : }
164 : final callback = callbackInfo;
165 :
166 : // Parse input JSON if present
167 : Map<String, dynamic>? input;
168 : // Check if inputJson is literally the string "null" or empty, and treat it as no input
169 0 : if (inputJson != null && inputJson.isNotEmpty && inputJson != "null") {
170 : try {
171 0 : input = jsonDecode(inputJson) as Map<String, dynamic>;
172 : } catch (e) {
173 0 : throw FormatException(
174 0 : 'Failed to parse callback input JSON: "$inputJson"',
175 : inputJson,
176 : );
177 : }
178 : }
179 :
180 : // Execute the callback with a hard timeout to prevent indefinite hangs.
181 : // 25 s leaves a 5 s safety buffer within iOS BGAppRefreshTask's 30 s budget.
182 : // For longer work use Constraints(bgTaskType: BGTaskType.processing).
183 0 : final result = await callback(input).timeout(
184 : const Duration(seconds: 25),
185 0 : onTimeout: () {
186 0 : developer.log(
187 : '[NativeWorkManager] DartWorker callback "$callbackId" timed out '
188 : 'after 25 s. Consider:\n'
189 : ' • Breaking the work into smaller tasks.\n'
190 : ' • Using Constraints(bgTaskType: BGTaskType.processing) for '
191 : 'tasks that need up to 10 minutes (iOS).',
192 : level: 900,
193 : );
194 : return false;
195 : },
196 : );
197 :
198 : // Return execution result to native side
199 : return result;
200 : } catch (e, stackTrace) {
201 : // Log error for debugging
202 0 : developer.log('ERROR in _callbackDispatcher: $e');
203 0 : developer.log('Stack trace: $stackTrace');
204 :
205 : // Return false to indicate failure
206 : return false;
207 : }
208 : }
209 :
210 0 : throw MissingPluginException('Unknown method: ${call.method}');
211 : });
212 : }
213 :
214 : class NativeWorkManager {
215 0 : NativeWorkManager._();
216 :
217 : static bool _initialized = false;
218 : static bool _enforceHttps = false;
219 : static bool _blockPrivateIPs = false;
220 : static bool _registerPlugins = false;
221 :
222 : /// Whether HTTPS is enforced for all background HTTP tasks.
223 17 : @internal
224 : static bool get enforceHttps => _enforceHttps;
225 :
226 : /// Whether private/loopback IPs are blocked for background HTTP tasks.
227 17 : @internal
228 : static bool get blockPrivateIPs => _blockPrivateIPs;
229 :
230 : /// Whether plugins are registered in the background Flutter Engine.
231 1 : @internal
232 : static bool get registerPluginsEnabled => _registerPlugins;
233 :
234 : /// Internal testing only - resets security flags.
235 2 : @visibleForTesting
236 : static void resetSecurityFlags() {
237 : _enforceHttps = false;
238 : _blockPrivateIPs = false;
239 : _registerPlugins = false;
240 : }
241 :
242 : /// Internal testing only - resets initialized state so initialize() can be
243 : /// called again with different parameters in the same test process.
244 2 : @visibleForTesting
245 : static void resetInitializedState() {
246 : _initialized = false;
247 : _initCompleter = null;
248 : _enforceHttps = false;
249 : _blockPrivateIPs = false;
250 : _registerPlugins = false;
251 : }
252 :
253 : // Completer used to make concurrent initialize() calls wait on the first one
254 : // rather than racing past the _initialized flag check.
255 : static Completer<void>? _initCompleter;
256 15 : static final Map<String, DartWorkerCallback> _dartWorkers = {};
257 :
258 : // Observability
259 : static StreamSubscription<TaskEvent>? _observabilityEventSub;
260 : static StreamSubscription<TaskProgress>? _observabilityProgressSub;
261 :
262 : /// Map of callback IDs to their serializable handles.
263 : /// Handles can be passed across isolates, unlike function closures.
264 15 : static final Map<String, int> _callbackHandles = {};
265 :
266 : // ═══════════════════════════════════════════════════════════════════════════
267 : // INITIALIZATION
268 : // ═══════════════════════════════════════════════════════════════════════════
269 :
270 : /// Initialize the work manager.
271 : ///
272 : /// **REQUIRED:** Must be called before any other method, typically in `main()`.
273 : ///
274 : /// ## Basic Usage (Native Workers Only)
275 : ///
276 : /// ```dart
277 : /// void main() async {
278 : /// WidgetsFlutterBinding.ensureInitialized();
279 : /// await NativeWorkManager.initialize();
280 : /// runApp(MyApp());
281 : /// }
282 : /// ```
283 : ///
284 : /// ## Advanced Usage (With Dart Workers)
285 : ///
286 : /// ```dart
287 : /// void main() async {
288 : /// WidgetsFlutterBinding.ensureInitialized();
289 : /// await NativeWorkManager.initialize(
290 : /// dartWorkers: {
291 : /// 'customSync': (input) async {
292 : /// // Your custom Dart logic
293 : /// final data = await fetchDataFromLocalDb();
294 : /// await uploadToServer(data);
295 : /// return true; // true = success, false = failure
296 : /// },
297 : /// 'cleanup': (input) async {
298 : /// await cleanupOldFiles();
299 : /// return true;
300 : /// },
301 : /// },
302 : /// debugMode: true, // Shows notifications for all task events
303 : /// );
304 : /// runApp(MyApp());
305 : /// }
306 : /// ```
307 : ///
308 : /// ## Parameters
309 : ///
310 : /// **[dartWorkers]** - Optional map of callback IDs to worker functions.
311 : /// - Only needed if you want to run Dart code in background (Mode 2)
312 : /// - Each callback receives optional input data as `Map<String, dynamic>`
313 : /// - Must return `Future<bool>` (true = success, false = failure)
314 : /// - Callbacks run in a background isolate with Flutter Engine
315 : ///
316 : /// **[debugMode]** - Enable debug notifications (default: false).
317 : /// - Shows notifications when tasks complete with execution time
318 : /// - Displays success/failure status
319 : /// - **Only works in debug builds** - automatically disabled in release
320 : /// - Useful for development and debugging
321 : ///
322 : /// ## Platform Considerations
323 : ///
324 : /// **Android:**
325 : /// - No special setup required
326 : /// - Debug notifications use NotificationManager
327 : /// - Automatically creates debug notification channel
328 : ///
329 : /// **iOS:**
330 : /// - Debug mode requests notification permissions on first run
331 : /// - Uses UNUserNotificationCenter
332 : /// - BGTaskScheduler setup is automatic (reads Info.plist)
333 : ///
334 : /// ## Common Pitfalls
335 : ///
336 : /// ❌ **Don't** call initialize() multiple times - it's idempotent but wasteful
337 : /// ❌ **Don't** forget to call this before scheduling tasks
338 : /// ❌ **Don't** register anonymous functions as dartWorkers (won't work in background)
339 : /// ✅ **Do** call this in main() before runApp()
340 : /// ✅ **Do** use named top-level or static functions for dartWorkers
341 : ///
342 : /// ## See Also
343 : ///
344 : /// - [enqueue] - Schedule a background task
345 : /// - [DartWorker] - Create a Dart callback worker
346 : /// - [NativeWorker] - Create a native worker (no Flutter Engine)
347 5 : static Future<void> initialize({
348 : Map<String, DartWorkerCallback>? dartWorkers,
349 : bool debugMode = false,
350 : int maxConcurrentTasks = 4,
351 : int diskSpaceBufferMB = 20,
352 :
353 : /// Automatically delete completed/failed/cancelled task records older than
354 : /// this many days during initialize(). Set to 0 to disable auto-cleanup.
355 : /// Default: 30 days (prevents unbounded SQLite growth on long-running apps).
356 : int cleanupAfterDays = 30,
357 :
358 : /// When true, all HTTP workers reject plain HTTP URLs and only allow HTTPS.
359 : /// Useful for apps that require transport security across all background tasks.
360 : /// Default: false (backward compatible).
361 : bool enforceHttps = false,
362 :
363 : /// When true, HTTP workers block requests to private/loopback IP ranges
364 : /// (10.x, 172.16-31.x, 192.168.x, 127.x, ::1) to prevent SSRF attacks.
365 : /// Has no effect on hostnames — only parsed IP literals are checked.
366 : /// Default: false (backward compatible).
367 : bool blockPrivateIPs = false,
368 :
369 : /// When true, the background Flutter Engine will automatically register all
370 : /// plugins (calls GeneratedPluginRegistrant). This is required if you want
371 : /// to use other plugins (like flutter_local_notifications) inside your
372 : /// Dart workers.
373 : ///
374 : /// WARNING: Enabling this may increase RAM usage (~10-20MB) and may cause
375 : /// side-effects if other plugins perform cleanup when the background
376 : /// engine is destroyed (e.g. disconnecting Bluetooth or stopping audio).
377 : ///
378 : /// ALTERNATIVE: If you leave this false, you can still register specific
379 : /// plugins natively via `NativeWorkmanagerPlugin.setPluginRegistrantCallback`.
380 : ///
381 : /// Default: false (Zero-Engine I/O principle).
382 : bool registerPlugins = false,
383 : }) async {
384 : // If already initializing (concurrent calls), wait on the in-flight future.
385 3 : if (_initCompleter != null) return _initCompleter!.future;
386 : if (_initialized) return;
387 :
388 5 : _initCompleter = Completer<void>();
389 : try {
390 5 : await _initializeInternal(
391 : dartWorkers: dartWorkers,
392 : debugMode: debugMode,
393 : maxConcurrentTasks: maxConcurrentTasks,
394 : diskSpaceBufferMB: diskSpaceBufferMB,
395 : cleanupAfterDays: cleanupAfterDays,
396 : enforceHttps: enforceHttps,
397 : blockPrivateIPs: blockPrivateIPs,
398 : registerPlugins: registerPlugins,
399 : );
400 : _initialized = true;
401 5 : _initCompleter!.complete();
402 : } catch (e, st) {
403 : final completer = _initCompleter;
404 : _initCompleter = null; // allow retry on failure
405 0 : completer?.completeError(e, st);
406 : rethrow;
407 : }
408 : }
409 :
410 5 : static Future<void> _initializeInternal({
411 : Map<String, DartWorkerCallback>? dartWorkers,
412 : bool debugMode = false,
413 : int maxConcurrentTasks = 4,
414 : int diskSpaceBufferMB = 20,
415 : int cleanupAfterDays = 30,
416 : bool enforceHttps = false,
417 : bool blockPrivateIPs = false,
418 : bool registerPlugins = false,
419 : }) async {
420 : _enforceHttps = enforceHttps;
421 : _blockPrivateIPs = blockPrivateIPs;
422 : _registerPlugins = registerPlugins;
423 :
424 : // Phase 2: Register DevTools Extension Service
425 5 : registerDevToolsExtensions();
426 :
427 : // Register Dart workers and compute their handles
428 : if (dartWorkers != null) {
429 2 : _dartWorkers.addAll(dartWorkers);
430 :
431 : // Compute callback handles for each worker
432 2 : for (final entry in dartWorkers.entries) {
433 1 : final callbackId = entry.key;
434 1 : final callback = entry.value;
435 :
436 : // Get the handle for this callback
437 1 : final handle = PluginUtilities.getCallbackHandle(callback);
438 :
439 : if (handle == null) {
440 0 : throw StateError(
441 : 'Failed to get callback handle for "$callbackId". '
442 : 'Ensure the callback is a top-level or static function, '
443 : 'NOT an anonymous function or instance method.\n'
444 : '\n'
445 : 'Example CORRECT:\n'
446 : ' Future<bool> myCallback(Map<String, dynamic>? input) async { ... }\n'
447 : ' dartWorkers: {"myCallback": myCallback}\n'
448 : '\n'
449 : 'Example WRONG:\n'
450 : ' dartWorkers: {"bad": (input) async => true} // Anonymous function!',
451 : );
452 : }
453 :
454 3 : _callbackHandles[callbackId] = handle.toRawHandle();
455 : }
456 : }
457 :
458 : // Set up callback executor
459 10 : NativeWorkManagerPlatform.instance.setCallbackExecutor(
460 : _executeDartCallback,
461 : );
462 :
463 : // Set up chain enqueue callback
464 : TaskChainBuilder.enqueueCallback = _enqueueChain;
465 :
466 : // Get callback handle for the dispatcher if any Dart workers registered
467 : int? callbackHandle;
468 1 : if (dartWorkers != null && dartWorkers.isNotEmpty) {
469 : // Get handle of the callback dispatcher
470 1 : callbackHandle = PluginUtilities.getCallbackHandle(
471 : _callbackDispatcher,
472 1 : )?.toRawHandle();
473 : }
474 :
475 : // Initialize platform with config.
476 10 : await NativeWorkManagerPlatform.instance.initialize(
477 : callbackHandle: callbackHandle,
478 : debugMode: debugMode,
479 : maxConcurrentTasks: maxConcurrentTasks,
480 : diskSpaceBufferMB: diskSpaceBufferMB,
481 : cleanupAfterDays: cleanupAfterDays,
482 : enforceHttps: enforceHttps,
483 : blockPrivateIPs: blockPrivateIPs,
484 : registerPlugins: registerPlugins,
485 : );
486 : // _initialized is set by the caller (initialize()) after this returns.
487 : }
488 :
489 5 : static void _checkInitialized() {
490 : if (!_initialized) {
491 2 : throw StateError(
492 : 'NativeWorkManager not initialized. '
493 : 'Call NativeWorkManager.initialize() first.',
494 : );
495 : }
496 : }
497 :
498 0 : static Future<bool> _executeDartCallback(
499 : String callbackId,
500 : Map<String, dynamic>? input,
501 : ) async {
502 0 : final callback = _dartWorkers[callbackId];
503 : if (callback == null) {
504 0 : throw StateError('No Dart worker registered for: $callbackId');
505 : }
506 :
507 0 : return callback(input);
508 : }
509 :
510 : // ═══════════════════════════════════════════════════════════════════════════
511 : // TASK SCHEDULING
512 : // ═══════════════════════════════════════════════════════════════════════════
513 :
514 : /// Schedule a background task.
515 : ///
516 : /// This is the primary method for scheduling work to be executed in the background.
517 : /// Tasks can be one-time, periodic, or triggered by specific conditions.
518 : ///
519 : /// ## Basic Examples
520 : ///
521 : /// **Immediate one-time task:**
522 : /// ```dart
523 : /// await NativeWorkManager.enqueue(
524 : /// taskId: 'quick-sync',
525 : /// trigger: TaskTrigger.oneTime(),
526 : /// worker: NativeWorker.httpRequest(
527 : /// url: 'https://api.example.com/ping',
528 : /// method: HttpMethod.post,
529 : /// ),
530 : /// );
531 : /// ```
532 : ///
533 : /// **Delayed task:**
534 : /// ```dart
535 : /// await NativeWorkManager.enqueue(
536 : /// taskId: 'delayed-upload',
537 : /// trigger: TaskTrigger.oneTime(delay: Duration(minutes: 15)),
538 : /// worker: NativeWorker.httpUpload(
539 : /// url: 'https://api.example.com/upload',
540 : /// filePath: '/path/to/file.jpg',
541 : /// ),
542 : /// );
543 : /// ```
544 : ///
545 : /// **Periodic task (every hour):**
546 : /// ```dart
547 : /// await NativeWorkManager.enqueue(
548 : /// taskId: 'hourly-sync',
549 : /// trigger: TaskTrigger.periodic(Duration(hours: 1)),
550 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
551 : /// constraints: Constraints.networkRequired,
552 : /// );
553 : /// ```
554 : ///
555 : /// **Task with constraints:**
556 : /// ```dart
557 : /// await NativeWorkManager.enqueue(
558 : /// taskId: 'battery-safe-task',
559 : /// trigger: TaskTrigger.oneTime(),
560 : /// worker: NativeWorker.httpDownload(
561 : /// url: 'https://example.com/large-file.zip',
562 : /// savePath: '/path/to/save.zip',
563 : /// ),
564 : /// constraints: Constraints(
565 : /// requiresCharging: true,
566 : /// requiresWifi: true,
567 : /// ),
568 : /// );
569 : /// ```
570 : ///
571 : /// **Tagged tasks for bulk operations:**
572 : /// ```dart
573 : /// // Schedule multiple tasks with same tag
574 : /// for (var i = 0; i < 5; i++) {
575 : /// await NativeWorkManager.enqueue(
576 : /// taskId: 'upload-$i',
577 : /// trigger: TaskTrigger.oneTime(),
578 : /// worker: NativeWorker.httpUpload(
579 : /// url: 'https://api.example.com/upload',
580 : /// filePath: '/path/to/file$i.jpg',
581 : /// ),
582 : /// tag: 'batch-upload', // Same tag for all
583 : /// );
584 : /// }
585 : ///
586 : /// // Later, cancel all at once
587 : /// await NativeWorkManager.cancelByTag(tag: 'batch-upload');
588 : /// ```
589 : ///
590 : /// ## Parameters
591 : ///
592 : /// **[taskId]** *(required)* - Unique identifier for the task.
593 : /// - Must not be empty
594 : /// - Used to cancel, query, or update the task
595 : /// - If duplicate ID exists, behavior depends on [existingPolicy]
596 : ///
597 : /// **[trigger]** *(required)* - When the task should execute.
598 : /// - `TaskTrigger.oneTime()` - Execute once (optionally with delay)
599 : /// - `TaskTrigger.periodic(duration)` - Repeat every duration (min 15 minutes)
600 : /// - See [TaskTrigger] for all options
601 : ///
602 : /// **[worker]** *(required)* - What work to perform.
603 : /// - `NativeWorker.*` - Native workers (no Flutter Engine, fast startup)
604 : /// - `DartWorker` - Run Dart code (requires Flutter Engine)
605 : /// - See [NativeWorker] and [DartWorker] for details
606 : ///
607 : /// **[constraints]** *(optional)* - Execution conditions (default: no constraints).
608 : /// - `Constraints.networkRequired` - Requires any network
609 : /// - `Constraints.heavyTask` - Requires charging + WiFi
610 : /// - Custom: `Constraints(requiresCharging: true, ...)`
611 : /// - See [Constraints] for all options
612 : ///
613 : /// **[existingPolicy]** *(optional)* - Handle duplicate task IDs (default: replace).
614 : /// - `ExistingTaskPolicy.replace` - Cancel old, schedule new
615 : /// - `ExistingTaskPolicy.keep` - Keep old, ignore new
616 : ///
617 : /// **[tag]** *(optional)* - Group related tasks for bulk operations.
618 : /// - Must not be empty string (use null if no tag)
619 : /// - Use with [cancelByTag] and [getTasksByTag]
620 : /// - Multiple tasks can share the same tag
621 : ///
622 : /// ## Platform Considerations
623 : ///
624 : /// **Android:**
625 : /// - Periodic tasks have minimum interval of 15 minutes (OS limitation)
626 : /// - Constraints enforced by WorkManager
627 : /// - Exact timing not guaranteed (OS may defer tasks)
628 : ///
629 : /// **iOS:**
630 : /// - Periodic tasks use BGAppRefreshTask (runs opportunistically)
631 : /// - One-time tasks use BGProcessingTask
632 : /// - Task may not run immediately even without delay
633 : /// - Charging/battery constraints are advisory only
634 : ///
635 : /// ## Common Pitfalls
636 : ///
637 : /// ❌ **Don't** use periodic intervals < 15 minutes (will throw error)
638 : /// ❌ **Don't** expect exact timing - OS may defer tasks
639 : /// ❌ **Don't** use empty string for tag (use null instead)
640 : /// ❌ **Don't** schedule too many tasks (performance impact)
641 : /// ✅ **Do** use tags for managing related tasks
642 : /// ✅ **Do** use constraints to optimize battery life
643 : /// ✅ **Do** handle task failure gracefully
644 : ///
645 : /// ## Error Handling
646 : ///
647 : /// This method may throw:
648 : /// - `ArgumentError` - Invalid parameters (empty taskId, invalid URL, etc.)
649 : /// - `StateError` - NativeWorkManager not initialized or unregistered DartWorker
650 : ///
651 : /// ## Returns
652 : ///
653 : /// A [TaskHandler] which allows tracking progress and completion of this specific task,
654 : /// and contains the [ScheduleResult] from the OS.
655 : ///
656 : /// ## See Also
657 : ///
658 : /// - [TaskHandler] - Controller for tracking task progress and result
659 : /// - [cancel] - Cancel a specific task
660 : /// - [cancelByTag] - Cancel all tasks with a tag
661 : /// - [cancelAll] - Cancel all scheduled tasks
662 : /// - [NativeWorkManager.getTaskStatus] - Check task status
663 : /// - [TaskTrigger] - Available trigger types
664 : /// - [Constraints] - Available constraints
665 3 : static Future<TaskHandler> enqueue({
666 : required String taskId,
667 : TaskTrigger trigger = const TaskTrigger.oneTime(),
668 : required Worker worker,
669 : Constraints constraints = const Constraints(),
670 : ExistingTaskPolicy existingPolicy = ExistingTaskPolicy.replace,
671 : String? tag,
672 : }) async {
673 3 : _checkInitialized();
674 :
675 : // Validation
676 3 : if (taskId.isEmpty) {
677 0 : throw ArgumentError(
678 : 'taskId cannot be empty. '
679 0 : 'Use a unique identifier like "sync-${DateTime.now().millisecondsSinceEpoch}"',
680 : );
681 : }
682 :
683 : // Validate tag if provided
684 0 : if (tag != null && tag.isEmpty) {
685 0 : throw ArgumentError(
686 : 'tag cannot be empty string. '
687 : 'Either provide a valid tag or omit the parameter.',
688 : );
689 : }
690 :
691 : // Validate periodic trigger interval (Android minimum)
692 3 : if (trigger is PeriodicTrigger) {
693 2 : if (trigger.interval < const Duration(minutes: 15)) {
694 2 : throw ArgumentError(
695 : 'Periodic interval must be at least 15 minutes on Android.\n'
696 2 : 'Current: ${trigger.interval.inMinutes} minutes\n'
697 : 'Minimum: 15 minutes\n'
698 : 'Use TaskTrigger.oneTime() for immediate execution.',
699 : );
700 : }
701 3 : if (trigger.initialDelay != null && trigger.initialDelay!.isNegative) {
702 2 : throw ArgumentError(
703 : 'initialDelay cannot be negative.\n'
704 1 : 'Current: ${trigger.initialDelay}',
705 : );
706 : }
707 : }
708 :
709 : // ExactTrigger is Android-only. BGTaskScheduler on iOS does not support
710 : // exact alarm scheduling — tasks fire within an OS-determined window that
711 : // can be hours late. Throw early so developers get a clear error rather
712 : // than silent misfires that are hard to debug.
713 3 : if (trigger is ExactTrigger &&
714 0 : defaultTargetPlatform == TargetPlatform.iOS) {
715 0 : throw UnsupportedError(
716 : 'ExactTrigger is not supported on iOS.\n'
717 : 'BGTaskScheduler cannot guarantee exact timing — tasks may run '
718 : 'hours after the scheduled time depending on OS conditions.\n'
719 : 'Alternatives:\n'
720 : ' • TaskTrigger.windowed() — approximate window (recommended)\n'
721 : ' • TaskTrigger.oneTime() — run as soon as possible\n'
722 : ' • Local notifications (flutter_local_notifications) for user-visible alarms',
723 : );
724 : }
725 :
726 : // Validate DartWorker registration and prepare worker data
727 : Worker workerToEnqueue = worker;
728 : Constraints finalConstraints = constraints;
729 :
730 3 : if (worker is DartWorker) {
731 6 : if (!_dartWorkers.containsKey(worker.callbackId)) {
732 0 : throw StateError(
733 0 : 'Dart worker "${worker.callbackId}" not registered.\n'
734 : 'Register it in NativeWorkManager.initialize():\n'
735 : ' await NativeWorkManager.initialize(\n'
736 : ' dartWorkers: {\n'
737 0 : ' "${worker.callbackId}": (input) async { ... },\n'
738 : ' },\n'
739 : ' );',
740 : );
741 : }
742 :
743 : // iOS safety: DartWorkers are heavy by definition because they spin up
744 : // a Flutter Engine. Force BGProcessingTask (60s+) instead of
745 : // BGAppRefreshTask (30s) to prevent immediate OS kills.
746 4 : if (defaultTargetPlatform == TargetPlatform.iOS &&
747 2 : !constraints.isHeavyTask) {
748 2 : finalConstraints = constraints.copyWith(isHeavyTask: true);
749 2 : developer.log(
750 : 'NativeWorkManager: DartWorker on iOS detected. Promoting to heavy task '
751 : '(isHeavyTask=true) to prevent OS termination.',
752 : name: 'NativeWorkManager',
753 : );
754 : }
755 :
756 : // Get the callback handle for this worker
757 6 : final callbackHandle = _callbackHandles[worker.callbackId];
758 : if (callbackHandle == null) {
759 0 : throw StateError(
760 0 : 'INTERNAL ERROR: Callback handle not found for "${worker.callbackId}". '
761 : 'This should never happen. Please report this bug.',
762 : );
763 : }
764 :
765 : // Create enhanced DartWorker with callback handle
766 2 : workerToEnqueue = DartWorkerInternal(
767 2 : callbackId: worker.callbackId,
768 : callbackHandle: callbackHandle,
769 2 : input: worker.input,
770 2 : autoDispose: worker.autoDispose,
771 2 : timeoutMs: worker.timeoutMs,
772 : );
773 : }
774 :
775 6 : final scheduleResult = await NativeWorkManagerPlatform.instance.enqueue(
776 : taskId: taskId,
777 : trigger: trigger,
778 : worker: workerToEnqueue,
779 : constraints: finalConstraints,
780 : existingPolicy: existingPolicy,
781 : tag: tag,
782 : );
783 :
784 3 : return TaskHandler(
785 : taskId: taskId,
786 : scheduleResult: scheduleResult,
787 : );
788 : }
789 :
790 : /// Cancel all tasks with a specific tag.
791 : ///
792 : /// This is the recommended way to cancel groups of related tasks. Much more
793 : /// efficient than canceling tasks individually.
794 : ///
795 : /// ## Example - Batch Upload Cancellation
796 : ///
797 : /// ```dart
798 : /// // Schedule multiple upload tasks with same tag
799 : /// for (var i = 0; i < 10; i++) {
800 : /// await NativeWorkManager.enqueue(
801 : /// taskId: 'upload-$i',
802 : /// trigger: TaskTrigger.oneTime(),
803 : /// worker: NativeWorker.httpUpload(
804 : /// url: 'https://api.example.com/upload',
805 : /// filePath: files[i],
806 : /// ),
807 : /// tag: 'batch-upload',
808 : /// );
809 : /// }
810 : ///
811 : /// // User cancels upload - cancel all 10 tasks at once
812 : /// await NativeWorkManager.cancelByTag(tag: 'batch-upload');
813 : /// ```
814 : ///
815 : /// ## Example - Feature-Based Cancellation
816 : ///
817 : /// ```dart
818 : /// // Tag tasks by feature
819 : /// await NativeWorkManager.enqueue(
820 : /// taskId: 'photo-sync',
821 : /// trigger: TaskTrigger.periodic(Duration(hours: 6)),
822 : /// worker: ...,
823 : /// tag: 'photo-feature',
824 : /// );
825 : ///
826 : /// await NativeWorkManager.enqueue(
827 : /// taskId: 'photo-cleanup',
828 : /// trigger: TaskTrigger.periodic(Duration(days: 1)),
829 : /// worker: ...,
830 : /// tag: 'photo-feature',
831 : /// );
832 : ///
833 : /// // User disables photo feature - cancel all related tasks
834 : /// await NativeWorkManager.cancelByTag(tag: 'photo-feature');
835 : /// ```
836 : ///
837 : /// ## Parameters
838 : ///
839 : /// **[tag]** *(required)* - The tag to match.
840 : /// - Must not be empty
841 : /// - Case-sensitive exact match
842 : /// - Only tasks with exactly this tag will be canceled
843 : ///
844 : /// ## Behavior
845 : ///
846 : /// - Cancels ALL tasks that have the specified tag
847 : /// - Does nothing if no tasks have the tag (no error thrown)
848 : /// - Tasks are canceled immediately (won't execute even if scheduled)
849 : /// - Running tasks may complete before cancellation takes effect
850 : ///
851 : /// ## Platform Notes
852 : ///
853 : /// **Android:** Uses WorkManager.cancelAllWorkByTag()
854 : /// **iOS:** Cancels all BGTaskRequest instances with matching identifier prefix
855 : ///
856 : /// ## See Also
857 : ///
858 : /// - [cancel] - Cancel a specific task by ID
859 : /// - [cancelAll] - Cancel all scheduled tasks
860 : /// - [getTasksByTag] - Query tasks by tag
861 0 : static Future<void> cancelByTag({required String tag}) async {
862 0 : _checkInitialized();
863 :
864 0 : if (tag.isEmpty) {
865 0 : throw ArgumentError('tag cannot be empty');
866 : }
867 :
868 0 : return NativeWorkManagerPlatform.instance.cancelByTag(tag: tag);
869 : }
870 :
871 : /// Get all tasks with a specific tag.
872 : ///
873 : /// Returns a list of task IDs that have the given tag.
874 : ///
875 : /// ```dart
876 : /// List<String> syncTasks = await NativeWorkManager.getTasksByTag(tag: 'sync-group');
877 : /// print('Found ${syncTasks.length} sync tasks');
878 : /// ```
879 0 : static Future<List<String>> getTasksByTag({required String tag}) async {
880 0 : _checkInitialized();
881 :
882 0 : if (tag.isEmpty) {
883 0 : throw ArgumentError('tag cannot be empty');
884 : }
885 :
886 0 : return NativeWorkManagerPlatform.instance.getTasksByTag(tag: tag);
887 : }
888 :
889 : /// Get all tags currently in use.
890 : ///
891 : /// Returns a list of all unique tags that have been assigned to tasks.
892 : ///
893 : /// ```dart
894 : /// List<String> allTags = await NativeWorkManager.getAllTags();
895 : /// print('Active tag groups: $allTags');
896 : /// ```
897 0 : static Future<List<String>> getAllTags() async {
898 0 : _checkInitialized();
899 0 : return NativeWorkManagerPlatform.instance.getAllTags();
900 : }
901 :
902 : /// Cancel a specific task by its ID.
903 : ///
904 : /// Use this to cancel individual tasks. For canceling multiple related tasks,
905 : /// consider using [cancelByTag] instead.
906 : ///
907 : /// ## Example
908 : ///
909 : /// ```dart
910 : /// // Schedule a task
911 : /// await NativeWorkManager.enqueue(
912 : /// taskId: 'daily-sync',
913 : /// trigger: TaskTrigger.periodic(Duration(days: 1)),
914 : /// worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
915 : /// );
916 : ///
917 : /// // Later, cancel it
918 : /// await NativeWorkManager.cancel(taskId: 'daily-sync');
919 : /// ```
920 : ///
921 : /// ## Parameters
922 : ///
923 : /// **[taskId]** *(required)* - The ID of the task to cancel.
924 : ///
925 : /// ## Behavior
926 : ///
927 : /// - Cancels the task with the specified ID
928 : /// - Does nothing if task doesn't exist (no error thrown)
929 : /// - Task won't execute after cancellation
930 : /// - If task is currently running, may complete before cancellation takes effect
931 : ///
932 : /// ## See Also
933 : ///
934 : /// - [cancelByTag] - Cancel multiple tasks by tag
935 : /// - [cancelAll] - Cancel all tasks
936 2 : static Future<void> cancel({required String taskId}) async {
937 2 : _checkInitialized();
938 0 : return NativeWorkManagerPlatform.instance.cancel(taskId: taskId);
939 : }
940 :
941 : /// Cancel all scheduled tasks.
942 : ///
943 : /// **Use with caution!** This cancels every task managed by NativeWorkManager,
944 : /// including periodic tasks, delayed tasks, and tasks from other parts of your app.
945 : ///
946 : /// ## Example - App Logout
947 : ///
948 : /// ```dart
949 : /// // User logs out - cancel all background sync tasks
950 : /// await NativeWorkManager.cancelAll();
951 : /// ```
952 : ///
953 : /// ## Example - Reset App State
954 : ///
955 : /// ```dart
956 : /// Future<void> resetApp() async {
957 : /// // Clear all background tasks
958 : /// await NativeWorkManager.cancelAll();
959 : ///
960 : /// // Clear local data
961 : /// await clearLocalDatabase();
962 : ///
963 : /// // Restart app
964 : /// await restartApp();
965 : /// }
966 : /// ```
967 : ///
968 : /// ## Behavior
969 : ///
970 : /// - Cancels ALL tasks regardless of ID, tag, or status
971 : /// - Does not throw error if no tasks exist
972 : /// - Running tasks may complete before cancellation takes effect
973 : /// - This is a destructive operation - tasks cannot be recovered
974 : ///
975 : /// ## Alternatives
976 : ///
977 : /// Consider these alternatives for more granular control:
978 : /// - Use [cancelByTag] to cancel groups of related tasks
979 : /// - Use [cancel] to cancel specific tasks
980 : ///
981 : /// ## Platform Notes
982 : ///
983 : /// **Android:** Calls WorkManager.cancelAllWork()
984 : /// **iOS:** Cancels all pending BGTaskRequest instances
985 : ///
986 : /// ## See Also
987 : ///
988 : /// - [cancel] - Cancel specific task
989 : /// - [cancelByTag] - Cancel tasks by tag
990 1 : static Future<void> cancelAll() async {
991 1 : _checkInitialized();
992 0 : return NativeWorkManagerPlatform.instance.cancelAll();
993 : }
994 :
995 : /// Report progress from inside a [DartWorker] callback.
996 : ///
997 : /// Call this from your [DartWorkerCallback] to emit progress events that
998 : /// will appear in [NativeWorkManager.progress] on the UI side.
999 : ///
1000 : /// The [taskId] is injected into the callback input map under the key
1001 : /// `'__taskId'`. Read it and forward to this method:
1002 : ///
1003 : /// ```dart
1004 : /// await NativeWorkManager.initialize(
1005 : /// dartWorkers: {
1006 : /// 'processFiles': (input) async {
1007 : /// final taskId = input?['__taskId'] as String?;
1008 : /// for (var i = 1; i <= 10; i++) {
1009 : /// await processFile(i);
1010 : /// await NativeWorkManager.reportDartWorkerProgress(
1011 : /// taskId: taskId,
1012 : /// progress: (i / 10 * 100).round(),
1013 : /// message: 'Processing file $i / 10',
1014 : /// );
1015 : /// }
1016 : /// return true;
1017 : /// },
1018 : /// },
1019 : /// );
1020 : /// ```
1021 : ///
1022 : /// **Thread safety:** Safe to call from any isolate that has access to the
1023 : /// `dev.brewkits/dart_worker_channel` MethodChannel (i.e., the background
1024 : /// isolate spawned by DartWorker execution).
1025 : ///
1026 : /// Does nothing if [taskId] is null or empty (avoids need for null checks in
1027 : /// callers that cannot guarantee a taskId is present).
1028 1 : static Future<void> reportDartWorkerProgress({
1029 : String? taskId,
1030 : required int progress,
1031 : String? message,
1032 : }) async {
1033 1 : if (taskId == null || taskId.isEmpty) return;
1034 : const channel = MethodChannel('dev.brewkits/dart_worker_channel');
1035 0 : await channel.invokeMethod<void>('reportProgress', <String, Object?>{
1036 0 : 'taskId': taskId,
1037 0 : 'progress': progress.clamp(0, 100),
1038 0 : if (message != null) 'message': message,
1039 : });
1040 : }
1041 :
1042 : /// Get the current status of a task.
1043 : ///
1044 : /// Query the execution state of a specific task. Useful for showing
1045 : /// task progress in your UI or debugging task execution.
1046 : ///
1047 : /// ## Example - Show Upload Status
1048 : ///
1049 : /// ```dart
1050 : /// final status = await NativeWorkManager.getTaskStatus(taskId: 'photo-upload');
1051 : ///
1052 : /// switch (status) {
1053 : /// case TaskStatus.enqueued:
1054 : /// print('Upload is waiting to start');
1055 : /// break;
1056 : /// case TaskStatus.running:
1057 : /// print('Upload in progress...');
1058 : /// break;
1059 : /// case TaskStatus.succeeded:
1060 : /// print('Upload complete!');
1061 : /// break;
1062 : /// case TaskStatus.failed:
1063 : /// print('Upload failed');
1064 : /// break;
1065 : /// case TaskStatus.cancelled:
1066 : /// print('Upload was canceled');
1067 : /// break;
1068 : /// case null:
1069 : /// print('Task not found');
1070 : /// break;
1071 : /// }
1072 : /// ```
1073 : ///
1074 : /// ## Example - UI Integration
1075 : ///
1076 : /// ```dart
1077 : /// class UploadStatusWidget extends StatefulWidget {
1078 : /// @override
1079 : /// State createState() => _UploadStatusWidgetState();
1080 : /// }
1081 : ///
1082 : /// class _UploadStatusWidgetState extends State<UploadStatusWidget> {
1083 : /// TaskStatus? _status;
1084 : ///
1085 : /// @override
1086 : /// void initState() {
1087 : /// super.initState();
1088 : /// _checkStatus();
1089 : /// Timer.periodic(Duration(seconds: 1), (_) => _checkStatus());
1090 : /// }
1091 : ///
1092 : /// Future<void> _checkStatus() async {
1093 : /// final status = await NativeWorkManager.getTaskStatus(taskId: 'upload');
1094 : /// setState(() => _status = status);
1095 : /// }
1096 : ///
1097 : /// @override
1098 : /// Widget build(BuildContext context) {
1099 : /// if (_status == TaskStatus.running) {
1100 : /// return CircularProgressIndicator();
1101 : /// }
1102 : /// return Text('Status: ${_status ?? 'Not found'}');
1103 : /// }
1104 : /// }
1105 : /// ```
1106 : ///
1107 : /// ## Parameters
1108 : ///
1109 : /// **[taskId]** *(required)* - The ID of the task to query.
1110 : ///
1111 : /// ## Returns
1112 : ///
1113 : /// A [TaskStatus] enum value, or `null` if the task doesn't exist:
1114 : /// - `TaskStatus.enqueued` - Task is scheduled but not yet started
1115 : /// - `TaskStatus.running` - Task is currently executing
1116 : /// - `TaskStatus.succeeded` - Task completed successfully
1117 : /// - `TaskStatus.failed` - Task failed with an error
1118 : /// - `TaskStatus.cancelled` - Task was canceled
1119 : /// - `null` - Task not found (may have been canceled or completed long ago)
1120 : ///
1121 : /// ## Platform Considerations
1122 : ///
1123 : /// **Android:**
1124 : /// - Status reflects WorkManager's WorkInfo state
1125 : /// - Completed tasks remain queryable for some time
1126 : ///
1127 : /// **iOS:**
1128 : /// - Status may not be available for completed/failed tasks
1129 : /// - Returns null if task is not in pending queue
1130 : ///
1131 : /// ## Common Pitfalls
1132 : ///
1133 : /// ❌ **Don't** poll this method too frequently (impacts performance)
1134 : /// ❌ **Don't** rely on this for completed tasks (may return null)
1135 : /// ✅ **Do** use [events] stream for real-time task completion notifications
1136 : /// ✅ **Do** cache status locally if polling frequently
1137 : ///
1138 : /// ## See Also
1139 : ///
1140 : /// - [events] - Stream of task completion events
1141 : /// - [progress] - Stream of task progress updates
1142 : /// Get task status.
1143 0 : static Future<TaskStatus?> getTaskStatus({required String taskId}) async {
1144 0 : _checkInitialized();
1145 0 : return NativeWorkManagerPlatform.instance.getTaskStatus(taskId: taskId);
1146 : }
1147 :
1148 : /// Get detailed task record.
1149 0 : static Future<TaskRecord?> getTaskRecord({required String taskId}) async {
1150 0 : _checkInitialized();
1151 0 : return NativeWorkManagerPlatform.instance.getTaskRecord(taskId: taskId);
1152 : }
1153 :
1154 : // ═══════════════════════════════════════════════════════════════════════════
1155 : // PAUSE / RESUME
1156 : // ═══════════════════════════════════════════════════════════════════════════
1157 :
1158 : /// Pause a running download task.
1159 : ///
1160 : /// On **Android**, pausing cancels the WorkManager job but preserves the
1161 : /// partial `.tmp` file so that a subsequent [resume] call can re-enqueue
1162 : /// the worker and OkHttp will send a `Range:` header to resume from the
1163 : /// last downloaded byte. The task is stored in the SQLite task store with
1164 : /// status `"paused"`.
1165 : ///
1166 : /// On **iOS**, the underlying `URLSessionDownloadTask` is cancelled with
1167 : /// resume data. If resume data is available, [resume] will call
1168 : /// `resumeDownload(with:)` to continue from where it left off. If not
1169 : /// (e.g. the download had not yet started), [resume] re-starts from scratch
1170 : /// using the config saved in the task store.
1171 : ///
1172 : /// **Note:** Only `HttpDownloadWorker` tasks support pause/resume. For other
1173 : /// worker types the call is a no-op on the native side.
1174 0 : static Future<void> pause({required String taskId}) async {
1175 0 : _checkInitialized();
1176 0 : return NativeWorkManagerPlatform.instance.pauseTask(taskId: taskId);
1177 : }
1178 :
1179 : /// Resume a previously paused download task.
1180 : ///
1181 : /// The task must have been paused via [pause] before calling this method.
1182 : /// See [pause] for platform-specific behaviour.
1183 0 : static Future<void> resume({required String taskId}) async {
1184 0 : _checkInitialized();
1185 0 : return NativeWorkManagerPlatform.instance.resumeTask(taskId: taskId);
1186 : }
1187 :
1188 : /// Pause all running tasks that share a given tag.
1189 : ///
1190 : /// Looks up every task with [tag] via [getTasksByTag] then calls [pause]
1191 : /// on each one concurrently. Completed, failed, or already-paused tasks
1192 : /// are silently skipped on the native side.
1193 : ///
1194 : /// ```dart
1195 : /// // Pause all active downloads in the "media" group
1196 : /// await NativeWorkManager.pauseByTag('media');
1197 : /// ```
1198 0 : static Future<void> pauseByTag({required String tag}) async {
1199 0 : _checkInitialized();
1200 : final taskIds =
1201 0 : await NativeWorkManagerPlatform.instance.getTasksByTag(tag: tag);
1202 0 : await Future.wait(
1203 0 : taskIds.map(
1204 0 : (id) => NativeWorkManagerPlatform.instance.pauseTask(taskId: id)),
1205 : );
1206 : }
1207 :
1208 : /// Resume all paused tasks that share a given tag.
1209 : ///
1210 : /// Looks up every task with [tag] via [getTasksByTag] then calls [resume]
1211 : /// on each one concurrently. Only tasks in the `paused` state will
1212 : /// actually restart; other tasks are silently skipped on the native side.
1213 : ///
1214 : /// ```dart
1215 : /// // Resume all downloads in the "media" group
1216 : /// await NativeWorkManager.resumeByTag('media');
1217 : /// ```
1218 0 : static Future<void> resumeByTag({required String tag}) async {
1219 0 : _checkInitialized();
1220 : final taskIds =
1221 0 : await NativeWorkManagerPlatform.instance.getTasksByTag(tag: tag);
1222 0 : await Future.wait(
1223 0 : taskIds.map(
1224 0 : (id) => NativeWorkManagerPlatform.instance.resumeTask(taskId: id)),
1225 : );
1226 : }
1227 :
1228 : // ═══════════════════════════════════════════════════════════════════════════
1229 : // TASK STORE
1230 : // ═══════════════════════════════════════════════════════════════════════════
1231 :
1232 : /// Return all tasks from the persistent SQLite task store, newest first.
1233 : ///
1234 : /// Each [TaskRecord] contains the task ID, tag, current status, worker class
1235 : /// name, and timestamps. The store is updated automatically when tasks are
1236 : /// enqueued, completed, failed, cancelled, or paused.
1237 : ///
1238 : /// ```dart
1239 : /// final tasks = await NativeWorkManager.allTasks();
1240 : /// for (final t in tasks) {
1241 : /// print('${t.taskId}: ${t.status}');
1242 : /// }
1243 : /// ```
1244 0 : static Future<List<TaskRecord>> allTasks() async {
1245 0 : _checkInitialized();
1246 0 : return NativeWorkManagerPlatform.instance.allTasks();
1247 : }
1248 :
1249 : /// Fetch the server-suggested filename for a URL by sending a HEAD request
1250 : /// and parsing the `Content-Disposition` header (RFC 6266).
1251 : ///
1252 : /// Returns the sanitized filename string, or `null` if the server did not
1253 : /// provide a `Content-Disposition: attachment; filename=…` header.
1254 : ///
1255 : /// Prefer `filename*=UTF-8''…` (RFC 5987 percent-encoded) when present.
1256 : ///
1257 : /// ```dart
1258 : /// final name = await NativeWorkManager.getServerFilename(
1259 : /// 'https://files.example.com/report.pdf',
1260 : /// );
1261 : /// // name == 'Q4_Report_2024.pdf'
1262 : ///
1263 : /// await NativeWorkManager.enqueue(
1264 : /// taskId: 'dl-report',
1265 : /// trigger: const TaskTrigger.oneTime(),
1266 : /// worker: NativeWorker.httpDownload(
1267 : /// url: 'https://files.example.com/report.pdf',
1268 : /// savePath: '/path/to/downloads/$name',
1269 : /// ),
1270 : /// );
1271 : /// ```
1272 1 : static Future<String?> getServerFilename(
1273 : String url, {
1274 : Map<String, String>? headers,
1275 : int timeoutMs = 30000,
1276 : }) {
1277 1 : _checkInitialized();
1278 0 : return NativeWorkManagerPlatform.instance.getServerFilename(
1279 : url: url,
1280 : headers: headers,
1281 : timeoutMs: timeoutMs,
1282 : );
1283 : }
1284 :
1285 : /// Get the current progress of all running tasks.
1286 : ///
1287 : /// Useful for restoring UI state when the app restarts, allowing you to
1288 : /// "re-attach" to tasks that are still executing in the background.
1289 : ///
1290 : /// Returns a map of task IDs to their most recent [TaskProgress] update.
1291 0 : static Future<Map<String, TaskProgress>> getRunningProgress() async {
1292 0 : _checkInitialized();
1293 0 : final raw = await NativeWorkManagerPlatform.instance.getRunningProgress();
1294 0 : return raw.map((k, v) =>
1295 0 : MapEntry(k, TaskProgress.fromMap(Map<String, dynamic>.from(v))));
1296 : }
1297 :
1298 : /// Open a file with the system default application.
1299 : ///
1300 : /// Launches the OS file viewer for the given [path]. Useful after a download
1301 : /// completes to let the user immediately view/open the downloaded file.
1302 : ///
1303 : /// On **Android**, opens via `Intent.ACTION_VIEW` with a `FileProvider` URI.
1304 : /// Requires the `native_workmanager.provider` FileProvider to be declared in
1305 : /// the app's `AndroidManifest.xml` (added automatically by the plugin).
1306 : ///
1307 : /// On **iOS**, presents a `UIDocumentInteractionController`.
1308 : ///
1309 : /// ```dart
1310 : /// // After download completes:
1311 : /// NativeWorkManager.events.listen((event) async {
1312 : /// if (event.taskId == 'my-download' && event.success) {
1313 : /// await NativeWorkManager.openFile('/path/to/downloaded.pdf');
1314 : /// }
1315 : /// });
1316 : /// ```
1317 1 : static Future<void> openFile(String path, {String? mimeType}) {
1318 1 : _checkInitialized();
1319 0 : return NativeWorkManagerPlatform.instance
1320 0 : .openFile(path, mimeType: mimeType);
1321 : }
1322 :
1323 : /// Set the maximum number of concurrent downloads per host.
1324 : ///
1325 : /// Limits how many simultaneous downloads can target the same server.
1326 : /// Default is 2. Call after [initialize].
1327 : ///
1328 : /// ```dart
1329 : /// await NativeWorkManager.setMaxConcurrentPerHost(3);
1330 : /// ```
1331 1 : static Future<void> setMaxConcurrentPerHost(int max) {
1332 1 : _checkInitialized();
1333 0 : return NativeWorkManagerPlatform.instance.setMaxConcurrentPerHost(max);
1334 : }
1335 :
1336 : /// Return all tasks that match a given [status].
1337 : ///
1338 : /// Queries the persistent task store and filters by status. Useful for
1339 : /// building download managers, queue dashboards, or retry logic.
1340 : ///
1341 : /// ```dart
1342 : /// final running = await NativeWorkManager.getTasksByStatus(TaskStatus.running);
1343 : /// final paused = await NativeWorkManager.getTasksByStatus(TaskStatus.paused);
1344 : /// ```
1345 0 : static Future<List<TaskRecord>> getTasksByStatus(TaskStatus status) async {
1346 0 : _checkInitialized();
1347 0 : final all = await NativeWorkManagerPlatform.instance.allTasks();
1348 0 : return all.where((t) => t.status == status.name).toList();
1349 : }
1350 :
1351 : /// Pause all currently-running tasks.
1352 : ///
1353 : /// Queries the task store for every task in the `running` state, then
1354 : /// calls [pause] on each one concurrently. Tasks that cannot be paused
1355 : /// (e.g. non-download workers) are silently skipped on the native side.
1356 : ///
1357 : /// ```dart
1358 : /// // Pause everything — e.g. when the user taps "Pause All"
1359 : /// await NativeWorkManager.pauseAll();
1360 : /// ```
1361 0 : static Future<void> pauseAll() async {
1362 0 : _checkInitialized();
1363 0 : final running = await getTasksByStatus(TaskStatus.running);
1364 0 : await Future.wait(
1365 0 : running.map((t) =>
1366 0 : NativeWorkManagerPlatform.instance.pauseTask(taskId: t.taskId)),
1367 : );
1368 : }
1369 :
1370 : /// Resume all currently-paused tasks.
1371 : ///
1372 : /// Queries the task store for every task in the `paused` state, then
1373 : /// calls [resume] on each one concurrently.
1374 : ///
1375 : /// ```dart
1376 : /// // Resume everything — e.g. when network becomes available again
1377 : /// await NativeWorkManager.resumeAll();
1378 : /// ```
1379 0 : static Future<void> resumeAll() async {
1380 0 : _checkInitialized();
1381 0 : final paused = await getTasksByStatus(TaskStatus.paused);
1382 0 : await Future.wait(
1383 0 : paused.map((t) =>
1384 0 : NativeWorkManagerPlatform.instance.resumeTask(taskId: t.taskId)),
1385 : );
1386 : }
1387 :
1388 : /// Enqueue multiple tasks at once and return their [ScheduleResult]s.
1389 : ///
1390 : /// Each entry in [requests] maps to one [enqueue] call. All tasks are
1391 : /// submitted concurrently; the returned list preserves the input order.
1392 : ///
1393 : /// ```dart
1394 : /// final results = await NativeWorkManager.enqueueAll([
1395 : /// EnqueueRequest(
1396 : /// taskId: 'dl-1',
1397 : /// trigger: TaskTrigger.oneTime(),
1398 : /// worker: NativeWorker.httpDownload(url: urls[0], savePath: paths[0]),
1399 : /// tag: 'batch-download',
1400 : /// ),
1401 : /// EnqueueRequest(
1402 : /// taskId: 'dl-2',
1403 : /// trigger: TaskTrigger.oneTime(),
1404 : /// worker: NativeWorker.httpDownload(url: urls[1], savePath: paths[1]),
1405 : /// tag: 'batch-download',
1406 : /// ),
1407 : /// ]);
1408 : /// final accepted = results.where((r) => r.scheduleResult == ScheduleResult.accepted).length;
1409 : /// print('$accepted / ${results.length} tasks accepted');
1410 : /// ```
1411 0 : static Future<List<TaskHandler>> enqueueAll(
1412 : List<EnqueueRequest> requests,
1413 : ) async {
1414 0 : _checkInitialized();
1415 0 : return Future.wait(
1416 0 : requests.map(
1417 0 : (r) => enqueue(
1418 0 : taskId: r.taskId,
1419 0 : trigger: r.trigger,
1420 0 : worker: r.worker,
1421 0 : constraints: r.constraints,
1422 0 : existingPolicy: r.existingPolicy,
1423 0 : tag: r.tag,
1424 : ),
1425 : ),
1426 : );
1427 : }
1428 :
1429 : // ═══════════════════════════════════════════════════════════════════════════
1430 : // TASK CHAINS
1431 : // ═══════════════════════════════════════════════════════════════════════════
1432 :
1433 : /// Start building a task chain with a single initial task.
1434 : ///
1435 : /// Task chains allow you to define complex multi-step workflows where tasks
1436 : /// execute in sequence or parallel. This is the foundation for building
1437 : /// sophisticated background processing pipelines.
1438 : ///
1439 : /// ## Basic Sequential Chain (A → B → C)
1440 : ///
1441 : /// ```dart
1442 : /// await NativeWorkManager.beginWith(
1443 : /// TaskRequest(
1444 : /// id: 'download',
1445 : /// worker: NativeWorker.httpDownload(
1446 : /// url: 'https://example.com/video.mp4',
1447 : /// savePath: '/tmp/video.mp4',
1448 : /// ),
1449 : /// ),
1450 : /// )
1451 : /// .then(TaskRequest(
1452 : /// id: 'process',
1453 : /// worker: DartWorker(
1454 : /// callbackId: 'processVideo',
1455 : /// input: {'path': '/tmp/video.mp4'},
1456 : /// ),
1457 : /// ))
1458 : /// .then(TaskRequest(
1459 : /// id: 'upload',
1460 : /// worker: NativeWorker.httpUpload(
1461 : /// url: 'https://api.example.com/videos',
1462 : /// filePath: '/tmp/processed_video.mp4',
1463 : /// ),
1464 : /// ))
1465 : /// .enqueue();
1466 : /// ```
1467 : ///
1468 : /// ## Parallel Tasks (A → \[B1, B2, B3\])
1469 : ///
1470 : /// ```dart
1471 : /// await NativeWorkManager.beginWith(
1472 : /// TaskRequest(
1473 : /// id: 'prepare-data',
1474 : /// worker: DartWorker(callbackId: 'prepareData'),
1475 : /// ),
1476 : /// )
1477 : /// .then([
1478 : /// // These 3 tasks run in parallel
1479 : /// TaskRequest(
1480 : /// id: 'upload-server1',
1481 : /// worker: NativeWorker.httpUpload(
1482 : /// url: 'https://server1.example.com/upload',
1483 : /// filePath: '/data/file.zip',
1484 : /// ),
1485 : /// ),
1486 : /// TaskRequest(
1487 : /// id: 'upload-server2',
1488 : /// worker: NativeWorker.httpUpload(
1489 : /// url: 'https://server2.example.com/upload',
1490 : /// filePath: '/data/file.zip',
1491 : /// ),
1492 : /// ),
1493 : /// TaskRequest(
1494 : /// id: 'upload-backup',
1495 : /// worker: NativeWorker.httpUpload(
1496 : /// url: 'https://backup.example.com/upload',
1497 : /// filePath: '/data/file.zip',
1498 : /// ),
1499 : /// ),
1500 : /// ])
1501 : /// .enqueue();
1502 : /// ```
1503 : ///
1504 : /// ## Complex Workflow with Constraints
1505 : ///
1506 : /// ```dart
1507 : /// await NativeWorkManager.beginWith(
1508 : /// TaskRequest(
1509 : /// id: 'fetch-metadata',
1510 : /// worker: NativeWorker.httpRequest(
1511 : /// url: 'https://api.example.com/metadata',
1512 : /// method: HttpMethod.get,
1513 : /// ),
1514 : /// ),
1515 : /// )
1516 : /// .then(TaskRequest(
1517 : /// id: 'download-file',
1518 : /// worker: NativeWorker.httpDownload(
1519 : /// url: 'https://cdn.example.com/large-file.zip',
1520 : /// savePath: '/downloads/file.zip',
1521 : /// ),
1522 : /// ))
1523 : /// .then(TaskRequest(
1524 : /// id: 'extract-and-process',
1525 : /// worker: DartWorker(callbackId: 'extractZip'),
1526 : /// ))
1527 : /// .named('data-sync-pipeline')
1528 : /// .withConstraints(Constraints.heavyTask) // Requires charging + WiFi
1529 : /// .enqueue();
1530 : /// ```
1531 : ///
1532 : /// ## Parameters
1533 : ///
1534 : /// **[task]** *(required)* - The first task in the chain.
1535 : /// - Must be a [TaskRequest]
1536 : /// - This task executes before all other tasks in the chain
1537 : ///
1538 : /// ## Returns
1539 : ///
1540 : /// A [TaskChainBuilder] that you can use to:
1541 : /// - Add more tasks with `.then()`
1542 : /// - Set constraints with `.withConstraints()`
1543 : /// - Name the chain with `.named()`
1544 : /// - Execute the chain with `.enqueue()`
1545 : ///
1546 : /// ## Chain Execution Rules
1547 : ///
1548 : /// - **Sequential tasks:** Execute one after another (A → B → C)
1549 : /// - **Parallel tasks:** All start together (`[A, B, C]` → D)
1550 : /// - **Failure handling:** If any task fails, the entire chain stops
1551 : /// - **Constraints:** Applied to the entire chain, not individual tasks
1552 : ///
1553 : /// ## Common Use Cases
1554 : ///
1555 : /// 1. **Download → Process → Upload:** Fetch data, transform it, upload result
1556 : /// 2. **Fetch → Parallel Uploads:** Get data once, upload to multiple servers
1557 : /// 3. **Multi-step Data Sync:** Fetch metadata, download files, process locally
1558 : /// 4. **Backup Pipeline:** Compress files, encrypt, upload to multiple cloud storage
1559 : ///
1560 : /// ## Platform Considerations
1561 : ///
1562 : /// **Android:**
1563 : /// - Uses WorkManager's chain API
1564 : /// - Each task in chain is a separate Work item
1565 : /// - Failure of one task cancels remaining tasks
1566 : ///
1567 : /// **iOS:**
1568 : /// - Simulated via sequential BGTaskRequest scheduling
1569 : /// - Chain state tracked internally
1570 : /// - More reliable on iOS 15+
1571 : ///
1572 : /// ## Common Pitfalls
1573 : ///
1574 : /// ❌ **Don't** make chains too long (increases failure risk)
1575 : /// ❌ **Don't** use chains for independent tasks (just schedule separately)
1576 : /// ❌ **Don't** rely on exact timing - chains may be delayed by OS
1577 : /// ✅ **Do** handle failures gracefully
1578 : /// ✅ **Do** keep chains focused on related tasks
1579 : /// ✅ **Do** use constraints to ensure suitable execution conditions
1580 : ///
1581 : /// ## See Also
1582 : ///
1583 : /// - [beginWithAll] - Start chain with multiple parallel initial tasks
1584 : /// - [TaskRequest] - Configuration for individual tasks in chain
1585 : /// - [TaskChainBuilder] - Builder for constructing task chains
1586 0 : static TaskChainBuilder beginWith(TaskRequest task) {
1587 0 : _checkInitialized();
1588 0 : return TaskChainBuilder.internal([task]);
1589 : }
1590 :
1591 : /// Start building a task chain with multiple parallel initial tasks.
1592 : ///
1593 : /// Use this when you want multiple tasks to start simultaneously at the
1594 : /// beginning of a chain, then continue with sequential or parallel steps.
1595 : ///
1596 : /// ## Example - Parallel Download then Process
1597 : ///
1598 : /// ```dart
1599 : /// await NativeWorkManager.beginWithAll([
1600 : /// // These 3 downloads run in parallel
1601 : /// TaskRequest(
1602 : /// id: 'download-file1',
1603 : /// worker: NativeWorker.httpDownload(
1604 : /// url: 'https://cdn.example.com/file1.zip',
1605 : /// savePath: '/tmp/file1.zip',
1606 : /// ),
1607 : /// ),
1608 : /// TaskRequest(
1609 : /// id: 'download-file2',
1610 : /// worker: NativeWorker.httpDownload(
1611 : /// url: 'https://cdn.example.com/file2.zip',
1612 : /// savePath: '/tmp/file2.zip',
1613 : /// ),
1614 : /// ),
1615 : /// TaskRequest(
1616 : /// id: 'download-file3',
1617 : /// worker: NativeWorker.httpDownload(
1618 : /// url: 'https://cdn.example.com/file3.zip',
1619 : /// savePath: '/tmp/file3.zip',
1620 : /// ),
1621 : /// ),
1622 : /// ])
1623 : /// .then(TaskRequest(
1624 : /// // After ALL downloads complete, process them
1625 : /// id: 'merge-files',
1626 : /// worker: DartWorker(callbackId: 'mergeDownloads'),
1627 : /// ))
1628 : /// .enqueue();
1629 : /// ```
1630 : ///
1631 : /// ## Example - Multi-Source Data Fetch
1632 : ///
1633 : /// ```dart
1634 : /// await NativeWorkManager.beginWithAll([
1635 : /// TaskRequest(
1636 : /// id: 'fetch-api1',
1637 : /// worker: NativeWorker.httpRequest(
1638 : /// url: 'https://api1.example.com/data',
1639 : /// method: HttpMethod.get,
1640 : /// ),
1641 : /// ),
1642 : /// TaskRequest(
1643 : /// id: 'fetch-api2',
1644 : /// worker: NativeWorker.httpRequest(
1645 : /// url: 'https://api2.example.com/data',
1646 : /// method: HttpMethod.get,
1647 : /// ),
1648 : /// ),
1649 : /// TaskRequest(
1650 : /// id: 'fetch-api3',
1651 : /// worker: NativeWorker.httpRequest(
1652 : /// url: 'https://api3.example.com/data',
1653 : /// method: HttpMethod.get,
1654 : /// ),
1655 : /// ),
1656 : /// ])
1657 : /// .then(TaskRequest(
1658 : /// id: 'aggregate',
1659 : /// worker: DartWorker(callbackId: 'aggregateData'),
1660 : /// ))
1661 : /// .then(TaskRequest(
1662 : /// id: 'upload-results',
1663 : /// worker: NativeWorker.httpUpload(
1664 : /// url: 'https://api.example.com/results',
1665 : /// filePath: '/tmp/aggregated.json',
1666 : /// ),
1667 : /// ))
1668 : /// .enqueue();
1669 : /// ```
1670 : ///
1671 : /// ## Parameters
1672 : ///
1673 : /// **[tasks]** *(required)* - List of tasks to execute in parallel.
1674 : /// - Must not be empty
1675 : /// - All tasks start simultaneously
1676 : /// - Next step waits for ALL to complete
1677 : ///
1678 : /// ## Behavior
1679 : ///
1680 : /// - All initial tasks start at the same time
1681 : /// - Next task in chain waits for ALL initial tasks to complete
1682 : /// - If ANY initial task fails, entire chain stops
1683 : ///
1684 : /// ## When to Use
1685 : ///
1686 : /// ✅ **Use beginWithAll when:**
1687 : /// - You need to fetch from multiple sources before processing
1688 : /// - You want to maximize parallelism from the start
1689 : /// - Initial tasks are independent but results must be combined
1690 : ///
1691 : /// ❌ **Don't use beginWithAll when:**
1692 : /// - Tasks depend on each other - use [beginWith] with `.then()` instead
1693 : /// - You just need parallel tasks with no follow-up - schedule separately
1694 : ///
1695 : /// ## See Also
1696 : ///
1697 : /// - [beginWith] - Start chain with single initial task
1698 : /// - [TaskChainBuilder] - Builder for constructing task chains
1699 0 : static TaskChainBuilder beginWithAll(List<TaskRequest> tasks) {
1700 0 : _checkInitialized();
1701 0 : if (tasks.isEmpty) {
1702 0 : throw ArgumentError('Tasks list cannot be empty');
1703 : }
1704 0 : return TaskChainBuilder.internal(tasks);
1705 : }
1706 :
1707 0 : static Future<ScheduleResult> _enqueueChain(TaskChainBuilder chain) {
1708 : // Convert DartWorker to DartWorkerInternal for all tasks in the chain
1709 0 : final convertedSteps = chain.steps.map((step) {
1710 0 : return step.map((task) {
1711 0 : final worker = task.worker;
1712 :
1713 : // Check if worker is DartWorker and needs conversion
1714 0 : if (worker is DartWorker) {
1715 : // Get the callback handle for this worker
1716 0 : final callbackHandle = _callbackHandles[worker.callbackId];
1717 : if (callbackHandle == null) {
1718 0 : throw StateError(
1719 0 : 'INTERNAL ERROR: Callback handle not found for "${worker.callbackId}". '
1720 : 'This should never happen. Please report this bug.',
1721 : );
1722 : }
1723 :
1724 : // Convert DartWorker to DartWorkerInternal
1725 0 : final convertedWorker = DartWorkerInternal(
1726 0 : callbackId: worker.callbackId,
1727 : callbackHandle: callbackHandle,
1728 0 : input: worker.input,
1729 0 : autoDispose: worker.autoDispose,
1730 0 : timeoutMs: worker.timeoutMs,
1731 : );
1732 :
1733 : // Return modified task map with converted worker
1734 0 : return {
1735 0 : 'id': task.id,
1736 0 : 'workerClassName': convertedWorker.workerClassName,
1737 0 : 'workerConfig': convertedWorker.toMap(),
1738 0 : 'constraints': task.constraints.toMap(),
1739 : };
1740 : }
1741 :
1742 : // For non-DartWorker tasks, use original toMap()
1743 0 : return task.toMap();
1744 0 : }).toList();
1745 0 : }).toList();
1746 :
1747 : // Build the chain map with converted steps
1748 0 : final chainMap = {
1749 0 : 'name': chain.name,
1750 0 : 'constraints': chain.constraints.toMap(),
1751 : 'steps': convertedSteps,
1752 : };
1753 :
1754 0 : return NativeWorkManagerPlatform.instance.enqueueChain(chainMap);
1755 : }
1756 :
1757 : // ═══════════════════════════════════════════════════════════════════════════
1758 : // EVENTS & OBSERVABILITY
1759 : // ═══════════════════════════════════════════════════════════════════════════
1760 :
1761 : /// Stream of task completion events.
1762 : ///
1763 : /// **Recommended:** Use this stream to reactively respond to task completions
1764 : /// instead of polling [getTaskStatus].
1765 : ///
1766 : /// ## Example - Show Notification on Completion
1767 : ///
1768 : /// ```dart
1769 : /// @override
1770 : /// void initState() {
1771 : /// super.initState();
1772 : ///
1773 : /// // Listen to task events
1774 : /// NativeWorkManager.events.listen((event) {
1775 : /// if (event.success) {
1776 : /// showNotification(
1777 : /// title: 'Task Complete',
1778 : /// body: 'Task ${event.taskId} finished successfully',
1779 : /// );
1780 : /// } else {
1781 : /// showError(
1782 : /// title: 'Task Failed',
1783 : /// body: event.message ?? 'Unknown error',
1784 : /// );
1785 : /// }
1786 : /// });
1787 : /// }
1788 : /// ```
1789 : ///
1790 : /// ## Example - Update UI on Upload Complete
1791 : ///
1792 : /// ```dart
1793 : /// class UploadManager {
1794 : /// StreamSubscription? _eventSub;
1795 : ///
1796 : /// void startListening() {
1797 : /// _eventSub = NativeWorkManager.events.listen((event) {
1798 : /// if (event.taskId.startsWith('upload-')) {
1799 : /// if (event.success) {
1800 : /// markUploadComplete(event.taskId);
1801 : /// refreshUI();
1802 : /// } else {
1803 : /// showRetryDialog(event.taskId);
1804 : /// }
1805 : /// }
1806 : /// });
1807 : /// }
1808 : ///
1809 : /// void dispose() {
1810 : /// _eventSub?.cancel();
1811 : /// }
1812 : /// }
1813 : /// ```
1814 : ///
1815 : /// ## Example - Collect Statistics
1816 : ///
1817 : /// ```dart
1818 : /// int successCount = 0;
1819 : /// int failureCount = 0;
1820 : ///
1821 : /// NativeWorkManager.events.listen((event) {
1822 : /// if (event.success) {
1823 : /// successCount++;
1824 : /// } else {
1825 : /// failureCount++;
1826 : /// }
1827 : /// print('Success: $successCount, Failed: $failureCount');
1828 : /// });
1829 : /// ```
1830 : ///
1831 : /// ## Event Properties
1832 : ///
1833 : /// Each [TaskEvent] contains:
1834 : /// - `taskId` - ID of the completed task
1835 : /// - `success` - true if task succeeded, false if failed
1836 : /// - `message` - Optional error message (only present on failure)
1837 : ///
1838 : /// ## Behavior
1839 : ///
1840 : /// - Emits an event when ANY task completes (success or failure)
1841 : /// - Events are emitted even if app is in background
1842 : /// - Stream is broadcast - multiple listeners supported
1843 : /// - Events are NOT persisted - you won't receive events for tasks
1844 : /// that completed while app was closed
1845 : ///
1846 : /// ## Platform Notes
1847 : ///
1848 : /// **Android:**
1849 : /// - Events delivered via EventChannel
1850 : /// - Immediate delivery when app is running
1851 : ///
1852 : /// **iOS:**
1853 : /// - Events delivered when app comes to foreground
1854 : /// - May be batched if multiple tasks completed while app was suspended
1855 : ///
1856 : /// ## Common Patterns
1857 : ///
1858 : /// **Filter by task ID prefix:**
1859 : /// ```dart
1860 : /// NativeWorkManager.events
1861 : /// .where((event) => event.taskId.startsWith('sync-'))
1862 : /// .listen((event) {
1863 : /// // Only sync tasks
1864 : /// });
1865 : /// ```
1866 : ///
1867 : /// **Handle only failures:**
1868 : /// ```dart
1869 : /// NativeWorkManager.events
1870 : /// .where((event) => !event.success)
1871 : /// .listen((event) {
1872 : /// logError('Task ${event.taskId} failed: ${event.message}');
1873 : /// });
1874 : /// ```
1875 : ///
1876 : /// ## Common Pitfalls
1877 : ///
1878 : /// ❌ **Don't** forget to cancel subscriptions (causes memory leaks)
1879 : /// ❌ **Don't** perform heavy work in listener (blocks event stream)
1880 : /// ✅ **Do** use StreamBuilder for UI updates
1881 : /// ✅ **Do** filter events by taskId prefix for organization
1882 : ///
1883 : /// ## See Also
1884 : ///
1885 : /// - [progress] - Stream of progress updates during task execution
1886 : /// - [getTaskStatus] - Query task status on-demand
1887 0 : static Stream<TaskEvent> get events {
1888 0 : _checkInitialized();
1889 0 : return NativeWorkManagerPlatform.instance.events;
1890 : }
1891 :
1892 : /// Stream of task progress updates.
1893 : ///
1894 : /// Get real-time progress updates during task execution. Useful for showing
1895 : /// upload/download progress bars in your UI.
1896 : ///
1897 : /// ## Example - Show Progress Bar
1898 : ///
1899 : /// ```dart
1900 : /// class DownloadProgressWidget extends StatefulWidget {
1901 : /// final String taskId;
1902 : /// const DownloadProgressWidget({required this.taskId});
1903 : ///
1904 : /// @override
1905 : /// State createState() => _DownloadProgressWidgetState();
1906 : /// }
1907 : ///
1908 : /// class _DownloadProgressWidgetState extends State<DownloadProgressWidget> {
1909 : /// double _progress = 0.0;
1910 : /// StreamSubscription? _progressSub;
1911 : ///
1912 : /// @override
1913 : /// void initState() {
1914 : /// super.initState();
1915 : /// _progressSub = NativeWorkManager.progress
1916 : /// .where((p) => p.taskId == widget.taskId)
1917 : /// .listen((progress) {
1918 : /// setState(() {
1919 : /// _progress = progress.progress / 100.0;
1920 : /// });
1921 : /// });
1922 : /// }
1923 : ///
1924 : /// @override
1925 : /// void dispose() {
1926 : /// _progressSub?.cancel();
1927 : /// super.dispose();
1928 : /// }
1929 : ///
1930 : /// @override
1931 : /// Widget build(BuildContext context) {
1932 : /// return LinearProgressIndicator(value: _progress);
1933 : /// }
1934 : /// }
1935 : /// ```
1936 : ///
1937 : /// ## Example - Show Upload Speed
1938 : ///
1939 : /// ```dart
1940 : /// double? lastProgress;
1941 : /// DateTime? lastUpdate;
1942 : ///
1943 : /// NativeWorkManager.progress.listen((progress) {
1944 : /// if (lastProgress != null && lastUpdate != null) {
1945 : /// final progressDelta = progress.progress - lastProgress!;
1946 : /// final timeDelta = DateTime.now().difference(lastUpdate!).inSeconds;
1947 : ///
1948 : /// if (timeDelta > 0) {
1949 : /// final speed = progressDelta / timeDelta;
1950 : /// print('Upload speed: ${speed.toStringAsFixed(1)}%/sec');
1951 : /// }
1952 : /// }
1953 : ///
1954 : /// lastProgress = progress.progress;
1955 : /// lastUpdate = DateTime.now();
1956 : /// });
1957 : /// ```
1958 : ///
1959 : /// ## Progress Properties
1960 : ///
1961 : /// Each [TaskProgress] contains:
1962 : /// - `taskId` - ID of the task reporting progress
1963 : /// - `progress` - Progress value (0-100)
1964 : ///
1965 : /// ## Supported Workers
1966 : ///
1967 : /// Progress updates are available for:
1968 : /// - ✅ `NativeWorker.httpUpload` - Upload progress
1969 : /// - ✅ `NativeWorker.httpDownload` - Download progress
1970 : /// - ❌ `NativeWorker.httpRequest` - No progress (too fast)
1971 : /// - ❌ `NativeWorker.httpSync` - No progress
1972 : /// - ⚠️ `DartWorker` - Only if manually reported in callback
1973 : ///
1974 : /// ## Behavior
1975 : ///
1976 : /// - Progress values range from 0 to 100
1977 : /// - Updates emitted periodically during task execution
1978 : /// - Not all tasks report progress (see Supported Workers above)
1979 : /// - Stream is broadcast - multiple listeners supported
1980 : ///
1981 : /// ## Platform Notes
1982 : ///
1983 : /// **Android:**
1984 : /// - Progress delivered via EventChannel
1985 : /// - Update frequency: ~1 update per second
1986 : ///
1987 : /// **iOS:**
1988 : /// - Progress may not be available for all workers
1989 : /// - Update frequency varies by worker type
1990 : ///
1991 : /// ## Common Pitfalls
1992 : ///
1993 : /// ❌ **Don't** expect progress for all worker types
1994 : /// ❌ **Don't** assume linear progress (may jump or pause)
1995 : /// ❌ **Don't** forget to cancel subscriptions
1996 : /// ✅ **Do** filter by taskId if tracking specific task
1997 : /// ✅ **Do** show indeterminate progress for unsupported workers
1998 : ///
1999 : /// ## See Also
2000 : ///
2001 : /// - [events] - Stream of task completion events
2002 0 : static Stream<TaskProgress> get progress {
2003 0 : _checkInitialized();
2004 0 : return NativeWorkManagerPlatform.instance.progress;
2005 : }
2006 :
2007 : // ═══════════════════════════════════════════════════════════════════════════
2008 : // DART WORKER REGISTRATION
2009 : // ═══════════════════════════════════════════════════════════════════════════
2010 :
2011 : /// Register additional Dart workers after initialization.
2012 : ///
2013 : /// ```dart
2014 : /// NativeWorkManager.registerDartWorker(
2015 : /// 'lateWorker',
2016 : /// (input) async {
2017 : /// // Worker logic
2018 : /// return true;
2019 : /// },
2020 : /// );
2021 : /// ```
2022 4 : static void registerDartWorker(String id, DartWorkerCallback callback) {
2023 8 : _dartWorkers[id] = callback;
2024 :
2025 4 : final handle = PluginUtilities.getCallbackHandle(callback);
2026 : if (handle == null) {
2027 2 : throw StateError(
2028 : 'Failed to get callback handle for "$id". '
2029 : 'Ensure the callback is a top-level or static function, '
2030 : 'NOT an anonymous function or instance method.',
2031 : );
2032 : }
2033 9 : _callbackHandles[id] = handle.toRawHandle();
2034 : }
2035 :
2036 : /// Unregister a Dart worker.
2037 4 : static void unregisterDartWorker(String id) {
2038 8 : _dartWorkers.remove(id);
2039 8 : _callbackHandles.remove(id);
2040 : }
2041 :
2042 : /// Check if a Dart worker is registered.
2043 3 : static bool isDartWorkerRegistered(String id) {
2044 6 : return _dartWorkers.containsKey(id);
2045 : }
2046 :
2047 : // ═══════════════════════════════════════════════════════════════════════════
2048 : // OBSERVABILITY
2049 : // ═══════════════════════════════════════════════════════════════════════════
2050 :
2051 : // ═══════════════════════════════════════════════════════════════════════════
2052 : // TASK GRAPH (DAG)
2053 : // ═══════════════════════════════════════════════════════════════════════════
2054 :
2055 : /// Schedule a [TaskGraph] (directed acyclic graph) of background tasks.
2056 : ///
2057 : /// Nodes with no dependencies run immediately in parallel. A node starts
2058 : /// only when **all** its [TaskNode.dependsOn] nodes have succeeded. If any
2059 : /// node fails, its transitive dependents are cancelled.
2060 : ///
2061 : /// Returns a [GraphExecution] handle whose [GraphExecution.result] future
2062 : /// resolves when the entire graph finishes.
2063 : ///
2064 : /// ```dart
2065 : /// final graph = TaskGraph(id: 'export')
2066 : /// ..add(TaskNode(id: 'dl-a', worker: HttpDownloadWorker(url: urlA, savePath: pathA)))
2067 : /// ..add(TaskNode(id: 'dl-b', worker: HttpDownloadWorker(url: urlB, savePath: pathB)))
2068 : /// ..add(TaskNode(id: 'merge', worker: DartWorker(callbackId: 'merge'),
2069 : /// dependsOn: ['dl-a', 'dl-b']))
2070 : /// ..add(TaskNode(id: 'upload', worker: HttpUploadWorker(url: apiUrl, filePath: merged),
2071 : /// dependsOn: ['merge']));
2072 : ///
2073 : /// final exec = await NativeWorkManager.enqueueGraph(graph);
2074 : /// final result = await exec.result;
2075 : /// print(result.success ? 'Done!' : 'Failed: ${result.failedNodes}');
2076 : /// ```
2077 : ///
2078 : /// **Note:** The graph executor uses [NativeWorkManager.events] for
2079 : /// fan-in synchronization, so the app must be running while the graph
2080 : /// executes. Graphs that must survive app termination should use
2081 : /// independent [enqueue] calls with chain logic instead.
2082 0 : static Future<GraphExecution> enqueueGraph(TaskGraph graph) {
2083 0 : _checkInitialized();
2084 0 : return enqueueTaskGraph(graph);
2085 : }
2086 :
2087 : /// Configure observability hooks for all background tasks.
2088 : ///
2089 : /// Call after [initialize] to receive callbacks whenever a task starts,
2090 : /// completes, or fails — without manually subscribing to the [events] and
2091 : /// [progress] streams in every widget.
2092 : ///
2093 : /// Calling [configure] again replaces the previous observability config.
2094 : /// Pass `null` to remove the existing config.
2095 : ///
2096 : /// ```dart
2097 : /// NativeWorkManager.configure(
2098 : /// observability: ObservabilityConfig(
2099 : /// onTaskStart: (taskId, workerType) {
2100 : /// analytics.track('bg_task_start', {'type': workerType});
2101 : /// },
2102 : /// onTaskComplete: (event) {
2103 : /// performance.record('task_ok', {'id': event.taskId});
2104 : /// },
2105 : /// onTaskFail: (event) {
2106 : /// crashlytics.log('Task failed: ${event.taskId} — ${event.message}');
2107 : /// },
2108 : /// ),
2109 : /// );
2110 : /// ```
2111 1 : static void configure({ObservabilityConfig? observability}) {
2112 : // Cancel existing observability subscriptions.
2113 1 : _observabilityEventSub?.cancel();
2114 1 : _observabilityProgressSub?.cancel();
2115 : _observabilityEventSub = null;
2116 : _observabilityProgressSub = null;
2117 :
2118 : if (observability == null) return;
2119 :
2120 1 : final dispatcher = ObservabilityDispatcher(observability);
2121 :
2122 2 : _observabilityEventSub = NativeWorkManagerPlatform.instance.events
2123 2 : .listen(dispatcher.dispatchEvent);
2124 2 : _observabilityProgressSub = NativeWorkManagerPlatform.instance.progress
2125 2 : .listen(dispatcher.dispatchProgress);
2126 : }
2127 :
2128 : /// Register a remote trigger for background task execution.
2129 : ///
2130 : /// Remote triggers allow your backend to initiate background tasks on
2131 : /// user devices via FCM (Android/iOS) or APNs (iOS).
2132 : ///
2133 : /// Unlike standard tasks, remote triggers are **registered once** and
2134 : /// can then be triggered multiple times from the server by sending a
2135 : /// data message with the matching payload.
2136 : ///
2137 : /// **Zero Flutter Engine overhead:** When a remote trigger is received, the
2138 : /// plugin enqueues the worker directly on the native side. Flutter is
2139 : /// NOT started unless you use a [DartWorker].
2140 : ///
2141 : /// ## Example - Remote Sync
2142 : ///
2143 : /// ```dart
2144 : /// await NativeWorkManager.registerRemoteTrigger(
2145 : /// source: RemoteTriggerSource.fcm,
2146 : /// rule: RemoteTriggerRule(
2147 : /// payloadKey: 'action',
2148 : /// workerMappings: {
2149 : /// 'sync': NativeWorker.httpSync(
2150 : /// url: 'https://api.example.com/sync',
2151 : /// headers: {'Authorization': 'Bearer {{token}}'}, // From payload
2152 : /// ),
2153 : /// },
2154 : /// ),
2155 : /// );
2156 : /// ```
2157 0 : static Future<void> registerRemoteTrigger({
2158 : required RemoteTriggerSource source,
2159 : required RemoteTriggerRule rule,
2160 : }) async {
2161 0 : _checkInitialized();
2162 0 : return NativeWorkManagerPlatform.instance.registerRemoteTrigger(
2163 : source: source,
2164 : rule: rule,
2165 : );
2166 : }
2167 :
2168 : /// Register a middleware for background tasks.
2169 : ///
2170 : /// Middleware allows you to intercept and modify tasks globally on the
2171 : /// native side.
2172 : ///
2173 : /// ## Example - Global Auth Header
2174 : ///
2175 : /// ```dart
2176 : /// await NativeWorkManager.registerMiddleware(
2177 : /// HeaderMiddleware(
2178 : /// headers: {'Authorization': 'Bearer my-token'},
2179 : /// urlPattern: 'https://api.myapp.com/.*',
2180 : /// ),
2181 : /// );
2182 : /// ```
2183 0 : static Future<void> registerMiddleware(Middleware middleware) async {
2184 0 : _checkInitialized();
2185 0 : return NativeWorkManagerPlatform.instance
2186 0 : .registerMiddleware(middleware.toMap());
2187 : }
2188 :
2189 : // ═══════════════════════════════════════════════════════════════════════════
2190 : // OBSERVABILITY EXTENSIONS
2191 : // ═══════════════════════════════════════════════════════════════════════════
2192 :
2193 : /// Convenience stub for Sentry integration.
2194 : ///
2195 : /// **⚠️ This method does not call any Sentry SDK.** It only logs to
2196 : /// `debugPrint` so it is useful only as a quick smoke-test.
2197 : ///
2198 : /// For production Sentry integration, implement [WorkManagerLogger] and
2199 : /// wire it up via [ObservabilityConfig.fromLogger]:
2200 : ///
2201 : /// ```dart
2202 : /// class SentryWorkManagerLogger implements WorkManagerLogger {
2203 : /// @override
2204 : /// void onTaskStart(String taskId, String workerType) =>
2205 : /// Sentry.addBreadcrumb(Breadcrumb(message: 'Task started: $taskId'));
2206 : ///
2207 : /// @override
2208 : /// void onTaskComplete(TaskEvent event) =>
2209 : /// Sentry.addBreadcrumb(Breadcrumb(message: 'Task done: ${event.taskId}'));
2210 : ///
2211 : /// @override
2212 : /// void onTaskFail(TaskEvent event) =>
2213 : /// Sentry.captureMessage('Task failed: ${event.taskId}');
2214 : /// }
2215 : ///
2216 : /// NativeWorkManager.configure(
2217 : /// observability: ObservabilityConfig.fromLogger(SentryWorkManagerLogger()),
2218 : /// );
2219 : /// ```
2220 1 : @Deprecated(
2221 : 'useSentry() does not call the Sentry SDK. '
2222 : 'Implement WorkManagerLogger and use ObservabilityConfig.fromLogger() instead.',
2223 : )
2224 : static void useSentry({bool addBreadcrumbs = true, dynamic sentryInstance}) {
2225 1 : configure(
2226 1 : observability: ObservabilityConfig(
2227 : onTaskStart: addBreadcrumbs
2228 0 : ? (id, type) =>
2229 0 : debugPrint('[native_workmanager] task started: $id ($type)')
2230 : : null,
2231 : onTaskComplete: addBreadcrumbs
2232 0 : ? (e) => debugPrint('[native_workmanager] task done: ${e.taskId}')
2233 : : null,
2234 0 : onTaskFail: (e) => debugPrint(
2235 0 : '[native_workmanager] task FAILED: ${e.taskId} — ${e.message}'),
2236 : ),
2237 : );
2238 : }
2239 :
2240 : /// Convenience stub for Firebase Analytics / Crashlytics integration.
2241 : ///
2242 : /// **⚠️ This method does not call any Firebase SDK.** It only logs to
2243 : /// `debugPrint` so it is useful only as a quick smoke-test.
2244 : ///
2245 : /// For production Firebase integration, implement [WorkManagerLogger] and
2246 : /// wire it up via [ObservabilityConfig.fromLogger]:
2247 : ///
2248 : /// ```dart
2249 : /// class FirebaseWorkManagerLogger implements WorkManagerLogger {
2250 : /// @override
2251 : /// void onTaskStart(String taskId, String workerType) =>
2252 : /// FirebaseAnalytics.instance.logEvent(
2253 : /// name: 'bg_task_start',
2254 : /// parameters: {'task_id': taskId, 'worker': workerType},
2255 : /// );
2256 : ///
2257 : /// @override
2258 : /// void onTaskComplete(TaskEvent event) =>
2259 : /// FirebaseAnalytics.instance.logEvent(
2260 : /// name: 'bg_task_success',
2261 : /// parameters: {'task_id': event.taskId},
2262 : /// );
2263 : ///
2264 : /// @override
2265 : /// void onTaskFail(TaskEvent event) =>
2266 : /// FirebaseCrashlytics.instance.recordError(
2267 : /// event.message ?? 'Unknown',
2268 : /// null,
2269 : /// reason: 'Background task failed: ${event.taskId}',
2270 : /// );
2271 : /// }
2272 : ///
2273 : /// NativeWorkManager.configure(
2274 : /// observability: ObservabilityConfig.fromLogger(FirebaseWorkManagerLogger()),
2275 : /// );
2276 : /// ```
2277 1 : @Deprecated(
2278 : 'useFirebase() does not call the Firebase SDK. '
2279 : 'Implement WorkManagerLogger and use ObservabilityConfig.fromLogger() instead.',
2280 : )
2281 : static void useFirebase({
2282 : bool logToAnalytics = true,
2283 : bool logToCrashlytics = true,
2284 : }) {
2285 1 : configure(
2286 1 : observability: ObservabilityConfig(
2287 : onTaskStart: logToAnalytics
2288 0 : ? (id, type) => debugPrint(
2289 0 : '[native_workmanager] Firebase: bg_task_start $id $type')
2290 : : null,
2291 : onTaskComplete: logToAnalytics
2292 0 : ? (e) => debugPrint(
2293 0 : '[native_workmanager] Firebase: bg_task_success ${e.taskId}')
2294 : : null,
2295 0 : onTaskFail: (e) {
2296 : if (logToAnalytics) {
2297 0 : debugPrint(
2298 0 : '[native_workmanager] Firebase: bg_task_fail ${e.taskId} — ${e.message}');
2299 : }
2300 : if (logToCrashlytics) {
2301 0 : debugPrint(
2302 0 : '[native_workmanager] Firebase Crashlytics: task failed: ${e.taskId}');
2303 : }
2304 : },
2305 : ),
2306 : );
2307 : }
2308 : }
|