Hybrid Task Runner
A Flutter package for running background tasks on Android. It combines AlarmManager + WorkManager to get the best of both worlds.
Platform: Android only (iOS is not supported yet)
Why Was This Made?
Background execution on Android is complicated. There are two main options:
- AlarmManager - High precision, can set exact times, but only runs for ~10 seconds
- WorkManager - Can run for a long time (10+ minutes), but timing is not exact as it's batched by the system
This package combines both:
- AlarmManager triggers at the precise time
- Immediately enqueues a WorkManager task
- WorkManager runs the heavy task (can run 10+ minutes)
- After completion, schedules the next alarm
So you get timing precision AND long execution duration.
Dependencies
dependencies:
android_alarm_manager_plus: ^4.0.0
workmanager: ^0.9.0
shared_preferences: ^2.0.0
Why use these?
| Package | Purpose |
|---|---|
android_alarm_manager_plus |
Set exact alarm, wake up device, survive reboot |
workmanager |
Execute long-running task, survive process death |
shared_preferences |
Store callback handle & config across isolates |
Install
Run this command:
flutter pub add hybrid_task_runner
Or add it to pubspec.yaml:
dependencies:
hybrid_task_runner: ^1.1.0
Android Setup
1. Edit android/app/src/main/AndroidManifest.xml
Add permissions inside <manifest>:
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
Note: Do NOT add
USE_EXACT_ALARMunless your app is a calendar or alarm clock app. That permission is restricted by Google Play policy.
Add service & receivers inside <application>:
<!-- AlarmManager -->
<service
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
2. Ensure minSdkVersion >= 21
In android/app/build.gradle:
android {
defaultConfig {
minSdkVersion 21
}
}
3. Handle Android 14+ Permission (Important!)
On Android 14+, the SCHEDULE_EXACT_ALARM permission is denied by default for newly installed apps. Users must manually grant it in Settings.
Before scheduling tasks, check and request permission:
// Check if permission is granted
final canSchedule = await HybridRunner.canScheduleExactAlarms();
if (!canSchedule) {
// Show dialog explaining why the permission is needed
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Permission Required'),
content: Text(
'To run tasks at exact times, please enable '
'"Alarms & reminders" in Settings.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Later'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await HybridRunner.openExactAlarmSettings();
},
child: Text('Open Settings'),
),
],
),
);
return;
}
// Permission granted, safe to schedule
await HybridRunner.registerTask(...);
Example UI Flow:
| Permission Dialog | System Settings |
|---|---|
![]() |
![]() |
When the user taps "Open Settings", they'll be redirected to the system settings where they can enable "Allow setting alarms and reminders".
Android Version Compatibility:
| Android Version | Behavior |
|---|---|
| API ≤ 30 (Android 11 and below) | No permission needed. Exact alarms always work. |
| API 31-33 (Android 12-13) | SCHEDULE_EXACT_ALARM auto-granted on install. |
| API 34+ (Android 14+) | Permission denied by default. User must grant manually in Settings. |
Note: The
canScheduleExactAlarms()method handles all versions automatically. It returnstrueon older Android versions where permission is not required.
Usage
1. Create the callback function
IMPORTANT: Must be a top-level function (outside of any class) with the @pragma('vm:entry-point') annotation.
@pragma('vm:entry-point')
Future<bool> myBackgroundTask() async {
// Do heavy work here
// Can run for 10+ minutes
await syncDataToServer();
await processLocalFiles();
return true; // return false if failed
}
Why must it be top-level? Because the callback runs in a separate isolate, not in your app's main isolate.
2. Initialize in main()
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await HybridRunner.initialize();
runApp(MyApp());
}
3. Start the runner
await HybridRunner.start(
callback: myBackgroundTask,
loopInterval: Duration(minutes: 15),
runImmediately: true,
taskOverlapPolicy: TaskOverlapPolicy.parallel, // optional
);
Task Overlap Policy
Controls what happens when a new task is triggered while a previous task is still running:
| Policy | Behavior |
|---|---|
TaskOverlapPolicy.replace |
Cancel the running task, start the new one (default) |
TaskOverlapPolicy.skipIfRunning |
Ignore the new task if one is already running |
TaskOverlapPolicy.parallel |
Run both tasks simultaneously, no waiting |
Example use cases:
- replace: When you only care about the latest data sync
- skipIfRunning: When tasks should never overlap (e.g., database operations)
- parallel: When each task is independent (e.g., processing different files)
4. Stop if needed
await HybridRunner.stop();
5. Check status
bool isRunning = await HybridRunner.isActive;
Duration? interval = await HybridRunner.loopInterval;
Multi-Task API
For more complex scenarios, you can register multiple named tasks with independent schedules.
Register looping tasks
// Task 1: Sync data every 15 minutes
await HybridRunner.registerTask(
name: 'syncData',
callback: syncDataTask,
interval: Duration(minutes: 15),
taskOverlapPolicy: TaskOverlapPolicy.skipIfRunning,
);
// Task 2: Process files every 30 minutes
await HybridRunner.registerTask(
name: 'processFiles',
callback: processFilesTask,
interval: Duration(minutes: 30),
taskOverlapPolicy: TaskOverlapPolicy.parallel,
);
Register one-time tasks
One-time tasks run once and are automatically removed after execution.
// Run once after 30 minutes
await HybridRunner.registerTask(
name: 'sendReminder',
callback: sendReminderTask,
interval: Duration(minutes: 30),
isOneTime: true, // Runs once, then removed
);
// Run immediately (1 second delay)
await HybridRunner.registerTask(
name: 'initialSync',
callback: initialSyncTask,
interval: Duration(minutes: 1),
isOneTime: true,
runImmediately: true,
);
View all registered tasks
final tasks = await HybridRunner.getRegisteredTasks();
for (final task in tasks) {
print('Task: ${task.name}');
print(' Interval: ${task.interval.inMinutes} minutes');
print(' Active: ${task.isActive}');
print(' One-time: ${task.isOneTime}');
print(' Registered: ${task.registeredAt}');
}
Stop a specific task
final stopped = await HybridRunner.stopTask('syncData');
print('Task stopped: $stopped'); // true if found and stopped
Stop all tasks
await HybridRunner.stopAllTasks();
How It Works
User starts runner
↓
Schedule AlarmManager (exact time)
↓
[Device sleep / App closed]
↓
AlarmManager fires! (max 10 sec)
↓
Enqueue WorkManager task
↓
WorkManager executes callback (max 10+ min)
↓
Schedule next alarm
↓
[Loop continues...]
Verifying Background Execution
Method 1: Using ADB Logcat
Connect your device via USB and run:
# Filter logs for HybridRunner
adb logcat -s HybridRunner:V
# Or filter by your app's tag
adb logcat | findstr "HybridRunner" # Windows
adb logcat | grep "HybridRunner" # Mac/Linux
Expected output when task runs:
D HybridRunner: Alarm triggered, enqueuing WorkManager task...
D HybridRunner: Policy: parallel - running with unique ID: hybridTask_1234567890
D HybridRunner: WorkManager task enqueued successfully
D HybridRunner: WorkManager task started: hybridTask
D HybridRunner: Executing task: syncData
D HybridRunner: Task syncData completed with result: true
D HybridRunner: Scheduling next alarm in 900 seconds
Method 2: Database Logging (Recommended)
Log task executions to a local database so you can verify them later in the UI.
import 'package:sqflite/sqflite.dart';
@pragma('vm:entry-point')
Future<bool> myBackgroundTask() async {
// Log to database
final db = await openDatabase('task_logs.db');
await db.insert('logs', {
'timestamp': DateTime.now().toIso8601String(),
'event': 'TASK_EXECUTED',
'message': 'Background task ran successfully',
});
// Your actual task logic here
await syncDataToServer();
return true;
}
Then display these logs in your app's UI to verify background execution.
Method 3: Step-by-Step Testing
-
Start the runner with a short interval (e.g., 1 minute)
await HybridRunner.registerTask( name: 'test', callback: testTask, interval: Duration(minutes: 1), runImmediately: true, ); -
Close the app (swipe away, NOT force close)
-
Wait for the interval to pass
-
Check logs via ADB or open the app to see database logs
-
Repeat to verify multiple executions
Test Checklist
| Scenario | How to Test |
|---|---|
| App in foreground | Start runner, wait for interval |
| App in background | Minimize app, wait for interval |
| App closed (swiped away) | Close app, wait for interval, check logs |
| After device reboot | Reboot device, wait for interval |
| Screen off | Lock device, wait for interval |
Common Issues
| Issue | Solution |
|---|---|
| Task doesn't run when app closed | Check battery optimization settings |
| Task delayed significantly | Enable "alarmClock" mode (already enabled by default) |
| Task stops after some time | Whitelist app from battery saver |
| No logs appearing | Ensure callback has @pragma('vm:entry-point') |
Limitations
Force Close
If the user force closes the app (from Settings > Apps > Force Stop), all alarms are cancelled. This is Android behavior, not a bug.
Workaround: This package also registers a WorkManager periodic task as backup. After ~15 minutes, the task will run again and re-schedule the alarm.
Battery Optimization
Some vendors (Xiaomi, Oppo, Vivo, Samsung) have aggressive battery optimization that can kill background processes. Users need to whitelist the app in settings.
No Minimum Interval
Unlike standalone WorkManager periodic tasks (which have a 15-minute minimum), the hybrid approach has no minimum interval. AlarmManager can trigger at any time, and WorkManager one-off tasks run immediately when enqueued.
The 15-minute minimum only applies to the backup periodic task - a fallback that runs if alarms are cancelled (e.g., after force close). This backup will re-schedule the alarm when it runs.
Tips
- Test on a real device - Emulator is sometimes not accurate for background behavior
- Don't force close - Just swipe away or press back
- Whitelist from battery optimization - Important for reliability
- Short intervals work - Unlike pure WorkManager, you can use intervals < 15 minutes
API Reference
Single-Task API (Simple)
| Method | Description |
|---|---|
HybridRunner.initialize() |
Initialize AlarmManager and WorkManager. Call once in main(). |
HybridRunner.start({...}) |
Start a single task loop with the given callback and interval. |
HybridRunner.stop() |
Stop the single task loop. |
HybridRunner.isActive |
Check if the runner is currently active. |
HybridRunner.loopInterval |
Get the current loop interval. |
Multi-Task API (Advanced)
| Method | Description |
|---|---|
HybridRunner.registerTask({...}) |
Register a named task (looping or one-time). |
HybridRunner.getRegisteredTasks() |
Get a list of all registered tasks. |
HybridRunner.stopTask(name) |
Stop and remove a specific task by name. |
HybridRunner.stopAllTasks() |
Stop all registered tasks. |
Permission API (Android 12+)
| Method | Description |
|---|---|
HybridRunner.canScheduleExactAlarms() |
Check if exact alarm permission is granted. Returns true on Android 11-. |
HybridRunner.openExactAlarmSettings() |
Open system settings for exact alarm permission. |
registerTask Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
String |
required | Unique task identifier |
callback |
Function |
required | Top-level async function |
interval |
Duration |
required | Interval (loop) or delay (one-time) |
taskOverlapPolicy |
TaskOverlapPolicy |
replace |
Overlap behavior |
runImmediately |
bool |
false |
Start immediately |
isOneTime |
bool |
false |
Run once then auto-remove |
Classes & Enums
| Type | Description |
|---|---|
TaskOverlapPolicy |
Enum: replace, skipIfRunning, parallel |
RegisteredTask |
Task model with: name, interval, isActive, isOneTime, registeredAt |
License
MIT
Libraries
- hybrid_task_runner
- A Flutter package implementing a Hybrid Background Strategy.

