diff --git a/CLAUDE.md b/CLAUDE.md index 3f7f9327..c5389f1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,15 +1,19 @@ ## Pre-Commit Requirements **CRITICAL**: Always run from project root before ANY commit: 1. `dart analyze` (check for code errors) -2. `ktlint -F .` -3. `find . -name "*.dart" ! -name "*.g.dart" ! -path "*/.*" -print0 | xargs -0 dart format --set-exit-if-changed` -4. `flutter test` (all Dart tests) -5. `cd example/android && ./gradlew :workmanager_android:test` (Android native tests) +2. `ktlint -F .` (format Kotlin code) +3. `swiftlint --fix` (format Swift code) +4. `find . -name "*.dart" ! -name "*.g.dart" ! -path "*/.*" -print0 | xargs -0 dart format --set-exit-if-changed` +5. `flutter test` (all Dart tests) +6. `cd example/android && ./gradlew :workmanager_android:test` (Android native tests) +7. `cd example && flutter build apk --debug` (build Android example app) +8. `cd example && flutter build ios --debug --no-codesign` (build iOS example app) ## Code Generation - Regenerate Pigeon files: `melos run generate:pigeon` - Regenerate Dart files (including mocks): `melos run generate:dart` - Do not manually edit *.g.* files +- Never manually modify mocks or generated files. Always modify the source, then run the generator tasks via melos. ## Running Tests - Use melos to run all tests: `melos run test` @@ -32,4 +36,31 @@ - **No AI agent progress**: Don't document debugging steps, build fixes, or internal development process - **What matters to users**: Breaking changes, new features, bug fixes that affect their code - **Example of bad changelog entry**: "Fixed Kotlin null safety issues with androidx.work 2.10.2 type system improvements" -- **Example of good changelog entry**: "Fixed periodic tasks not respecting frequency changes" \ No newline at end of file +- **Example of good changelog entry**: "Fixed periodic tasks not respecting frequency changes" + +## Documentation Components (docs.page) +- **Component reference**: https://use.docs.page/ contains the full reference for available components +- **Tabs component syntax**: + ```jsx + + + Content here + + + ``` +- Use `` not `` - this is a common mistake that causes JavaScript errors +- Always include both `label` and `value` props on TabItem components + +## Pull Request Description Guidelines + +Template: +```markdown +## Summary +- Brief change description + +Fixes #123 + +## Breaking Changes (if applicable) +**Before:** `old code` +**After:** `new code` +``` \ No newline at end of file diff --git a/docs.json b/docs.json index 513b3184..d557ee3f 100644 --- a/docs.json +++ b/docs.json @@ -29,6 +29,10 @@ "title": "Task Customization", "href": "/customization" }, + { + "title": "Task Status Tracking", + "href": "/task-status" + }, { "title": "Debugging", "href": "/debugging" diff --git a/docs/debugging.mdx b/docs/debugging.mdx index 0b84d772..3626d292 100644 --- a/docs/debugging.mdx +++ b/docs/debugging.mdx @@ -5,18 +5,128 @@ description: Debug and troubleshoot background tasks on Android and iOS Background tasks can be tricky to debug since they run when your app is closed. Here's how to effectively debug and troubleshoot them on both platforms. -## Enable Debug Mode +## Hook-Based Debug System -Always start by enabling debug notifications: +The Workmanager plugin uses a hook-based debug system that allows you to customize how debug information is handled. + +### Quick Setup + +Initialize Workmanager without any debug parameters: ```dart -Workmanager().initialize( - callbackDispatcher, - isInDebugMode: true, // Shows notifications when tasks execute -); +await Workmanager().initialize(callbackDispatcher); ``` -This shows system notifications whenever background tasks run, making it easy to verify execution. +Then set up platform-specific debug handlers as needed. + +## Android Debug Handlers + +### Logging Debug Handler (Recommended) + +Shows debug information in Android's Log system (visible in `adb logcat`): + +```kotlin +// In your Application class +import dev.fluttercommunity.workmanager.WorkmanagerDebug +import dev.fluttercommunity.workmanager.LoggingDebugHandler + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + } +} +``` + +### Notification Debug Handler + +Shows debug information as notifications (requires notification permissions): + +```kotlin +import dev.fluttercommunity.workmanager.NotificationDebugHandler + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + WorkmanagerDebug.setCurrent(NotificationDebugHandler()) + } +} +``` + +## iOS Debug Handlers + +### Logging Debug Handler (Recommended) + +Shows debug information in iOS's unified logging system: + +```swift +// In your AppDelegate.swift +import workmanager_apple + +@main +class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} +``` + +### Notification Debug Handler + +Shows debug information as notifications: + +```swift +WorkmanagerDebug.setCurrent(NotificationDebugHandler()) +``` + +## Custom Debug Handlers + +Create your own debug handler for custom logging needs: + + + + +```kotlin +class CustomDebugHandler : WorkmanagerDebug() { + override fun onTaskStatusUpdate(context: Context, taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + // Custom status handling logic + // See Task Status documentation for detailed status information + } + + override fun onExceptionEncountered(context: Context, taskInfo: TaskDebugInfo?, exception: Throwable) { + // Handle exceptions + } +} + +WorkmanagerDebug.setCurrent(CustomDebugHandler()) +``` + + + + +```swift +class CustomDebugHandler: WorkmanagerDebug { + override func onTaskStatusUpdate(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + // Custom status handling logic + // See Task Status documentation for detailed status information + } + + override func onExceptionEncountered(taskInfo: TaskDebugInfo?, exception: Error) { + // Handle exceptions + } +} + +WorkmanagerDebug.setCurrent(CustomDebugHandler()) +``` + + + + +For detailed information about task statuses, lifecycle, and notification formats, see the [Task Status Tracking](task-status) guide. ## Android Debugging @@ -243,7 +353,7 @@ Future isTaskHealthy(String taskName, Duration maxAge) async { - [ ] Workmanager initialized in main() - [ ] Task names are unique - [ ] Platform setup completed ([iOS setup guide](quickstart#ios)) -- [ ] Debug notifications enabled (`isInDebugMode: kDebugMode`) +- [ ] Debug handler configured (see [Debug Handlers](#debug-handlers)) **Performance & Reliability:** - [ ] Task logic optimized for background execution diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 1489d0cb..1e7d8d3b 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -145,10 +145,7 @@ void callbackDispatcher() { import 'package:flutter/foundation.dart'; void main() { - Workmanager().initialize( - callbackDispatcher, - isInDebugMode: kDebugMode, - ); + Workmanager().initialize(callbackDispatcher); runApp(MyApp()); } diff --git a/docs/task-status.mdx b/docs/task-status.mdx new file mode 100644 index 00000000..a3c8d11d --- /dev/null +++ b/docs/task-status.mdx @@ -0,0 +1,295 @@ +--- +title: Task Status Tracking +description: Understanding background task lifecycle and status notifications +--- + +Workmanager provides detailed task status tracking through debug handlers, allowing you to monitor the complete lifecycle of your background tasks from scheduling to completion. + +## Task Status Overview + +Background tasks go through several status states during their lifecycle. The Workmanager plugin tracks these states and provides notifications through debug handlers. + +## Task Status States + +| Status | Description | When it occurs | Android | iOS | +|--------|-------------|----------------|---------|-----| +| **Scheduled** | Task has been scheduled with the system | When `registerOneOffTask()` or `registerPeriodicTask()` is called | ✅ | ✅ | +| **Started** | Task execution has begun (first attempt) | When task starts running for the first time | ✅ | ✅ | +| **Retrying** | Task is being retried after a previous attempt | When task starts running after `runAttemptCount > 0` | ✅ | ❌ | +| **Rescheduled** | Task will be retried later | When Dart function returns `false` | ✅ | ❌ | +| **Completed** | Task finished successfully | When Dart function returns `true` | ✅ | ✅ | +| **Failed** | Task failed permanently | When Dart function throws an exception | ✅ | ✅ | +| **Cancelled** | Task was cancelled before completion | When `cancelAll()` or `cancelByUniqueName()` is called | ✅ | ✅ | + +## Task Result Behavior + +The behavior of task status depends on what your Dart background function returns: + +### Dart Function Return Values + + + + +| Dart Return | Task Status | System Behavior | Debug Notification | +|-------------|-------------|-----------------|-------------------| +| `true` | **Completed** | Task succeeds, won't retry | ✅ Success | +| `false` | **Rescheduled** | WorkManager schedules retry with backoff | 🔄 Rescheduled | +| `Future.error()` | **Failed** | Task fails permanently, no retry | ❌ Failed + error | +| Exception thrown | **Failed** | Task fails permanently, no retry | ❌ Failed + error | + + + + +| Dart Return | Task Status | System Behavior | Debug Notification | +|-------------|-------------|-----------------|-------------------| +| `true` | **Completed** | Task succeeds, won't retry | ✅ Success | +| `false` | **Retrying** | App must manually reschedule | 🔄 Retrying | +| `Future.error()` | **Failed** | Task fails, no automatic retry | ❌ Failed + error | +| Exception thrown | **Failed** | Task fails, no automatic retry | ❌ Failed + error | + + + + +## Advanced Android Features + +### Retry Detection + +On Android, Workmanager can distinguish between first attempts and retries using `runAttemptCount`: + +| Scenario | Start Status | End Status | Notification Example | +|----------|--------------|------------|---------------------| +| Fresh task, succeeds | **Started** | **Completed** | ▶️ Started → ✅ Success | +| Fresh task, returns false | **Started** | **Rescheduled** | ▶️ Started → 🔄 Rescheduled | +| Retry attempt, succeeds | **Retrying** | **Completed** | 🔄 Retrying → ✅ Success | +| Retry attempt, fails | **Retrying** | **Failed** | 🔄 Retrying → ❌ Failed | + +### Backoff Policy + +When a task returns `false`, Android WorkManager uses exponential backoff by default: +- 1st retry: ~30 seconds +- 2nd retry: ~1 minute +- 3rd retry: ~2 minutes +- Maximum: ~5 hours + +## Debug Handler Integration + +Task status is exposed through debug handlers. Set up debug handlers to receive status notifications: + +### Notification Configuration + +The `NotificationDebugHandler` supports custom notification channels and grouping: + +**Android Options:** +- `channelId`: Custom notification channel ID for organizing notifications (if custom, you must create the channel first) +- `channelName`: Human-readable channel name shown in system settings (only used if using default channel) +- `groupKey`: Groups related notifications together in the notification drawer + +**iOS Options:** +- `categoryIdentifier`: Custom notification category for specialized handling +- `threadIdentifier`: Groups notifications in the same conversation thread + + + + +```kotlin +// NotificationDebugHandler - shows status as notifications +WorkmanagerDebug.setCurrent(NotificationDebugHandler()) + +// Custom notification channel and grouping (you must create the channel first) +val channelId = "MyAppDebugChannel" +if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, "My App Debug", NotificationManager.IMPORTANCE_DEFAULT) + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) +} +WorkmanagerDebug.setCurrent(NotificationDebugHandler( + channelId = channelId, + groupKey = "workmanager_debug_group" +)) + +// LoggingDebugHandler - writes to system log +WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + +// Custom handler +class CustomDebugHandler : WorkmanagerDebug() { + override fun onTaskStatusUpdate( + context: Context, + taskInfo: TaskDebugInfo, + status: TaskStatus, + result: TaskResult? + ) { + when (status) { + TaskStatus.SCHEDULED -> log("Task scheduled: ${taskInfo.taskName}") + TaskStatus.STARTED -> log("Task started: ${taskInfo.taskName}") + TaskStatus.RETRYING -> log("Task retrying (attempt ${taskInfo.runAttemptCount}): ${taskInfo.taskName}") + TaskStatus.RESCHEDULED -> log("Task rescheduled: ${taskInfo.taskName}") + TaskStatus.COMPLETED -> log("Task completed: ${taskInfo.taskName}") + TaskStatus.FAILED -> log("Task failed: ${taskInfo.taskName}, error: ${result?.error}") + TaskStatus.CANCELLED -> log("Task cancelled: ${taskInfo.taskName}") + } + } +} +``` + + + + +```swift +// NotificationDebugHandler - shows status as notifications +WorkmanagerDebug.setCurrent(NotificationDebugHandler()) + +// Custom notification category and thread grouping +WorkmanagerDebug.setCurrent(NotificationDebugHandler( + categoryIdentifier: "myAppDebugCategory", + threadIdentifier: "workmanager_debug_thread" +)) + +// LoggingDebugHandler - writes to system log +WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + +// Custom handler +class CustomDebugHandler: WorkmanagerDebug { + override func onTaskStatusUpdate(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + switch status { + case .scheduled: + print("Task scheduled: \(taskInfo.taskName)") + case .started: + print("Task started: \(taskInfo.taskName)") + case .retrying: + print("Task retrying: \(taskInfo.taskName)") + case .rescheduled: + print("Task rescheduled: \(taskInfo.taskName)") + case .completed: + print("Task completed: \(taskInfo.taskName)") + case .failed: + print("Task failed: \(taskInfo.taskName), error: \(result?.error ?? "unknown")") + case .cancelled: + print("Task cancelled: \(taskInfo.taskName)") + } + } +} +``` + + + + +## Notification Format + +The built-in `NotificationDebugHandler` shows concise, actionable notifications: + +### Notification Examples + +| Status | Title Format | Body | +|--------|--------------|------| +| Scheduled | 📅 Scheduled | taskName | +| Started | ▶️ Started | taskName | +| Retrying | 🔄 Retrying | taskName | +| Rescheduled | 🔄 Rescheduled | taskName | +| Success | ✅ Success | taskName | +| Failed | ❌ Failed | taskName + error message | +| Exception | ❌ Exception | taskName + exception details | +| Cancelled | ⏹️ Cancelled | taskName | + +## Platform Differences + +### Android Advantages +- **Retry detection**: Can distinguish first attempts from retries +- **Automatic rescheduling**: WorkManager handles retry logic with backoff +- **Rich debug info**: Access to `runAttemptCount` and system constraints +- **Guaranteed execution**: Tasks will retry according to policy + +### iOS Limitations +- **No retry detection**: Cannot distinguish first attempts from retries +- **Manual rescheduling**: App must reschedule tasks on failure +- **System controlled**: iOS decides when/if tasks actually run +- **No guarantees**: Tasks may never execute depending on system state + +## Best Practices + +### Task Implementation + +```dart +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + try { + // Your task logic here + final result = await performWork(task, inputData); + + if (result.isSuccess) { + return true; // ✅ Task succeeded + } else { + return false; // 🔄 Retry with backoff (Android) or manual reschedule (iOS) + } + } catch (e) { + // 🔥 Permanent failure - will not retry + throw Exception('Task failed: $e'); + } + }); +} +``` + +### Error Handling Strategy + +| Error Type | Recommended Return | Result | +|------------|-------------------|--------| +| Network timeout | `return false` | Task will retry later | +| Invalid data | `throw Exception()` | Task fails permanently | +| Temporary server error | `return false` | Task will retry with backoff | +| Authentication failure | `throw Exception()` | Task fails, needs user intervention | + +### Monitoring Task Health + +```dart +// Track task execution in your debug handler +class TaskHealthMonitor : WorkmanagerDebug() { + override fun onTaskStatusUpdate(context: Context, taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + when (status) { + TaskStatus.COMPLETED -> recordSuccess(taskInfo.taskName) + TaskStatus.FAILED -> recordFailure(taskInfo.taskName, result?.error) + TaskStatus.RETRYING -> recordRetry(taskInfo.taskName) + } + } +} +``` + +## Troubleshooting + +### Common Issues + +**Tasks showing as "Rescheduled" but not running:** +- Android: Check battery optimization and Doze mode settings +- iOS: Verify Background App Refresh is enabled and app is used regularly + +**Tasks immediately failing:** +- Check if task logic throws exceptions during initialization +- Verify all dependencies are available in background isolate +- Review error messages in Failed notifications + +**No status notifications appearing:** +- Ensure debug handler is set before task execution +- Check notification permissions (for NotificationDebugHandler) +- Verify debug handler is called during task lifecycle + +For detailed debugging guidance, see the [Debugging Guide](debugging). + +## Migration from isInDebugMode + +If you were using the deprecated `isInDebugMode` parameter: + +```dart +// ❌ Old approach (deprecated) +await Workmanager().initialize( + callbackDispatcher, + isInDebugMode: true, // Deprecated +); + +// ✅ New approach +await Workmanager().initialize(callbackDispatcher); + +// Set up platform-specific debug handler +// Android: WorkmanagerDebug.setCurrent(NotificationDebugHandler()) +// iOS: WorkmanagerDebug.setCurrent(LoggingDebugHandler()) +``` + +The new system provides much more detailed and customizable debugging information than the simple boolean flag. \ No newline at end of file diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 06fb9da0..52a70e32 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,14 @@ + + + = Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + debugChannelId, + "Workmanager Example Debug", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Debug notifications for background tasks in example app" + } + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + // EXAMPLE: Enable debug handlers for background tasks + // Choose one of the following options: + + // Option 1: Custom notification handler using our custom channel + WorkmanagerDebug.setCurrent( + NotificationDebugHandler( + channelId = debugChannelId, + channelName = "Workmanager Example Debug", + groupKey = "workmanager_example_group", + ), + ) + + // Option 2: Default notification handler (creates and uses default channel) + // WorkmanagerDebug.setCurrent(NotificationDebugHandler()) + + // Option 3: Logging-based debug handler (writes to system log) + // WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + + // Note: For Android 13+, the app needs to request POST_NOTIFICATIONS permission + // at runtime from the Flutter side or in the first activity + } +} diff --git a/example/android/app/src/main/kotlin/dev/fluttercommunity/workmanager/example/MainActivity.kt b/example/android/app/src/main/kotlin/dev/fluttercommunity/workmanager/example/MainActivity.kt new file mode 100644 index 00000000..e40ddc8e --- /dev/null +++ b/example/android/app/src/main/kotlin/dev/fluttercommunity/workmanager/example/MainActivity.kt @@ -0,0 +1,51 @@ +package dev.fluttercommunity.workmanager.example + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() { + companion object { + private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001 + } + + override fun onStart() { + super.onStart() + + // Request notification permission for Android 13+ (API 33+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_PERMISSION_REQUEST_CODE, + ) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted - debug notifications will work + println("Notification permission granted for debug handler") + } else { + // Permission denied - debug notifications won't show + println("Notification permission denied - debug notifications will not be shown") + } + } + } +} diff --git a/example/integration_test/workmanager_integration_test.dart b/example/integration_test/workmanager_integration_test.dart index ce724c2b..6f67e891 100644 --- a/example/integration_test/workmanager_integration_test.dart +++ b/example/integration_test/workmanager_integration_test.dart @@ -76,7 +76,7 @@ void main() { testWidgets('initialize should succeed on all platforms', (WidgetTester tester) async { - await workmanager.initialize(callbackDispatcher, isInDebugMode: true); + await workmanager.initialize(callbackDispatcher); // No exception means success }); diff --git a/example/ios/Podfile b/example/ios/Podfile index 0de7e7da..afde56aa 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 70aa6a17..a5a6792b 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -41,8 +41,8 @@ SPEC CHECKSUMS: path_provider_foundation: 608fcb11be570ce83519b076ab6a1fffe2474f05 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - workmanager_apple: f073c5f57af569af5c2dab83ae031bd4396c8a95 + workmanager_apple: 46692e3180809ea34232c2c29ad16d35ab793ded -PODFILE CHECKSUM: 4225ca2ac155c3e63d4d416fa6b1b890e2563502 +PODFILE CHECKSUM: bf5d48b0f58a968d755f5b593e79332a40015529 COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 9b957cf2..b32dca93 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -493,7 +493,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_VERSION = 5.0; @@ -584,7 +584,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -636,7 +636,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -730,7 +730,7 @@ DEVELOPMENT_TEAM = GPGRWN6G4J; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -762,7 +762,7 @@ DEVELOPMENT_TEAM = GPGRWN6G4J; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -791,7 +791,7 @@ DEVELOPMENT_TEAM = GPGRWN6G4J; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 79a5d40c..b27794fc 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -13,6 +13,24 @@ import workmanager_apple GeneratedPluginRegistrant.register(with: self) UNUserNotificationCenter.current().delegate = self + // Request notification permission for debug handler + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if granted { + print("Notification permission granted for debug handler") + } else if let error = error { + print("Error requesting notification permission: \(error)") + } + } + + // EXAMPLE: Enable debug notifications for background tasks + // Uncomment one of the following lines to enable debug output: + + // Option 1: Notification-based debug handler (shows debug info as notifications) + WorkmanagerDebug.setCurrent(NotificationDebugHandler()) + + // Option 2: Logging-based debug handler (writes to system log) + // WorkmanagerDebug.setCurrent(LoggingDebugHandler()) + WorkmanagerPlugin.setPluginRegistrantCallback { registry in // Registry in this case is the FlutterEngine that is created in Workmanager's // performFetchWithCompletionHandler or BGAppRefreshTask. diff --git a/example/lib/main.dart b/example/lib/main.dart index 704c6c4e..779a7d78 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -149,10 +149,7 @@ class _MyAppState extends State { } if (!workmanagerInitialized) { try { - await Workmanager().initialize( - callbackDispatcher, - isInDebugMode: true, - ); + await Workmanager().initialize(callbackDispatcher); } catch (e) { print('Error initializing Workmanager: $e'); return; diff --git a/workmanager/CHANGELOG.md b/workmanager/CHANGELOG.md index 2629438b..f01a14e0 100644 --- a/workmanager/CHANGELOG.md +++ b/workmanager/CHANGELOG.md @@ -1,16 +1,25 @@ # Future ## Breaking Changes -* **BREAKING**: Separate `ExistingWorkPolicy` and `ExistingPeriodicWorkPolicy` enums for better type safety and API clarity - * `registerPeriodicTask` now requires `ExistingPeriodicWorkPolicy` instead of `ExistingWorkPolicy` - * This mirrors Android's native WorkManager API design for better consistency - -## Bug Fixes & Improvements -* Fix issue #622: Periodic tasks running at incorrect frequencies when re-registered with different intervals - * Changed default policy from `KEEP` to `UPDATE` for periodic tasks - * `UPDATE` policy ensures new task configurations replace existing ones without disruption -* Fix null cast to map bug in executeTask when inputData contains null keys or values (thanks to @Dr-wgy) -* Internal improvements to development and testing infrastructure +* **BREAKING**: `isInDebugMode` parameter in `initialize()` is deprecated + * Parameter still accepted but will be removed in future version + * Replace with hook-based debug system using `WorkmanagerDebug.setCurrent()` +* **BREAKING**: iOS minimum deployment target increased to 14.0 + * Update your iOS project's deployment target to 14.0+ +* **BREAKING**: `registerPeriodicTask` now uses `ExistingPeriodicWorkPolicy` + * Replace `ExistingWorkPolicy` parameter with `ExistingPeriodicWorkPolicy` + +## New Features +* Add optional hook-based debug system with configurable handlers + * `NotificationDebugHandler` - shows task status as notifications + * `LoggingDebugHandler` - writes task events to system log + * Eliminates risk of debug notifications appearing in production apps +* Add `TaskStatus.SCHEDULED` and `TaskStatus.RESCHEDULED` for better task lifecycle visibility + +## Bug Fixes +* Fix periodic tasks running at wrong frequency when re-registered (#622) +* Fix crash when inputData contains null values (thanks @Dr-wgy) +* Fix Android retry detection to properly identify retrying tasks # 0.8.0 diff --git a/workmanager/README.md b/workmanager/README.md index 6c09bb18..f03934c9 100644 --- a/workmanager/README.md +++ b/workmanager/README.md @@ -7,385 +7,62 @@ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/fluttercommunity/flutter_workmanager/test.yml?branch=main&label=tests)](https://github.com/fluttercommunity/flutter_workmanager/actions) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/fluttercommunity/flutter_workmanager/blob/main/LICENSE) -Flutter WorkManager is a wrapper around [Android's WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager), [iOS' performFetchWithCompletionHandler](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1623125-application) and [iOS BGAppRefreshTask](https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask), effectively enabling headless execution of Dart code in the background. +Execute Dart code in the background, even when your app is closed. A Flutter wrapper around [Android's WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) and [iOS Background Tasks](https://developer.apple.com/documentation/backgroundtasks). -For iOS users, please watch this video on a general introduction to background processing: https://developer.apple.com/videos/play/wwdc2019/707. All of the constraints discussed in the video also apply to this plugin. +## 📖 Documentation -This is especially useful to run periodic tasks, such as fetching remote data on a regular basis. +**[Complete documentation is available at docs.page →](https://docs.page/fluttercommunity/flutter_workmanager)** -> This plugin was featured in this [Medium blogpost](https://medium.com/vrt-digital-studio/flutter-workmanager-81e0cfbd6f6e) +- **[Quick Start Guide](https://docs.page/fluttercommunity/flutter_workmanager/quickstart)** - Installation and platform setup +- **[API Documentation](https://pub.dev/documentation/workmanager/latest/)** - Complete Dart API reference +- **[Debugging Guide](https://docs.page/fluttercommunity/flutter_workmanager/debugging)** - Troubleshooting and debug hooks -## Federated Plugin Architecture - -This plugin uses a federated architecture, which means that the main `workmanager` package provides the API, while platform-specific implementations are in separate packages: - -- **workmanager**: The main package that provides the unified API -- **workmanager_platform_interface**: The common platform interface -- **workmanager_android**: Android-specific implementation -- **workmanager_apple**: Apple platform (iOS/macOS) implementation - -This architecture allows for better platform-specific optimizations and easier maintenance. When you add `workmanager` to your `pubspec.yaml`, the platform-specific packages are automatically included through the endorsed federated plugin system. - -# Platform Setup - -In order for background work to be scheduled correctly you should follow the Android and iOS setup first. - -- [Android Setup](https://github.com/fluttercommunity/flutter_workmanager/blob/master/ANDROID_SETUP.md) -- [iOS Setup](https://github.com/fluttercommunity/flutter_workmanager/blob/master/IOS_SETUP.md) - -# How to use the package? - -See sample folder for a complete working example. -Before registering any task, the WorkManager plugin must be initialized. +## 🚀 Quick Example ```dart -@pragma('vm:entry-point') // Mandatory if the App is obfuscated or using Flutter 3.1+ +@pragma('vm:entry-point') void callbackDispatcher() { - Workmanager().executeTask((task, inputData) { - print("Native called background task: $task"); //simpleTask will be emitted here. + Workmanager().executeTask((task, inputData) async { + print("Background task: $task"); + // Your background work here return Future.value(true); }); } void main() { - Workmanager().initialize( - callbackDispatcher, // The top level function, aka callbackDispatcher - isInDebugMode: true // If enabled it will post a notification whenever the task is running. Handy for debugging tasks - ); - Workmanager().registerOneOffTask("task-identifier", "simpleTask"); + Workmanager().initialize(callbackDispatcher); + Workmanager().registerOneOffTask("task-id", "simpleTask"); runApp(MyApp()); } ``` -> The `callbackDispatcher` needs to be either a static function or a top level function to be accessible as a Flutter entry point. - -The workmanager runs on a separate isolate from the main flutter isolate. Ensure to initialize all dependencies inside the `Workmanager().executeTask`. +## 🎯 Use Cases -##### Debugging tips +Perfect for: +- **Data sync** - Keep your app's data fresh +- **File uploads** - Reliable uploads in background +- **Cleanup tasks** - Remove old files and cache +- **Notifications** - Check for new messages +- **Database maintenance** - Optimize and clean databases -Wrap the code inside your `Workmanager().executeTask` in a `try and catch` in order to catch any exceptions thrown. +## 🏗️ Federated Architecture -```dart -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((task, inputData) async { +This plugin uses a federated architecture with platform-specific implementations: - int? totalExecutions; - final _sharedPreference = await SharedPreferences.getInstance(); //Initialize dependency +- **workmanager**: Main package providing the unified API +- **workmanager_android**: Android implementation using WorkManager +- **workmanager_apple**: iOS/macOS implementation using Background Tasks - try { //add code execution - totalExecutions = _sharedPreference.getInt("totalExecutions"); - _sharedPreference.setInt("totalExecutions", totalExecutions == null ? 1 : totalExecutions+1); - } catch(err) { - Logger().e(err.toString()); // Logger flutter package, prints error on the debug console - throw Exception(err); - } +## 🐛 Support & Issues - return Future.value(true); - }); -} -``` +- **Documentation**: [docs.page/fluttercommunity/flutter_workmanager](https://docs.page/fluttercommunity/flutter_workmanager) +- **Bug Reports**: [GitHub Issues](https://github.com/fluttercommunity/flutter_workmanager/issues) +- **Questions**: [GitHub Discussions](https://github.com/fluttercommunity/flutter_workmanager/discussions) -Android tasks are identified using their `taskName`. -iOS tasks are identified using their `taskIdentifier`. +## 📱 Example App -However, there is an exception for iOS background fetch: `Workmanager.iOSBackgroundTask`, a constant for iOS background fetch task. +See the [example folder](../example/) for a complete working demo with all features. --- -# Work Result - -The `Workmanager().executeTask(...` block supports 3 possible outcomes: - -1. `Future.value(true)`: The task is successful. -2. `Future.value(false)`: The task did not complete successfully and needs to be retried. On Android, the retry is done automatically. On iOS (when using BGTaskScheduler), the retry needs to be scheduled manually. -3. `Future.error(...)`: The task failed. - -On Android, the `BackoffPolicy` will configure how `WorkManager` is going to retry the task. - -Refer to the example app for a successful, retrying and a failed task. - -# iOS specific setup and note - -Initialize Workmanager only once. -Background app refresh can only be tested on a real device, it cannot be tested on a simulator. - -### Migrate to 0.6.x -Version 0.6.x of this plugin has some breaking changes for iOS: -- Workmanager.registerOneOffTask was previously using iOS **BGProcessingTask**, now it will be an immediate run task which will continue in the background if user leaves the App. Since the previous solution meant the one off task will only run if the device is idle and as often experienced only when device is charging, in practice it means somewhere at night, or not at all during that day, because **BGProcessingTask** is meant for long running tasks. The new solution makes it more in line with Android except it does not support **initialDelay** -- If you need the old behavior you can use the new iOS only method `Workmanager.registerProcessingTask`: - 1. Replace `Workmanager().registerOneOffTask` with `Workmanager().registerProcessingTask` in your App - 1. Replace `WorkmanagerPlugin.registerTask` with `WorkmanagerPlugin.registerBGProcessingTask` in `AppDelegate.swift` -- Workmanager.registerOneOffTask does not support **initialDelay** -- Workmanager.registerOneOffTask now supports **inputData** which was always returning null in the previous solution -- Workmanager.registerOneOffTask now does NOT require `WorkmanagerPlugin.registerTask` call in `AppDelegate.swift` hence remove the call - -### One off tasks -iOS supports **One off tasks** only on iOS 13+ with a few basic constraints: - -`registerOneOffTask` starts immediately. It might run for only 30 seconds due to iOS restrictions. - -```dart -Workmanager().registerOneOffTask( - "task-identifier", - simpleTaskKey, // Ignored on iOS - initialDelay: Duration(minutes: 30), // Ignored on iOS - inputData: ... // fully supported -); -``` - -### Periodic tasks -iOS supports two types of **Periodic tasks**: -- On iOS 12 and lower you can use deprecated Background Fetch API, see [iOS Setup](./IOS_SETUP.md), even though the API is -deprecated by iOS it still works on iOS 13+ as of writing this article - -- `registerPeriodicTask` is only supported on iOS 13+, it might run for only 30 seconds due to iOS restrictions, but doesn't start immediately, rather iOS will schedule it as per user's App usage pattern. - -> ⚠️ On iOS 13+, adding a `BGTaskSchedulerPermittedIdentifiers` key to the Info.plist for new `BGTaskScheduler` API disables the `performFetchWithCompletionHandler` and `setMinimumBackgroundFetchInterval` -methods, which means you cannot use both old Background Fetch and new `registerPeriodicTask` at the same time, you have to choose one based on your minimum iOS target version. -For details see [Apple Docs](https://developer.apple.com/documentation/uikit/app_and_environment/scenes/preparing_your_ui_to_run_in_the_background/using_background_tasks_to_update_your_app) - -To use `registerPeriodicTask` first register the task in `Info.plist` and `AppDelegate.swift` [iOS Setup](./IOS_SETUP.md). Unlike Android, for iOS you have to set the frequency in `AppDelegate.swift`. The frequency is not guaranteed rather iOS will schedule it as per user's App usage pattern, iOS might take a few days to learn usage pattern. In reality frequency just means do not repeat the task before x seconds/minutes. If frequency is not provided it will default to 15 minutes. - -```objc -// Register a periodic task with 20 minutes frequency. The frequency is in seconds. -WorkmanagerPlugin.registerPeriodicTask(withIdentifier: "dev.fluttercommunity.workmanagerExample.iOSBackgroundAppRefresh", frequency: NSNumber(value: 20 * 60)) -``` - -Then schedule the task from your App -```dart -const iOSBackgroundAppRefresh = "dev.fluttercommunity.workmanagerExample.iOSBackgroundAppRefresh"; -Workmanager().registerPeriodicTask( - iOSBackgroundAppRefresh, - iOSBackgroundAppRefresh, - initialDelay: Duration(seconds: 10), - frequency: Duration(hours: 1), // Ignored on iOS, rather set in AppDelegate.swift - inputData: ... // Not supported -); -``` - -For more information see [BGAppRefreshTask](https://developer.apple.com/documentation/backgroundtasks/bgapprefreshtask) - -### Processing tasks -iOS supports **Processing tasks** only on iOS 13+ which can run for more than 30 seconds. - -`registerProcessingTask` is a long running one off background task, currently only for iOS. It can be run for more than 30 seconds but doesn't start immediately, rather iOS might schedule it when device is idle and charging. -Processing tasks are for long processes like data processing and app maintenance. Processing tasks can run for minutes, but the system can interrupt these. -iOS might terminate any running background processing tasks when the user starts using the device. -For more information see [BGProcessingTask](https://developer.apple.com/documentation/backgroundtasks/bgprocessingtask) - -```dart -const iOSBackgroundProcessingTask = "dev.fluttercommunity.workmanagerExample.iOSBackgroundProcessingTask"; -Workmanager().registerProcessingTask( - iOSBackgroundProcessingTask, - iOSBackgroundProcessingTask, - initialDelay: Duration(minutes: 2), - constraints: Constraints( - // Connected or metered mark the task as requiring internet - networkType: NetworkType.connected, - // Require external power - requiresCharging: true, - ), -); -``` - -### Background App Refresh permission - -On iOS user can disable `Background App Refresh` permission anytime, hence background tasks can only run if user has granted the permission. - -Use `permision_handler` to check for the permission: - -``` dart -final status = await Permission.backgroundRefresh.status; -if (status != PermissionStatus.granted) { - _showNoPermission(context, status); - return; -} -``` - -For more information see the [BGTaskScheduler documentation](https://developer.apple.com/documentation/backgroundtasks). - -### Print scheduled tasks -On iOS you can print scheduled tasks using `Workmanager.printScheduledTasks` - -It prints task details to console. To be used during development/debugging. -Currently only supported on iOS and only on iOS 13+. - -```dart -if (Platform.isIOS) { - Workmanager().printScheduledTasks(); - // Prints: [BGTaskScheduler] Task Identifier: iOSBackgroundAppRefresh earliestBeginDate: 2023.10.10 PM 11:10:12 - // Or: [BGTaskScheduler] There are no scheduled tasks -} -``` - - -# Customisation (Android) - -Not every `Android WorkManager` feature is ported. - -Two kinds of background tasks can be registered : - -- **One off task** : runs only once -- **Periodic tasks** : runs indefinitely on a regular basis - -```dart -// One off task registration -Workmanager().registerOneOffTask( - "oneoff-task-identifier", - "simpleTask" -); - -// Periodic task registration -Workmanager().registerPeriodicTask( - "periodic-task-identifier", - "simplePeriodicTask", - // When no frequency is provided the default 15 minutes is set. - // Minimum frequency is 15 min. Android will automatically change your frequency to 15 min if you have configured a lower frequency. - frequency: Duration(hours: 1), -) -``` - -Each task must have an **unique name**; -This allows cancellation of a started task. -The second parameter is the `String` that will be sent to your `callbackDispatcher` function, indicating the task's _type_. - -## Tagging - -You can set the optional `tag` property. -Handy for cancellation by `tag`. -This is different from the unique name in that you can group multiple tasks under one tag. - -```dart -Workmanager().registerOneOffTask("1", "simpleTask", tag: "tag"); -``` - -## Existing Work Policy - -Indicates the desired behaviour when the same task is scheduled more than once. -The default is `keep` - -```dart -Workmanager().registerOneOffTask("1", "simpleTask", existingWorkPolicy: ExistingWorkPolicy.append); -``` - -## Initial Delay - -Indicates how along a task should waitbefore its first run. - -```dart -Workmanager().registerOneOffTask("1", "simpleTask", initialDelay: Duration(seconds: 10)); -``` - -## Constraints - -> Constraints are mapped at best effort to each platform. Android's WorkManager supports most of the specific constraints, whereas iOS tasks are limited. - -- NetworkType - Constrains the type of network required for your work to run. For example, Connected. - The `NetworkType` lists various network conditions. `.connected` & `.metered` will be mapped to [`requiresNetworkConnectivity`](https://developer.apple.com/documentation/backgroundtasks/bgprocessingtaskrequest/3142242-requiresnetworkconnectivity) on iOS. -- RequiresBatteryNotLow (Android only) - When set to true, your work will not run if the device is in low battery mode. - **Enabling the battery saving mode on the android device prevents the job from running** -- RequiresCharging - When set to true, your work will only run when the device is charging. -- RequiresDeviceIdle (Android only) - When set to true, this requires the user’s device to be idle before the work will run. This can be useful for running batched operations that might otherwise have a - negative performance impact on other apps running actively on the user’s device. -- RequiresStorageNotLow (Android only) - When set to true, your work will not run if the user’s storage space on the device is too low. - -```dart -Workmanager().registerOneOffTask( - "1", - "simpleTask", - constraints: Constraints( - networkType: NetworkType.connected, - requiresBatteryNotLow: true, - requiresCharging: true, - requiresDeviceIdle: true, - requiresStorageNotLow: true - ) -); -``` - -### InputData - -Add some input data for your task. Valid value types are: `int`, `bool`, `double`, `String` and their `list` - -```dart - Workmanager().registerOneOffTask( - "1", - "simpleTask", - inputData: { - 'int': 1, - 'bool': true, - 'double': 1.0, - 'string': 'string', - 'array': [1, 2, 3], - }, -); -``` - -## BackoffPolicy - -Indicates the waiting strategy upon task failure. -The default is `BackoffPolicy.exponential`. -You can also specify the delay. - -```dart -Workmanager().registerOneOffTask("1", "simpleTask", backoffPolicy: BackoffPolicy.exponential, backoffPolicyDelay: Duration(seconds: 10)); -``` - -## Cancellation - -A task can be cancelled in different ways : - -### By Tag - -Cancels the task that was previously registered using this **Tag**, if any. - -```dart -Workmanager().cancelByTag("tag"); -``` - -### By Unique Name - -```dart -Workmanager().cancelByUniqueName(""); -``` - -### All - -```dart -Workmanager().cancelAll(); -``` - - -# Building project - -Project was migrated to [Melos](https://pub.dev/packages/melos) so build steps has changed. - -1. Install melos - -``` -dart pub global activate melos -``` - -2. In project root bootstrap - -``` -melos bootstrap -``` - -3. Get packages - -``` -melos run get -``` - -Now you should be able to run example project - -``` -cd example -flutter run -``` \ No newline at end of file +For detailed setup instructions, advanced configuration, and troubleshooting, visit the **[complete documentation](https://docs.page/fluttercommunity/flutter_workmanager)**. \ No newline at end of file diff --git a/workmanager/lib/src/workmanager_impl.dart b/workmanager/lib/src/workmanager_impl.dart index 8d5508b1..c045c946 100644 --- a/workmanager/lib/src/workmanager_impl.dart +++ b/workmanager/lib/src/workmanager_impl.dart @@ -118,12 +118,16 @@ class Workmanager { /// Initialize the Workmanager with a [callbackDispatcher]. /// /// The [callbackDispatcher] is a top level function which will be invoked by Android or iOS whenever a scheduled task is due. - /// The [isInDebugMode] will post local notifications for every background worker that ran. This is very useful when trying to debug what's happening in the background. + /// + /// [isInDebugMode] is deprecated and has no effect. Use WorkmanagerDebug handlers instead. Future initialize( Function callbackDispatcher, { + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') bool isInDebugMode = false, }) async { return _platform.initialize(callbackDispatcher, + // ignore: deprecated_member_use isInDebugMode: isInDebugMode); } diff --git a/workmanager/test/backward_compatibility_test.dart b/workmanager/test/backward_compatibility_test.dart new file mode 100644 index 00000000..051fa9be --- /dev/null +++ b/workmanager/test/backward_compatibility_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workmanager/workmanager.dart'; + +void callbackDispatcher() { + // Test callback dispatcher +} + +void main() { + group('Backward compatibility', () { + test('initialize() still accepts isInDebugMode parameter', () async { + // This test verifies that existing code using isInDebugMode will still compile + // The parameter is deprecated but should not break existing code + + // This should compile without errors + await expectLater( + () async => await Workmanager().initialize( + callbackDispatcher, + // ignore: deprecated_member_use_from_same_package + isInDebugMode: true, // Deprecated but still compiles + ), + throwsA(isA()), // Platform not available in tests + ); + + // This should also compile (without the parameter) + await expectLater( + () async => await Workmanager().initialize(callbackDispatcher), + throwsA(isA()), // Platform not available in tests + ); + }); + }); +} diff --git a/workmanager_android/CHANGELOG.md b/workmanager_android/CHANGELOG.md index 57d9bfca..676ea7fb 100644 --- a/workmanager_android/CHANGELOG.md +++ b/workmanager_android/CHANGELOG.md @@ -1,21 +1,23 @@ ## Future -### Dependencies & Infrastructure Updates -* Updated androidx.work from 2.9.0 to 2.10.2 with improved Flow-based observability -* Regenerated Pigeon files with updated version 26.0.0 +### Dependencies +* Updated androidx.work from 2.9.0 to 2.10.2 ### Breaking Changes -* **BREAKING**: Update `registerPeriodicTask` to use `ExistingPeriodicWorkPolicy` instead of `ExistingWorkPolicy` - * This provides better type safety and mirrors Android's native API +* **BREAKING**: `registerPeriodicTask` now uses `ExistingPeriodicWorkPolicy` + * Replace `ExistingWorkPolicy` parameter with `ExistingPeriodicWorkPolicy` -### Bug Fixes -* Fix issue #622: Periodic tasks running at incorrect frequencies when re-registered - * Changed default `ExistingPeriodicWorkPolicy` from `KEEP` to `UPDATE` - * Ensures new task configurations properly replace existing ones -* Fix null callback crash in BackgroundWorker when FlutterCallbackInformation is null (thanks to @jonathanduke, @Muneeza-PT) +### New Features +* Add `NotificationDebugHandler` for debug notifications with configurable channels +* Add `LoggingDebugHandler` for system log-based debugging +* Add `TaskStatus.SCHEDULED` and `TaskStatus.RESCHEDULED` for better task lifecycle tracking -### Improvements -* Improve SharedPreferenceHelper callback handling - now calls callback immediately when preferences are already loaded +### Bug Fixes +* Fix periodic tasks running at wrong frequency when re-registered (#622) + * Changed default policy from `KEEP` to `UPDATE` + * `UPDATE` ensures new task configurations replace existing ones +* Fix crash when background task callback is null (thanks @jonathanduke, @Muneeza-PT) +* Fix retry detection using `runAttemptCount` to properly identify retrying tasks ## 0.8.0 diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt index 8913c9c6..ef2a37ad 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/BackgroundWorker.kt @@ -3,11 +3,11 @@ package dev.fluttercommunity.workmanager import android.content.Context import android.os.Handler import android.os.Looper -import android.util.Log import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.google.common.util.concurrent.ListenableFuture +import dev.fluttercommunity.workmanager.pigeon.TaskStatus import dev.fluttercommunity.workmanager.pigeon.WorkmanagerFlutterApi import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.dart.DartExecutor @@ -27,11 +27,8 @@ class BackgroundWorker( private lateinit var flutterApi: WorkmanagerFlutterApi companion object { - const val TAG = "BackgroundWorker" - const val PAYLOAD_KEY = "dev.fluttercommunity.workmanager.INPUT_DATA" const val DART_TASK_KEY = "dev.fluttercommunity.workmanager.DART_TASK" - const val IS_IN_DEBUG_MODE_KEY = "dev.fluttercommunity.workmanager.IS_IN_DEBUG_MODE_KEY" private val flutterLoader = FlutterLoader() } @@ -51,9 +48,7 @@ class BackgroundWorker( private val dartTask get() = workerParams.inputData.getString(DART_TASK_KEY)!! - private val isInDebug - get() = workerParams.inputData.getBoolean(IS_IN_DEBUG_MODE_KEY, false) - + private val runAttemptCount = workerParams.runAttemptCount private val randomThreadIdentifier = Random().nextInt() private var engine: FlutterEngine? = null @@ -85,24 +80,25 @@ class BackgroundWorker( val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) if (callbackInfo == null) { - Log.e(TAG, "Failed to resolve Dart callback for handle $callbackHandle.") + val exception = IllegalStateException("Failed to resolve Dart callback for handle $callbackHandle") + WorkmanagerDebug.onExceptionEncountered(applicationContext, null, exception) completer?.set(Result.failure()) return@ensureInitializationCompleteAsync } val dartBundlePath = flutterLoader.findAppBundlePath() - if (isInDebug) { - DebugHelper.postTaskStarting( - applicationContext, - randomThreadIdentifier, - dartTask, - payload, - callbackHandle, - callbackInfo, - dartBundlePath, + val taskInfo = + TaskDebugInfo( + taskName = dartTask, + inputData = payload, + startTime = startTime, + callbackHandle = callbackHandle, + callbackInfo = callbackInfo?.callbackName, ) - } + + val startStatus = if (runAttemptCount > 0) TaskStatus.RETRYING else TaskStatus.STARTED + WorkmanagerDebug.onTaskStatusUpdate(applicationContext, taskInfo, startStatus) engine?.let { engine -> flutterApi = WorkmanagerFlutterApi(engine.dartExecutor.binaryMessenger) @@ -130,19 +126,37 @@ class BackgroundWorker( stopEngine(null) } - private fun stopEngine(result: Result?) { + private fun stopEngine( + result: Result?, + errorMessage: String? = null, + ) { val fetchDuration = System.currentTimeMillis() - startTime - if (isInDebug) { - DebugHelper.postTaskCompleteNotification( - applicationContext, - randomThreadIdentifier, - dartTask, - payload, - fetchDuration, - result ?: Result.failure(), + val taskInfo = + TaskDebugInfo( + taskName = dartTask, + inputData = payload, + startTime = startTime, ) - } + + val taskResult = + TaskResult( + success = result is Result.Success, + duration = fetchDuration, + error = + when (result) { + is Result.Failure -> errorMessage ?: "Task failed" + else -> null + }, + ) + + val status = + when (result) { + is Result.Success -> TaskStatus.COMPLETED + is Result.Retry -> TaskStatus.RESCHEDULED + else -> TaskStatus.FAILED + } + WorkmanagerDebug.onTaskStatusUpdate(applicationContext, taskInfo, status, taskResult) // No result indicates we were signalled to stop by WorkManager. The result is already // STOPPED, so no need to resolve another one. @@ -168,8 +182,10 @@ class BackgroundWorker( stopEngine(if (wasSuccessful) Result.success() else Result.retry()) } result.isFailure -> { - Log.e(TAG, "Error executing task: ${result.exceptionOrNull()?.message}") - stopEngine(Result.failure()) + val exception = result.exceptionOrNull() + // Don't call onExceptionEncountered for Dart task failures + // These are handled as normal failures via onTaskStatusUpdate + stopEngine(Result.failure(), exception?.message) } } } diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DebugHelper.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DebugHelper.kt deleted file mode 100644 index a57f379c..00000000 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/DebugHelper.kt +++ /dev/null @@ -1,116 +0,0 @@ -package dev.fluttercommunity.workmanager - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.work.ListenableWorker -import io.flutter.view.FlutterCallbackInformation -import java.text.DateFormat -import java.util.Date -import java.util.concurrent.TimeUnit.MILLISECONDS - -object ThumbnailGenerator { - fun mapResultToEmoji(result: ListenableWorker.Result): String = - when (result) { - is ListenableWorker.Result.Success -> "\uD83C\uDF89" - else -> "\uD83D\uDD25" - } - - val workEmoji get() = listOf("\uD83D\uDC77\u200D♀️", "\uD83D\uDC77\u200D♂️").random() -} - -object DebugHelper { - private const val DEBUG_CHANNEL_ID = "WorkmanagerDebugChannelId" - private const val DEBUG_CHANNEL_NAME = "A helper channel to debug your background tasks." - private val debugDateFormatter = - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) - - private val currentTime get() = debugDateFormatter.format(Date()) - - private fun mapMillisToSeconds(milliseconds: Long) = "${MILLISECONDS.toSeconds(milliseconds)} seconds." - - fun postTaskCompleteNotification( - ctx: Context, - threadIdentifier: Int, - dartTask: String, - payload: Map? = null, - fetchDuration: Long, - result: ListenableWorker.Result, - ) { - postNotification( - ctx, - threadIdentifier, - "${ThumbnailGenerator.workEmoji} $currentTime", - """ - • Result: ${ThumbnailGenerator.mapResultToEmoji(result)} ${result.javaClass.simpleName} - • dartTask: $dartTask - • inputData: ${payload ?: "not found"} - • Elapsed time: ${mapMillisToSeconds(fetchDuration)} - """.trimIndent(), - ) - } - - fun postTaskStarting( - ctx: Context, - threadIdentifier: Int, - dartTask: String, - payload: Map? = null, - callbackHandle: Long, - callbackInfo: FlutterCallbackInformation?, - dartBundlePath: String?, - ) { - postNotification( - ctx, - threadIdentifier, - "${ThumbnailGenerator.workEmoji} $currentTime", - """ - • dartTask: $dartTask - • inputData: ${payload ?: "not found"} - • callbackHandle: $callbackHandle - • callBackName: ${callbackInfo?.callbackName ?: "not found"} - • callbackClassName: ${callbackInfo?.callbackClassName ?: "not found"} - • callbackLibraryPath: ${callbackInfo?.callbackLibraryPath ?: "not found"} - • dartBundlePath: $dartBundlePath" - """.trimIndent(), - ) - } - - private fun postNotification( - ctx: Context, - messageId: Int, - title: String, - contentText: String, - ) { - (ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).apply { - createNotificationChannel() - - notify( - messageId, - NotificationCompat - .Builder(ctx, DEBUG_CHANNEL_ID) - .setContentTitle(title) - .setContentText(contentText) - .setStyle( - NotificationCompat - .BigTextStyle() - .bigText(contentText), - ).setSmallIcon(android.R.drawable.stat_notify_sync) - .build(), - ) - } - } - - private fun NotificationManager.createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel( - NotificationChannel( - DEBUG_CHANNEL_ID, - DEBUG_CHANNEL_NAME, - NotificationManager.IMPORTANCE_DEFAULT, - ), - ) - } - } -} diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/LoggingDebugHandler.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/LoggingDebugHandler.kt new file mode 100644 index 00000000..8c994ad8 --- /dev/null +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/LoggingDebugHandler.kt @@ -0,0 +1,47 @@ +package dev.fluttercommunity.workmanager + +import android.content.Context +import android.util.Log +import dev.fluttercommunity.workmanager.pigeon.TaskStatus + +/** + * A debug handler that outputs debug information to Android's Log system. + */ +class LoggingDebugHandler : WorkmanagerDebug() { + companion object { + private const val TAG = "WorkmanagerDebug" + } + + override fun onTaskStatusUpdate( + context: Context, + taskInfo: TaskDebugInfo, + status: TaskStatus, + result: TaskResult?, + ) { + when (status) { + TaskStatus.SCHEDULED -> Log.d(TAG, "Task scheduled: ${taskInfo.taskName}") + TaskStatus.STARTED -> Log.d(TAG, "Task started: ${taskInfo.taskName}, callbackHandle: ${taskInfo.callbackHandle}") + TaskStatus.COMPLETED -> { + val success = result?.success ?: false + val duration = result?.duration ?: 0 + Log.d(TAG, "Task completed: ${taskInfo.taskName}, success: $success, duration: ${duration}ms") + } + TaskStatus.FAILED -> { + val error = result?.error ?: "Unknown error" + Log.e(TAG, "Task failed: ${taskInfo.taskName}, error: $error") + } + TaskStatus.CANCELLED -> Log.w(TAG, "Task cancelled: ${taskInfo.taskName}") + TaskStatus.RETRYING -> Log.w(TAG, "Task retrying: ${taskInfo.taskName}") + TaskStatus.RESCHEDULED -> Log.w(TAG, "Task rescheduled: ${taskInfo.taskName}") + } + } + + override fun onExceptionEncountered( + context: Context, + taskInfo: TaskDebugInfo?, + exception: Throwable, + ) { + val taskName = taskInfo?.taskName ?: "unknown" + Log.e(TAG, "Exception in task: $taskName", exception) + } +} diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/NotificationDebugHandler.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/NotificationDebugHandler.kt new file mode 100644 index 00000000..cd3b0df4 --- /dev/null +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/NotificationDebugHandler.kt @@ -0,0 +1,168 @@ +package dev.fluttercommunity.workmanager + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import dev.fluttercommunity.workmanager.pigeon.TaskStatus +import java.text.DateFormat +import java.util.Date +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.random.Random + +/** + * A debug handler that shows notifications for task events. + * Note: You need to ensure your app has notification permissions. + * + * @param channelId Custom notification channel ID (defaults to "WorkmanagerDebugChannelId") + * @param channelName Custom notification channel name (defaults to "Workmanager Debug") + * @param groupKey Custom notification group key for grouping notifications (optional) + */ +class NotificationDebugHandler( + private val channelId: String = "WorkmanagerDebugChannelId", + private val channelName: String = "Workmanager Debug", + private val groupKey: String? = null, +) : WorkmanagerDebug() { + private val isUsingDefaultChannel = channelId == "WorkmanagerDebugChannelId" + + companion object { + private val debugDateFormatter = + DateFormat.getTimeInstance(DateFormat.SHORT) + } + + private val startEmoji = "▶️" + private val retryEmoji = "🔄" + private val successEmoji = "✅" + private val failureEmoji = "❌" + private val stopEmoji = "⏹️" + private val currentTime get() = debugDateFormatter.format(Date()) + + override fun onTaskStatusUpdate( + context: Context, + taskInfo: TaskDebugInfo, + status: TaskStatus, + result: TaskResult?, + ) { + val notificationId = Random.nextInt() + val (emoji, title, content) = + when (status) { + TaskStatus.SCHEDULED -> + Triple( + "📅", + "Scheduled", + taskInfo.taskName, + ) + TaskStatus.STARTED -> + Triple( + startEmoji, + "Started", + taskInfo.taskName, + ) + TaskStatus.RETRYING -> + Triple( + retryEmoji, + "Retrying", + taskInfo.taskName, + ) + TaskStatus.RESCHEDULED -> + Triple( + retryEmoji, + "Rescheduled", + taskInfo.taskName, + ) + TaskStatus.COMPLETED -> { + val success = result?.success ?: false + val duration = MILLISECONDS.toSeconds(result?.duration ?: 0) + Triple( + if (success) successEmoji else failureEmoji, + if (success) "Success ${duration}s" else "Failed ${duration}s", + taskInfo.taskName, + ) + } + TaskStatus.FAILED -> { + val duration = MILLISECONDS.toSeconds(result?.duration ?: 0) + Triple( + failureEmoji, + "Failed ${duration}s", + "${taskInfo.taskName}\n${result?.error ?: "Unknown"}", + ) + } + TaskStatus.CANCELLED -> + Triple( + stopEmoji, + "Cancelled", + taskInfo.taskName, + ) + } + + postNotification( + context, + notificationId, + "$emoji $title", + content, + ) + } + + override fun onExceptionEncountered( + context: Context, + taskInfo: TaskDebugInfo?, + exception: Throwable, + ) { + val notificationId = Random.nextInt() + val taskName = taskInfo?.taskName ?: "unknown" + postNotification( + context, + notificationId, + "$failureEmoji Exception", + "$taskName\n${exception.message}", + ) + } + + private fun postNotification( + context: Context, + notificationId: Int, + title: String, + contentText: String, + ) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Only create notification channel if using default parameters + if (isUsingDefaultChannel) { + createNotificationChannel(notificationManager) + } + + val notificationBuilder = + NotificationCompat + .Builder(context, channelId) + .setContentTitle(title) + .setContentText(contentText) + .setStyle( + NotificationCompat + .BigTextStyle() + .bigText(contentText), + ).setSmallIcon(android.R.drawable.stat_notify_sync) + .setPriority(NotificationCompat.PRIORITY_LOW) + + // Add group key if specified + groupKey?.let { + notificationBuilder.setGroup(it) + } + + val notification = notificationBuilder.build() + + notificationManager.notify(notificationId, notification) + } + + private fun createNotificationChannel(notificationManager: NotificationManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager.createNotificationChannel(channel) + } + } +} diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt index 397e9721..bc4138ac 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkManagerUtils.kt @@ -13,7 +13,7 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import dev.fluttercommunity.workmanager.BackgroundWorker.Companion.DART_TASK_KEY -import dev.fluttercommunity.workmanager.BackgroundWorker.Companion.IS_IN_DEBUG_MODE_KEY +import dev.fluttercommunity.workmanager.pigeon.TaskStatus import java.util.concurrent.TimeUnit // Constants @@ -100,10 +100,7 @@ class WorkManagerWrapper( ) { private val workManager = WorkManager.getInstance(context) - fun enqueueOneOffTask( - request: dev.fluttercommunity.workmanager.pigeon.OneOffTaskRequest, - isInDebugMode: Boolean = false, - ) { + fun enqueueOneOffTask(request: dev.fluttercommunity.workmanager.pigeon.OneOffTaskRequest) { try { val oneOffTaskRequest = OneTimeWorkRequest @@ -111,7 +108,6 @@ class WorkManagerWrapper( .setInputData( buildTaskInputData( request.taskName, - isInDebugMode, request.inputData?.filterNotNullKeys(), ), ).setInitialDelay( @@ -139,15 +135,21 @@ class WorkManagerWrapper( ?: defaultOneOffExistingWorkPolicy, oneOffTaskRequest, ) + + val taskInfo = + TaskDebugInfo( + taskName = request.taskName, + uniqueName = request.uniqueName, + inputData = request.inputData?.filterNotNullKeys(), + startTime = System.currentTimeMillis(), + ) + WorkmanagerDebug.onTaskStatusUpdate(context, taskInfo, TaskStatus.SCHEDULED) } catch (e: Exception) { throw e } } - fun enqueuePeriodicTask( - request: dev.fluttercommunity.workmanager.pigeon.PeriodicTaskRequest, - isInDebugMode: Boolean = false, - ) { + fun enqueuePeriodicTask(request: dev.fluttercommunity.workmanager.pigeon.PeriodicTaskRequest) { val periodicTaskRequest = PeriodicWorkRequest .Builder( @@ -159,7 +161,6 @@ class WorkManagerWrapper( ).setInputData( buildTaskInputData( request.taskName, - isInDebugMode, request.inputData?.filterNotNullKeys(), ), ).setInitialDelay( @@ -186,18 +187,25 @@ class WorkManagerWrapper( ?: defaultPeriodExistingWorkPolicy, periodicTaskRequest, ) + + val taskInfo = + TaskDebugInfo( + taskName = request.taskName, + uniqueName = request.uniqueName, + inputData = request.inputData?.filterNotNullKeys(), + startTime = System.currentTimeMillis(), + ) + WorkmanagerDebug.onTaskStatusUpdate(context, taskInfo, TaskStatus.SCHEDULED) } private fun buildTaskInputData( dartTask: String, - isInDebugMode: Boolean, payload: Map?, ): Data { val builder = Data .Builder() .putString(DART_TASK_KEY, dartTask) - .putBoolean(IS_IN_DEBUG_MODE_KEY, isInDebugMode) // Add payload data if provided payload?.forEach { (key, value) -> diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerDebugHandler.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerDebugHandler.kt new file mode 100644 index 00000000..19219b2e --- /dev/null +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerDebugHandler.kt @@ -0,0 +1,91 @@ +package dev.fluttercommunity.workmanager + +import android.content.Context +import dev.fluttercommunity.workmanager.pigeon.TaskStatus + +/** + * Information about a task for debugging purposes. + */ +data class TaskDebugInfo( + val taskName: String, + val uniqueName: String? = null, + val inputData: Map? = null, + val startTime: Long, + val callbackHandle: Long? = null, + val callbackInfo: String? = null, +) + +/** + * Result information for a completed task. + */ +data class TaskResult( + val success: Boolean, + val duration: Long, + val error: String? = null, +) + +/** + * Abstract debug handler for Workmanager events. + * Override methods to customize debug behavior. Default implementations do nothing. + */ +abstract class WorkmanagerDebug { + companion object { + @JvmStatic + private var current: WorkmanagerDebug = object : WorkmanagerDebug() {} + + /** + * Set the global debug handler. + */ + @JvmStatic + fun setCurrent(handler: WorkmanagerDebug) { + current = handler + } + + /** + * Get the current debug handler. + */ + @JvmStatic + fun getCurrent(): WorkmanagerDebug = current + + // Internal methods for the plugin to call + internal fun onTaskStatusUpdate( + context: Context, + taskInfo: TaskDebugInfo, + status: TaskStatus, + result: TaskResult? = null, + ) { + current.onTaskStatusUpdate(context, taskInfo, status, result) + } + + internal fun onExceptionEncountered( + context: Context, + taskInfo: TaskDebugInfo?, + exception: Throwable, + ) { + current.onExceptionEncountered(context, taskInfo, exception) + } + } + + /** + * Called when a task status changes. + */ + open fun onTaskStatusUpdate( + context: Context, + taskInfo: TaskDebugInfo, + status: TaskStatus, + result: TaskResult?, + ) { + // Default: do nothing + } + + /** + * Called when an exception occurs during task processing. + */ + open fun onExceptionEncountered( + context: Context, + taskInfo: TaskDebugInfo?, + exception: Throwable, + ) { + // Default: do nothing + } +} diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt index a8b301d4..4799ada1 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/WorkmanagerPlugin.kt @@ -22,7 +22,6 @@ class WorkmanagerPlugin : private lateinit var preferenceManager: SharedPreferenceHelper private var currentDispatcherHandle: Long = -1L - private var isInDebugMode: Boolean = false override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { preferenceManager = @@ -49,7 +48,6 @@ class WorkmanagerPlugin : ) { try { preferenceManager.saveCallbackDispatcherHandleKey(request.callbackHandle) - isInDebugMode = request.isInDebugMode callback(Result.success(Unit)) } catch (e: Exception) { callback(Result.failure(e)) @@ -66,10 +64,7 @@ class WorkmanagerPlugin : } try { - workManagerWrapper!!.enqueueOneOffTask( - request = request, - isInDebugMode = isInDebugMode, - ) + workManagerWrapper!!.enqueueOneOffTask(request = request) callback(Result.success(Unit)) } catch (e: Exception) { callback(Result.failure(e)) @@ -86,10 +81,7 @@ class WorkmanagerPlugin : } try { - workManagerWrapper!!.enqueuePeriodicTask( - request = request, - isInDebugMode = isInDebugMode, - ) + workManagerWrapper!!.enqueuePeriodicTask(request = request) callback(Result.success(Unit)) } catch (e: Exception) { callback(Result.failure(e)) diff --git a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt index efc3fec6..8d9ec9a1 100644 --- a/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt +++ b/workmanager_android/android/src/main/kotlin/dev/fluttercommunity/workmanager/pigeon/WorkmanagerApi.g.kt @@ -84,6 +84,30 @@ class FlutterError ( val details: Any? = null ) : Throwable() +/** Task status for debugging and monitoring. */ +enum class TaskStatus(val raw: Int) { + /** Task has been scheduled */ + SCHEDULED(0), + /** Task has started execution */ + STARTED(1), + /** Task completed successfully */ + COMPLETED(2), + /** Task failed */ + FAILED(3), + /** Task was cancelled */ + CANCELLED(4), + /** Task is being retried */ + RETRYING(5), + /** Task was rescheduled for later execution */ + RESCHEDULED(6); + + companion object { + fun ofRaw(raw: Int): TaskStatus? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** * An enumeration of various network types that can be used as Constraints for work. * @@ -311,21 +335,18 @@ data class BackoffPolicyConfig ( /** Generated class from Pigeon that represents data sent in messages. */ data class InitializeRequest ( - val callbackHandle: Long, - val isInDebugMode: Boolean + val callbackHandle: Long ) { companion object { fun fromList(pigeonVar_list: List): InitializeRequest { val callbackHandle = pigeonVar_list[0] as Long - val isInDebugMode = pigeonVar_list[1] as Boolean - return InitializeRequest(callbackHandle, isInDebugMode) + return InitializeRequest(callbackHandle) } } fun toList(): List { return listOf( callbackHandle, - isInDebugMode, ) } override fun equals(other: Any?): Boolean { @@ -494,55 +515,60 @@ private open class WorkmanagerApiPigeonCodec : StandardMessageCodec() { return when (type) { 129.toByte() -> { return (readValue(buffer) as Long?)?.let { - NetworkType.ofRaw(it.toInt()) + TaskStatus.ofRaw(it.toInt()) } } 130.toByte() -> { return (readValue(buffer) as Long?)?.let { - BackoffPolicy.ofRaw(it.toInt()) + NetworkType.ofRaw(it.toInt()) } } 131.toByte() -> { return (readValue(buffer) as Long?)?.let { - ExistingWorkPolicy.ofRaw(it.toInt()) + BackoffPolicy.ofRaw(it.toInt()) } } 132.toByte() -> { return (readValue(buffer) as Long?)?.let { - ExistingPeriodicWorkPolicy.ofRaw(it.toInt()) + ExistingWorkPolicy.ofRaw(it.toInt()) } } 133.toByte() -> { return (readValue(buffer) as Long?)?.let { - OutOfQuotaPolicy.ofRaw(it.toInt()) + ExistingPeriodicWorkPolicy.ofRaw(it.toInt()) } } 134.toByte() -> { + return (readValue(buffer) as Long?)?.let { + OutOfQuotaPolicy.ofRaw(it.toInt()) + } + } + 135.toByte() -> { return (readValue(buffer) as? List)?.let { Constraints.fromList(it) } } - 135.toByte() -> { + 136.toByte() -> { return (readValue(buffer) as? List)?.let { BackoffPolicyConfig.fromList(it) } } - 136.toByte() -> { + 137.toByte() -> { return (readValue(buffer) as? List)?.let { InitializeRequest.fromList(it) } } - 137.toByte() -> { + 138.toByte() -> { return (readValue(buffer) as? List)?.let { OneOffTaskRequest.fromList(it) } } - 138.toByte() -> { + 139.toByte() -> { return (readValue(buffer) as? List)?.let { PeriodicTaskRequest.fromList(it) } } - 139.toByte() -> { + 140.toByte() -> { return (readValue(buffer) as? List)?.let { ProcessingTaskRequest.fromList(it) } @@ -552,48 +578,52 @@ private open class WorkmanagerApiPigeonCodec : StandardMessageCodec() { } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is NetworkType -> { + is TaskStatus -> { stream.write(129) writeValue(stream, value.raw) } - is BackoffPolicy -> { + is NetworkType -> { stream.write(130) writeValue(stream, value.raw) } - is ExistingWorkPolicy -> { + is BackoffPolicy -> { stream.write(131) writeValue(stream, value.raw) } - is ExistingPeriodicWorkPolicy -> { + is ExistingWorkPolicy -> { stream.write(132) writeValue(stream, value.raw) } - is OutOfQuotaPolicy -> { + is ExistingPeriodicWorkPolicy -> { stream.write(133) writeValue(stream, value.raw) } - is Constraints -> { + is OutOfQuotaPolicy -> { stream.write(134) + writeValue(stream, value.raw) + } + is Constraints -> { + stream.write(135) writeValue(stream, value.toList()) } is BackoffPolicyConfig -> { - stream.write(135) + stream.write(136) writeValue(stream, value.toList()) } is InitializeRequest -> { - stream.write(136) + stream.write(137) writeValue(stream, value.toList()) } is OneOffTaskRequest -> { - stream.write(137) + stream.write(138) writeValue(stream, value.toList()) } is PeriodicTaskRequest -> { - stream.write(138) + stream.write(139) writeValue(stream, value.toList()) } is ProcessingTaskRequest -> { - stream.write(139) + stream.write(140) writeValue(stream, value.toList()) } else -> super.writeValue(stream, value) diff --git a/workmanager_android/lib/workmanager_android.dart b/workmanager_android/lib/workmanager_android.dart index 149e3dbf..d6917fb5 100644 --- a/workmanager_android/lib/workmanager_android.dart +++ b/workmanager_android/lib/workmanager_android.dart @@ -17,12 +17,13 @@ class WorkmanagerAndroid extends WorkmanagerPlatform { @override Future initialize( Function callbackDispatcher, { + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') bool isInDebugMode = false, }) async { final callback = PluginUtilities.getCallbackHandle(callbackDispatcher); await _api.initialize(InitializeRequest( callbackHandle: callback!.toRawHandle(), - isInDebugMode: isInDebugMode, )); } diff --git a/workmanager_apple/CHANGELOG.md b/workmanager_apple/CHANGELOG.md index 63d34116..c53cfc29 100644 --- a/workmanager_apple/CHANGELOG.md +++ b/workmanager_apple/CHANGELOG.md @@ -1,11 +1,17 @@ ## Future -### Dependencies & Infrastructure Updates -* Regenerated Pigeon files with updated version 26.0.0 for enhanced multi-platform support - ### Breaking Changes -* **BREAKING**: Update `registerPeriodicTask` to use `ExistingPeriodicWorkPolicy` instead of `ExistingWorkPolicy` - * This provides better type safety across all platforms +* **BREAKING**: iOS minimum deployment target increased to 14.0 + * Update your iOS project's deployment target to 14.0+ + * Required for notification debug handlers (iOS 14+ notification permissions) +* **BREAKING**: `registerPeriodicTask` now uses `ExistingPeriodicWorkPolicy` + * Replace `ExistingWorkPolicy` parameter with `ExistingPeriodicWorkPolicy` + +### New Features +* Add `NotificationDebugHandler` for debug notifications with configurable grouping + * Requires iOS 14+ and notification permissions +* Add `LoggingDebugHandler` for system log-based debugging +* Add `TaskStatus.SCHEDULED` and `TaskStatus.RESCHEDULED` for better task lifecycle tracking ## 0.8.0 diff --git a/workmanager_apple/ios/Classes/BackgroundWorker.swift b/workmanager_apple/ios/Classes/BackgroundWorker.swift index dc28119f..9f85ff2e 100644 --- a/workmanager_apple/ios/Classes/BackgroundWorker.swift +++ b/workmanager_apple/ios/Classes/BackgroundWorker.swift @@ -85,13 +85,15 @@ class BackgroundWorker { let taskSessionStart = Date() let taskSessionIdentifier = UUID() - let debugHelper = DebugNotificationHelper(taskSessionIdentifier) - debugHelper.showStartFetchNotification( - startDate: taskSessionStart, - callBackHandle: callbackHandle, - callbackInfo: flutterCallbackInformation + let taskInfo = TaskDebugInfo( + taskName: "background_fetch", + startTime: taskSessionStart.timeIntervalSince1970, + callbackHandle: callbackHandle, + callbackInfo: flutterCallbackInformation.callbackName ) + WorkmanagerDebug.onTaskStatusUpdate(taskInfo: taskInfo, status: .started) + var flutterEngine: FlutterEngine? = FlutterEngine( name: backgroundMode.flutterThreadlabelPrefix, project: nil, @@ -131,11 +133,24 @@ class BackgroundWorker { let taskSessionCompleter = Date() let fetchResult: UIBackgroundFetchResult + let status: TaskStatus + let errorMessage: String? + switch taskResult { case .success(let wasSuccessful): - fetchResult = wasSuccessful ? .newData : .failed - case .failure: + if wasSuccessful { + fetchResult = .newData + status = .completed + errorMessage = nil + } else { + fetchResult = .failed + status = .retrying + errorMessage = nil + } + case .failure(let error): fetchResult = .failed + status = .failed + errorMessage = error.localizedDescription } let taskDuration = taskSessionCompleter.timeIntervalSince(taskSessionStart) @@ -143,11 +158,12 @@ class BackgroundWorker { "[\(String(describing: self))] \(#function) -> performBackgroundRequest.\(fetchResult) (finished in \(taskDuration.formatToSeconds()))" ) - debugHelper.showCompletedFetchNotification( - completedDate: taskSessionCompleter, - result: fetchResult, - elapsedTime: taskDuration + let taskResult = TaskResult( + success: status == .completed, + duration: Int64(taskDuration * 1000), // Convert to milliseconds + error: errorMessage ) + WorkmanagerDebug.onTaskStatusUpdate(taskInfo: taskInfo, status: status, result: taskResult) completionHandler(fetchResult) } case .failure(let error): diff --git a/workmanager_apple/ios/Classes/DebugNotificationHelper.swift b/workmanager_apple/ios/Classes/DebugNotificationHelper.swift deleted file mode 100644 index 9e1b223e..00000000 --- a/workmanager_apple/ios/Classes/DebugNotificationHelper.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// LocalNotificationHelper.swift -// workmanager -// -// Created by Kymer Gryson on 12/08/2019. -// - -import Foundation -import UserNotifications - -#if os(iOS) -import Flutter -#elseif os(macOS) -import FlutterMacOS -#else -#error("Unsupported platform.") -#endif - -class DebugNotificationHelper { - - private let identifier: UUID - - init(_ identifier: UUID) { - self.identifier = identifier - } - - func showStartFetchNotification(startDate: Date, - callBackHandle: Int64, - callbackInfo: FlutterCallbackInformation - ) { - let message = - """ - Starting Dart/Flutter with following params: - • callbackHandle: '\(callBackHandle)' - • callBackName: '\(callbackInfo.callbackName ?? "not found")' - • callbackClassName: '\(callbackInfo.callbackClassName ?? "not found")' - • callbackLibraryPath: '\(callbackInfo.callbackLibraryPath ?? "not found")' - """ - DebugNotificationHelper.scheduleNotification(identifier: identifier.uuidString, - title: startDate.formatted(), - body: message, - icon: .startWork) - } - - func showCompletedFetchNotification(completedDate: Date, - result: UIBackgroundFetchResult, - elapsedTime: TimeInterval) { - let message = - """ - Perform fetch completed: - • Elapsed time: \(elapsedTime.formatToSeconds()) - • Result: UIBackgroundFetchResult.\(result) - """ - DebugNotificationHelper.scheduleNotification(identifier: identifier.uuidString, - title: completedDate.formatted(), - body: message, - icon: result == .newData ? .success : .failure) - } - - // MARK: - Private helper functions - - private static func scheduleNotification(identifier: String, - title: String, - body: String, - icon: ThumbnailGenerator.ThumbnailIcon) { - guard UserDefaultsHelper.getIsDebug() else { - logInfo("\(logPrefix) \(#function): plugin is not running in debug mode or on iOS 9 or lower") - return - } - - UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert]) { (_, _) in } - let notificationRequest = createNotificationRequest( - identifier: identifier, - threadIdentifier: WorkmanagerPlugin.identifier, - title: title, - body: body, - icon: icon - ) - UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: nil) - - } - - private static func createNotificationRequest(identifier: String, - threadIdentifier: String, - title: String, - body: String, - icon: ThumbnailGenerator.ThumbnailIcon) -> UNNotificationRequest { - let notification = UNMutableNotificationContent() - notification.title = title - notification.body = body - notification.threadIdentifier = threadIdentifier - if let thumbnail = ThumbnailGenerator.createThumbnail(with: icon) { - notification.attachments = [thumbnail] - } - let immediateFutureTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) - let notificationRequest = UNNotificationRequest( - identifier: identifier, - content: notification, - trigger: immediateFutureTrigger - ) - - return notificationRequest - } - - private static var logPrefix: String { - return "\(String(describing: WorkmanagerPlugin.self)) - \(DebugNotificationHelper.self)" - } - -} diff --git a/workmanager_apple/ios/Classes/LoggingDebugHandler.swift b/workmanager_apple/ios/Classes/LoggingDebugHandler.swift new file mode 100644 index 00000000..7966251e --- /dev/null +++ b/workmanager_apple/ios/Classes/LoggingDebugHandler.swift @@ -0,0 +1,38 @@ +import Foundation +import os + +/** + * A debug handler that outputs debug information to iOS's unified logging system. + */ +public class LoggingDebugHandler: WorkmanagerDebug { + private let logger = os.Logger(subsystem: "dev.fluttercommunity.workmanager", category: "debug") + + public override init() {} + + override func onTaskStatusUpdate(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + switch status { + case .scheduled: + logger.debug("Task scheduled: \(taskInfo.taskName)") + case .started: + logger.debug("Task started: \(taskInfo.taskName), callbackHandle: \(taskInfo.callbackHandle ?? -1)") + case .completed: + let success = result?.success ?? false + let duration = result?.duration ?? 0 + logger.debug("Task completed: \(taskInfo.taskName), success: \(success), duration: \(duration)ms") + case .failed: + let error = result?.error ?? "Unknown error" + logger.error("Task failed: \(taskInfo.taskName), error: \(error)") + case .cancelled: + logger.info("Task cancelled: \(taskInfo.taskName)") + case .retrying: + logger.info("Task retrying: \(taskInfo.taskName)") + case .rescheduled: + logger.info("Task rescheduled: \(taskInfo.taskName)") + } + } + + override func onExceptionEncountered(taskInfo: TaskDebugInfo?, exception: Error) { + let taskName = taskInfo?.taskName ?? "unknown" + logger.error("Exception in task: \(taskName), error: \(exception.localizedDescription)") + } +} diff --git a/workmanager_apple/ios/Classes/NotificationDebugHandler.swift b/workmanager_apple/ios/Classes/NotificationDebugHandler.swift new file mode 100644 index 00000000..ad83054c --- /dev/null +++ b/workmanager_apple/ios/Classes/NotificationDebugHandler.swift @@ -0,0 +1,97 @@ +import Foundation +import UserNotifications + +/** + * A debug handler that shows notifications for task events. + * Note: You need to ensure your app has notification permissions. + * + * @param categoryIdentifier Custom notification category identifier (optional) + * @param threadIdentifier Custom thread identifier for grouping notifications (optional) + */ +public class NotificationDebugHandler: WorkmanagerDebug { + private let identifier = UUID().uuidString + private let startEmoji = "▶️" + private let retryEmoji = "🔄" + private let successEmoji = "✅" + private let failureEmoji = "❌" + private let stopEmoji = "⏹️" + + private let categoryIdentifier: String? + private let threadIdentifier: String? + + public init(categoryIdentifier: String? = nil, threadIdentifier: String? = nil) { + self.categoryIdentifier = categoryIdentifier + self.threadIdentifier = threadIdentifier + super.init() + } + + override func onTaskStatusUpdate(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + let (emoji, title, message) = formatNotification(taskInfo: taskInfo, status: status, result: result) + scheduleNotification( + title: "\(emoji) \(title)", + body: message + ) + } + + override func onExceptionEncountered(taskInfo: TaskDebugInfo?, exception: Error) { + let taskName = taskInfo?.taskName ?? "unknown" + scheduleNotification( + title: "\(failureEmoji) Exception", + body: "\(taskName)\n\(exception.localizedDescription)" + ) + } + + private func formatNotification(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) -> (String, String, String) { + switch status { + case .scheduled: + return ("📅", "Scheduled", taskInfo.taskName) + case .started: + return (startEmoji, "Started", taskInfo.taskName) + case .retrying: + return (retryEmoji, "Retrying", taskInfo.taskName) + case .rescheduled: + return (retryEmoji, "Rescheduled", taskInfo.taskName) + case .completed: + let success = result?.success ?? false + let duration = (result?.duration ?? 0) / 1000 + let emoji = success ? successEmoji : failureEmoji + let title = success ? "Success \(duration)s" : "Failed \(duration)s" + return (emoji, title, taskInfo.taskName) + case .failed: + let duration = (result?.duration ?? 0) / 1000 + let error = result?.error ?? "Unknown" + return (failureEmoji, "Failed \(duration)s", "\(taskInfo.taskName)\n\(error)") + case .cancelled: + return (stopEmoji, "Cancelled", taskInfo.taskName) + } + } + + private func scheduleNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + // Set category identifier if specified + if let categoryIdentifier = categoryIdentifier { + content.categoryIdentifier = categoryIdentifier + } + + // Set thread identifier if specified for grouping + if let threadIdentifier = threadIdentifier { + content.threadIdentifier = threadIdentifier + } + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil // Immediate delivery + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Failed to schedule notification: \(error)") + } + } + } +} diff --git a/workmanager_apple/ios/Classes/UserDefaultsHelper.swift b/workmanager_apple/ios/Classes/UserDefaultsHelper.swift index 6437ae14..e20c89db 100644 --- a/workmanager_apple/ios/Classes/UserDefaultsHelper.swift +++ b/workmanager_apple/ios/Classes/UserDefaultsHelper.swift @@ -15,7 +15,6 @@ struct UserDefaultsHelper { enum Key { case callbackHandle - case isDebug var stringValue: String { return "\(WorkmanagerPlugin.identifier).\(self)" @@ -32,16 +31,6 @@ struct UserDefaultsHelper { return getValue(for: .callbackHandle) } - // MARK: isDebug - - static func storeIsDebug(_ isDebug: Bool) { - store(isDebug, key: .isDebug) - } - - static func getIsDebug() -> Bool { - return getValue(for: .isDebug) ?? false - } - // MARK: Private helper functions private static func store(_ value: T, key: Key) { diff --git a/workmanager_apple/ios/Classes/WorkmanagerDebugHandler.swift b/workmanager_apple/ios/Classes/WorkmanagerDebugHandler.swift new file mode 100644 index 00000000..31b822d2 --- /dev/null +++ b/workmanager_apple/ios/Classes/WorkmanagerDebugHandler.swift @@ -0,0 +1,83 @@ +import Foundation +import os + +/** + * Information about a task for debugging purposes. + */ +public struct TaskDebugInfo { + public let taskName: String + public let uniqueName: String? + public let inputData: [String: Any]? + public let startTime: TimeInterval + public let callbackHandle: Int64? + public let callbackInfo: String? + + public init(taskName: String, uniqueName: String? = nil, inputData: [String: Any]? = nil, startTime: TimeInterval, callbackHandle: Int64? = nil, callbackInfo: String? = nil) { + self.taskName = taskName + self.uniqueName = uniqueName + self.inputData = inputData + self.startTime = startTime + self.callbackHandle = callbackHandle + self.callbackInfo = callbackInfo + } +} + +/** + * Result information for a completed task. + */ +public struct TaskResult { + public let success: Bool + public let duration: Int64 + public let error: String? + + public init(success: Bool, duration: Int64, error: String? = nil) { + self.success = success + self.duration = duration + self.error = error + } +} + +/** + * Abstract debug handler for Workmanager events. + * Override methods to customize debug behavior. Default implementations do nothing. + */ +public class WorkmanagerDebug { + private static var current: WorkmanagerDebug = WorkmanagerDebug() + + /** + * Set the global debug handler. + */ + public static func setCurrent(_ handler: WorkmanagerDebug) { + current = handler + } + + /** + * Get the current debug handler. + */ + public static func getCurrent() -> WorkmanagerDebug { + return current + } + + /** + * Called when a task status changes. + */ + func onTaskStatusUpdate(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult?) { + // Default: do nothing + } + + /** + * Called when an exception occurs during task processing. + */ + func onExceptionEncountered(taskInfo: TaskDebugInfo?, exception: Error) { + // Default: do nothing + } + + // Internal methods for the plugin to call + internal static func onTaskStatusUpdate(taskInfo: TaskDebugInfo, status: TaskStatus, result: TaskResult? = nil) { + current.onTaskStatusUpdate(taskInfo: taskInfo, status: status, result: result) + } + + internal static func onExceptionEncountered(taskInfo: TaskDebugInfo?, exception: Error) { + current.onExceptionEncountered(taskInfo: taskInfo, exception: exception) + } +} diff --git a/workmanager_apple/ios/Classes/WorkmanagerPlugin.swift b/workmanager_apple/ios/Classes/WorkmanagerPlugin.swift index ad43e107..16b14c70 100644 --- a/workmanager_apple/ios/Classes/WorkmanagerPlugin.swift +++ b/workmanager_apple/ios/Classes/WorkmanagerPlugin.swift @@ -14,7 +14,6 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin static let identifier = "dev.fluttercommunity.workmanager" private static var flutterPluginRegistrantCallback: FlutterPluginRegistrantCallback? - private var isInDebugMode: Bool = false // MARK: - Static Background Task Handlers @@ -150,8 +149,6 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin func initialize(request: InitializeRequest, completion: @escaping (Result) -> Void) { UserDefaultsHelper.storeCallbackHandle(request.callbackHandle) - UserDefaultsHelper.storeIsDebug(request.isInDebugMode) - isInDebugMode = request.isInDebugMode completion(.success(())) } @@ -175,6 +172,14 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin inputData: request.inputData as? [String: Any], delaySeconds: delaySeconds ) + + let taskInfo = TaskDebugInfo( + taskName: request.taskName, + uniqueName: request.uniqueName, + inputData: request.inputData as? [String: Any], + startTime: Date().timeIntervalSince1970 + ) + WorkmanagerDebug.getCurrent().onTaskStatusUpdate(taskInfo: taskInfo, status: .scheduled, result: nil) } } @@ -190,6 +195,14 @@ public class WorkmanagerPlugin: FlutterPluginAppLifeCycleDelegate, FlutterPlugin taskIdentifier: request.uniqueName, earliestBeginInSeconds: initialDelaySeconds ) + + let taskInfo = TaskDebugInfo( + taskName: request.taskName, + uniqueName: request.uniqueName, + inputData: request.inputData as? [String: Any], + startTime: Date().timeIntervalSince1970 + ) + WorkmanagerDebug.getCurrent().onTaskStatusUpdate(taskInfo: taskInfo, status: .scheduled, result: nil) } } diff --git a/workmanager_apple/ios/Classes/pigeon/WorkmanagerApi.g.swift b/workmanager_apple/ios/Classes/pigeon/WorkmanagerApi.g.swift index f2cf58cf..7ed61adb 100644 --- a/workmanager_apple/ios/Classes/pigeon/WorkmanagerApi.g.swift +++ b/workmanager_apple/ios/Classes/pigeon/WorkmanagerApi.g.swift @@ -135,6 +135,24 @@ func deepHashWorkmanagerApi(value: Any?, hasher: inout Hasher) { +/// Task status for debugging and monitoring. +enum TaskStatus: Int { + /// Task has been scheduled + case scheduled = 0 + /// Task has started execution + case started = 1 + /// Task completed successfully + case completed = 2 + /// Task failed + case failed = 3 + /// Task was cancelled + case cancelled = 4 + /// Task is being retried + case retrying = 5 + /// Task was rescheduled for later execution + case rescheduled = 6 +} + /// An enumeration of various network types that can be used as Constraints for work. /// /// Fully supported on Android. @@ -304,23 +322,19 @@ struct BackoffPolicyConfig: Hashable { /// Generated class from Pigeon that represents data sent in messages. struct InitializeRequest: Hashable { var callbackHandle: Int64 - var isInDebugMode: Bool // swift-format-ignore: AlwaysUseLowerCamelCase static func fromList(_ pigeonVar_list: [Any?]) -> InitializeRequest? { let callbackHandle = pigeonVar_list[0] as! Int64 - let isInDebugMode = pigeonVar_list[1] as! Bool return InitializeRequest( - callbackHandle: callbackHandle, - isInDebugMode: isInDebugMode + callbackHandle: callbackHandle ) } func toList() -> [Any?] { return [ - callbackHandle, - isInDebugMode, + callbackHandle ] } static func == (lhs: InitializeRequest, rhs: InitializeRequest) -> Bool { @@ -499,44 +513,50 @@ private class WorkmanagerApiPigeonCodecReader: FlutterStandardReader { case 129: let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) if let enumResultAsInt = enumResultAsInt { - return NetworkType(rawValue: enumResultAsInt) + return TaskStatus(rawValue: enumResultAsInt) } return nil case 130: let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) if let enumResultAsInt = enumResultAsInt { - return BackoffPolicy(rawValue: enumResultAsInt) + return NetworkType(rawValue: enumResultAsInt) } return nil case 131: let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) if let enumResultAsInt = enumResultAsInt { - return ExistingWorkPolicy(rawValue: enumResultAsInt) + return BackoffPolicy(rawValue: enumResultAsInt) } return nil case 132: let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) if let enumResultAsInt = enumResultAsInt { - return ExistingPeriodicWorkPolicy(rawValue: enumResultAsInt) + return ExistingWorkPolicy(rawValue: enumResultAsInt) } return nil case 133: let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) if let enumResultAsInt = enumResultAsInt { - return OutOfQuotaPolicy(rawValue: enumResultAsInt) + return ExistingPeriodicWorkPolicy(rawValue: enumResultAsInt) } return nil case 134: - return Constraints.fromList(self.readValue() as! [Any?]) + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return OutOfQuotaPolicy(rawValue: enumResultAsInt) + } + return nil case 135: - return BackoffPolicyConfig.fromList(self.readValue() as! [Any?]) + return Constraints.fromList(self.readValue() as! [Any?]) case 136: - return InitializeRequest.fromList(self.readValue() as! [Any?]) + return BackoffPolicyConfig.fromList(self.readValue() as! [Any?]) case 137: - return OneOffTaskRequest.fromList(self.readValue() as! [Any?]) + return InitializeRequest.fromList(self.readValue() as! [Any?]) case 138: - return PeriodicTaskRequest.fromList(self.readValue() as! [Any?]) + return OneOffTaskRequest.fromList(self.readValue() as! [Any?]) case 139: + return PeriodicTaskRequest.fromList(self.readValue() as! [Any?]) + case 140: return ProcessingTaskRequest.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -546,38 +566,41 @@ private class WorkmanagerApiPigeonCodecReader: FlutterStandardReader { private class WorkmanagerApiPigeonCodecWriter: FlutterStandardWriter { override func writeValue(_ value: Any) { - if let value = value as? NetworkType { + if let value = value as? TaskStatus { super.writeByte(129) super.writeValue(value.rawValue) - } else if let value = value as? BackoffPolicy { + } else if let value = value as? NetworkType { super.writeByte(130) super.writeValue(value.rawValue) - } else if let value = value as? ExistingWorkPolicy { + } else if let value = value as? BackoffPolicy { super.writeByte(131) super.writeValue(value.rawValue) - } else if let value = value as? ExistingPeriodicWorkPolicy { + } else if let value = value as? ExistingWorkPolicy { super.writeByte(132) super.writeValue(value.rawValue) - } else if let value = value as? OutOfQuotaPolicy { + } else if let value = value as? ExistingPeriodicWorkPolicy { super.writeByte(133) super.writeValue(value.rawValue) - } else if let value = value as? Constraints { + } else if let value = value as? OutOfQuotaPolicy { super.writeByte(134) + super.writeValue(value.rawValue) + } else if let value = value as? Constraints { + super.writeByte(135) super.writeValue(value.toList()) } else if let value = value as? BackoffPolicyConfig { - super.writeByte(135) + super.writeByte(136) super.writeValue(value.toList()) } else if let value = value as? InitializeRequest { - super.writeByte(136) + super.writeByte(137) super.writeValue(value.toList()) } else if let value = value as? OneOffTaskRequest { - super.writeByte(137) + super.writeByte(138) super.writeValue(value.toList()) } else if let value = value as? PeriodicTaskRequest { - super.writeByte(138) + super.writeByte(139) super.writeValue(value.toList()) } else if let value = value as? ProcessingTaskRequest { - super.writeByte(139) + super.writeByte(140) super.writeValue(value.toList()) } else { super.writeValue(value) diff --git a/workmanager_apple/ios/workmanager_apple.podspec b/workmanager_apple/ios/workmanager_apple.podspec index 48cde2b9..915b175b 100644 --- a/workmanager_apple/ios/workmanager_apple.podspec +++ b/workmanager_apple/ios/workmanager_apple.podspec @@ -15,7 +15,7 @@ Flutter Android Workmanager s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.ios.deployment_target = '13.0' + s.ios.deployment_target = '14.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.swift_version = '5.0' s.resource_bundles = { 'flutter_workmanager_privacy' => ['Resources/PrivacyInfo.xcprivacy'] } diff --git a/workmanager_apple/lib/workmanager_apple.dart b/workmanager_apple/lib/workmanager_apple.dart index d0963b96..2092a31b 100644 --- a/workmanager_apple/lib/workmanager_apple.dart +++ b/workmanager_apple/lib/workmanager_apple.dart @@ -17,12 +17,13 @@ class WorkmanagerApple extends WorkmanagerPlatform { @override Future initialize( Function callbackDispatcher, { + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') bool isInDebugMode = false, }) async { final callback = PluginUtilities.getCallbackHandle(callbackDispatcher); await _api.initialize(InitializeRequest( callbackHandle: callback!.toRawHandle(), - isInDebugMode: isInDebugMode, )); } diff --git a/workmanager_platform_interface/CHANGELOG.md b/workmanager_platform_interface/CHANGELOG.md index 6d18d9fc..a4cff868 100644 --- a/workmanager_platform_interface/CHANGELOG.md +++ b/workmanager_platform_interface/CHANGELOG.md @@ -1,14 +1,12 @@ ## Future -### Dependencies & Infrastructure Updates -* Updated Pigeon from 22.7.4 to 26.0.0 for enhanced multi-platform support -* Regenerated platform interface files with new Pigeon version - ### Breaking Changes -* **BREAKING**: Separate `ExistingWorkPolicy` and `ExistingPeriodicWorkPolicy` enums for better type safety - * Mirrors Android's native WorkManager API design - * `ExistingPeriodicWorkPolicy` now used for periodic tasks with three options: `keep`, `replace`, `update` - * Added comprehensive documentation with upstream Android documentation links +* **BREAKING**: Separate `ExistingWorkPolicy` and `ExistingPeriodicWorkPolicy` enums + * Use `ExistingPeriodicWorkPolicy` for periodic tasks: `keep`, `replace`, `update` + +### New Features +* Add `TaskStatus.SCHEDULED` and `TaskStatus.RESCHEDULED` enums for enhanced task lifecycle tracking +* Add debug handler interface and implementations for optional task monitoring ## 0.8.0 diff --git a/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart b/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart index 545dc53e..780c357d 100644 --- a/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart +++ b/workmanager_platform_interface/lib/src/pigeon/workmanager_api.g.dart @@ -42,6 +42,24 @@ bool _deepEquals(Object? a, Object? b) { } +/// Task status for debugging and monitoring. +enum TaskStatus { + /// Task has been scheduled + scheduled, + /// Task has started execution + started, + /// Task completed successfully + completed, + /// Task failed + failed, + /// Task was cancelled + cancelled, + /// Task is being retried + retrying, + /// Task was rescheduled for later execution + rescheduled, +} + /// An enumeration of various network types that can be used as Constraints for work. /// /// Fully supported on Android. @@ -248,17 +266,13 @@ class BackoffPolicyConfig { class InitializeRequest { InitializeRequest({ required this.callbackHandle, - required this.isInDebugMode, }); int callbackHandle; - bool isInDebugMode; - List _toList() { return [ callbackHandle, - isInDebugMode, ]; } @@ -269,7 +283,6 @@ class InitializeRequest { result as List; return InitializeRequest( callbackHandle: result[0]! as int, - isInDebugMode: result[1]! as bool, ); } @@ -532,38 +545,41 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is NetworkType) { + } else if (value is TaskStatus) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is BackoffPolicy) { + } else if (value is NetworkType) { buffer.putUint8(130); writeValue(buffer, value.index); - } else if (value is ExistingWorkPolicy) { + } else if (value is BackoffPolicy) { buffer.putUint8(131); writeValue(buffer, value.index); - } else if (value is ExistingPeriodicWorkPolicy) { + } else if (value is ExistingWorkPolicy) { buffer.putUint8(132); writeValue(buffer, value.index); - } else if (value is OutOfQuotaPolicy) { + } else if (value is ExistingPeriodicWorkPolicy) { buffer.putUint8(133); writeValue(buffer, value.index); - } else if (value is Constraints) { + } else if (value is OutOfQuotaPolicy) { buffer.putUint8(134); + writeValue(buffer, value.index); + } else if (value is Constraints) { + buffer.putUint8(135); writeValue(buffer, value.encode()); } else if (value is BackoffPolicyConfig) { - buffer.putUint8(135); + buffer.putUint8(136); writeValue(buffer, value.encode()); } else if (value is InitializeRequest) { - buffer.putUint8(136); + buffer.putUint8(137); writeValue(buffer, value.encode()); } else if (value is OneOffTaskRequest) { - buffer.putUint8(137); + buffer.putUint8(138); writeValue(buffer, value.encode()); } else if (value is PeriodicTaskRequest) { - buffer.putUint8(138); + buffer.putUint8(139); writeValue(buffer, value.encode()); } else if (value is ProcessingTaskRequest) { - buffer.putUint8(139); + buffer.putUint8(140); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -575,30 +591,33 @@ class _PigeonCodec extends StandardMessageCodec { switch (type) { case 129: final int? value = readValue(buffer) as int?; - return value == null ? null : NetworkType.values[value]; + return value == null ? null : TaskStatus.values[value]; case 130: final int? value = readValue(buffer) as int?; - return value == null ? null : BackoffPolicy.values[value]; + return value == null ? null : NetworkType.values[value]; case 131: final int? value = readValue(buffer) as int?; - return value == null ? null : ExistingWorkPolicy.values[value]; + return value == null ? null : BackoffPolicy.values[value]; case 132: final int? value = readValue(buffer) as int?; - return value == null ? null : ExistingPeriodicWorkPolicy.values[value]; + return value == null ? null : ExistingWorkPolicy.values[value]; case 133: final int? value = readValue(buffer) as int?; - return value == null ? null : OutOfQuotaPolicy.values[value]; + return value == null ? null : ExistingPeriodicWorkPolicy.values[value]; case 134: - return Constraints.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : OutOfQuotaPolicy.values[value]; case 135: - return BackoffPolicyConfig.decode(readValue(buffer)!); + return Constraints.decode(readValue(buffer)!); case 136: - return InitializeRequest.decode(readValue(buffer)!); + return BackoffPolicyConfig.decode(readValue(buffer)!); case 137: - return OneOffTaskRequest.decode(readValue(buffer)!); + return InitializeRequest.decode(readValue(buffer)!); case 138: - return PeriodicTaskRequest.decode(readValue(buffer)!); + return OneOffTaskRequest.decode(readValue(buffer)!); case 139: + return PeriodicTaskRequest.decode(readValue(buffer)!); + case 140: return ProcessingTaskRequest.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); diff --git a/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart b/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart index 116788df..40adea93 100644 --- a/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart +++ b/workmanager_platform_interface/lib/src/workmanager_platform_interface.dart @@ -33,11 +33,11 @@ abstract class WorkmanagerPlatform extends PlatformInterface { /// Initialize the platform workmanager with the callback function. /// /// [callbackDispatcher] is the callback function that will be called when background work is executed. - /// [isInDebugMode] determines whether debug notifications should be shown. - Future initialize( - Function callbackDispatcher, { - bool isInDebugMode = false, - }) { + /// [isInDebugMode] is deprecated and has no effect. Use WorkmanagerDebug handlers instead. + Future initialize(Function callbackDispatcher, + {@Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') + bool isInDebugMode = false}) { throw UnimplementedError('initialize() has not been implemented.'); } @@ -148,6 +148,8 @@ class _PlaceholderImplementation extends WorkmanagerPlatform { @override Future initialize( Function callbackDispatcher, { + @Deprecated( + 'Use WorkmanagerDebug handlers instead. This parameter has no effect.') bool isInDebugMode = false, }) async { throw UnimplementedError( diff --git a/workmanager_platform_interface/pigeons/workmanager_api.dart b/workmanager_platform_interface/pigeons/workmanager_api.dart index c9b07586..f56c1b59 100644 --- a/workmanager_platform_interface/pigeons/workmanager_api.dart +++ b/workmanager_platform_interface/pigeons/workmanager_api.dart @@ -16,6 +16,30 @@ import 'package:pigeon/pigeon.dart'; // Enums - Moved from platform interface for Pigeon compatibility +/// Task status for debugging and monitoring. +enum TaskStatus { + /// Task has been scheduled + scheduled, + + /// Task has started execution + started, + + /// Task completed successfully + completed, + + /// Task failed + failed, + + /// Task was cancelled + cancelled, + + /// Task is being retried + retrying, + + /// Task was rescheduled for later execution + rescheduled, +} + /// An enumeration of various network types that can be used as Constraints for work. /// /// Fully supported on Android. @@ -152,11 +176,9 @@ class BackoffPolicyConfig { } class InitializeRequest { - InitializeRequest( - {required this.callbackHandle, required this.isInDebugMode}); + InitializeRequest({required this.callbackHandle}); int callbackHandle; - bool isInDebugMode; } class OneOffTaskRequest {