Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ jobs:
- name: Run plugin unit tests with coverage
run: flutter test test/unit/ --coverage --reporter=expanded

- name: Run security tests
run: flutter test test/security/ --reporter=expanded

- name: Run performance tests (non-device)
run: flutter test test/performance/scheduling_performance_test.dart --reporter=expanded

- name: Upload coverage
uses: codecov/codecov-action@v4
with:
Expand Down
862 changes: 32 additions & 830 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,11 +364,16 @@ await NativeWorkManager
await NativeWorkManager.enqueue(
taskId: 'hourly-sync',
worker: NativeWorker.httpSync(url: 'https://api.example.com/sync'),
trigger: TaskTrigger.periodic(const Duration(hours: 1)),
trigger: TaskTrigger.periodic(
const Duration(hours: 1),
initialDelay: const Duration(minutes: 30), // Delay first run by 30m
),
constraints: const Constraints(requiresNetwork: true),
existingPolicy: ExistingTaskPolicy.keep,
);
```

> **New in v1.2.3**: `initialDelay` support for periodic tasks ensures your background work doesn't start immediately upon registration, saving resources when the app is first launched.
</details>

<details>
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ android {

dependencies {
// KMP WorkManager - Core library (from Maven Central v2.4.0)
api("dev.brewkits:kmpworkmanager:2.4.0")
api("dev.brewkits:kmpworkmanager:2.4.1")
// Android WorkManager — safe to use 2.10.0+ with kmpworkmanager 2.4.0+
// (getForegroundInfo() override added in kmpworkmanager 2.3.3)
api("androidx.work:work-runtime-ktx:2.10.1")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package dev.brewkits.native_workmanager
import android.content.Intent
import androidx.core.content.FileProvider
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.await
import dev.brewkits.kmpworkmanager.background.data.KmpHeavyWorker
Expand Down Expand Up @@ -296,7 +298,14 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res
"periodic" -> {
val intervalMs = (triggerMap?.get("intervalMs") as? Number)?.toLong() ?: 900_000L
val flexMs = (triggerMap?.get("flexMs") as? Number)?.toLong()
TaskTrigger.Periodic(intervalMs = intervalMs, flexMs = flexMs)
val initialDelayMs = (triggerMap?.get("initialDelayMs") as? Number)?.toLong() ?: 0L
val runImmediately = triggerMap?.get("runImmediately") as? Boolean ?: true
TaskTrigger.Periodic(
intervalMs = intervalMs,
flexMs = flexMs,
initialDelayMs = initialDelayMs,
runImmediately = runImmediately
)
}
"exact" -> {
val scheduledTimeMs = (triggerMap?.get("scheduledTimeMs") as? Number)?.toLong()
Expand Down Expand Up @@ -625,13 +634,28 @@ internal fun NativeWorkmanagerPlugin.enqueueOneTimeWorkDirect(
val workerClass = if (constraints.isHeavyTask) KmpHeavyWorker::class.java else KmpWorker::class.java

val dataBuilder = Data.Builder().putString("workerClassName", workerClassName)

// Apply middleware to inputJson before enqueuing (Phase 2)
val effectiveInputJson = if (inputJson != null) {
NativeWorkmanagerPlugin.applyMiddleware(context, workerClassName, inputJson)
} else inputJson

if (effectiveInputJson != null) dataBuilder.putString("inputJson", effectiveInputJson)

if (effectiveInputJson != null) {
// WorkManager hard-limits Data payloads to 10 240 bytes (10 KB).
// If the config exceeds that, spill it to a temp file and pass only the path.
// The worker reads the file and deletes it after use.
val payloadBytes = effectiveInputJson.toByteArray(Charsets.UTF_8)
if (payloadBytes.size > 10 * 1024) {
val spillFile = java.io.File(context.cacheDir, "wm_spill_${taskId}.json")
spillFile.writeText(effectiveInputJson, Charsets.UTF_8)
// Use the same key as kmpworkmanager (NativeTaskScheduler.KEY_INPUT_JSON_FILE)
// so BaseKmpWorker.resolveInputJson() picks it up without any changes.
dataBuilder.putString("inputJsonFile", spillFile.absolutePath)
NativeLogger.d("inputJson for '$taskId' exceeds 10 KB — spilled to ${spillFile.name}")
} else {
dataBuilder.putString("inputJson", effectiveInputJson)
}
}

val networkType = when {
constraints.requiresUnmeteredNetwork -> NetworkType.UNMETERED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import java.util.concurrent.ConcurrentHashMap
/**
* Native WorkManager Flutter Plugin for Android.
*
* Uses kmpworkmanager v2.3.9 from Maven Central as the core engine.
* Uses kmpworkmanager v2.4.1 from Maven Central as the core engine.
*/
class NativeWorkmanagerPlugin : FlutterPlugin, MethodCallHandler,
android.content.ComponentCallbacks2 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,15 @@ class OfflineQueueProcessor(

for (entry in entries) {
try {
// Try to enqueue each entry as a regular native work request
// We use enqueueOneTimeWorkDirect (copied logic or call helper)
enqueueEntry(applicationContext, entry)

// If successfully enqueued to WorkManager, remove from offline queue
store.deleteEntry(entry.id)
NativeLogger.d("✅ OfflineQueueProcessor: Task ${entry.taskId} moved to WorkManager")
} catch (e: Exception) {
NativeLogger.e("❌ OfflineQueueProcessor: Failed to move task ${entry.taskId}", e)
// Keep in queue for next run
// Enqueue failures (bad format, oversized payload) are not transient —
// retrying the same entry indefinitely would cause an infinite loop that
// drains the battery. Discard and move on.
NativeLogger.e("❌ OfflineQueueProcessor: Discarding unrecoverable entry ${entry.taskId}", e)
store.deleteEntry(entry.id)
}
}

Expand All @@ -66,7 +65,14 @@ class OfflineQueueProcessor(
.putString("workerClassName", entry.workerClassName)

if (entry.workerConfig != null) {
dataBuilder.putString("inputJson", entry.workerConfig)
val payloadBytes = entry.workerConfig.toByteArray(Charsets.UTF_8)
if (payloadBytes.size > 10 * 1024) {
val spillFile = java.io.File(context.cacheDir, "wm_spill_${entry.taskId}.json")
spillFile.writeText(entry.workerConfig, Charsets.UTF_8)
dataBuilder.putString("inputJsonFile", spillFile.absolutePath)
} else {
dataBuilder.putString("inputJson", entry.workerConfig)
}
}

// Parse retry policy to apply constraints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ object FlutterEngineManager {

if (timedOut) {
NativeLogger.e("DartWorker timed out after ${timeoutMs}ms — force-disposing engine to free hung isolate")
try { dispose() } catch (_: Exception) {}
// Only destroy the engine if this is the sole in-flight task.
// Destroying while other tasks hold a methodChannel reference causes
// a JNI crash (access to freed C++ FlutterJNI memory).
if (activeTaskCount.get() <= 0) {
try { dispose() } catch (_: Exception) {}
}
return@withContext false
}

Expand Down
19 changes: 16 additions & 3 deletions doc/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,15 +502,28 @@ TaskTrigger.oneTime(Duration(seconds: 30)) // Run after 30 seconds
Execute task repeatedly at fixed interval.

```dart
static TaskTrigger periodic(Duration interval)
static TaskTrigger periodic(
Duration interval, {
Duration? flexInterval,
Duration? initialDelay,
})
```

**Parameters:**
- `interval`: Time between executions (minimum 15 minutes).
- `flexInterval`: (Android only) Flex window for OS optimization.
- `initialDelay`: (New in v1.2.3) Delay before the very first execution.

**Example:**
```dart
TaskTrigger.periodic(Duration(hours: 1)) // Run every hour
// Run every hour, but wait 30 mins before first run
TaskTrigger.periodic(
Duration(hours: 1),
initialDelay: Duration(minutes: 30),
)
```

**Note:** Minimum interval is 15 minutes on both iOS and Android.
**Note:** Minimum interval is 15 minutes on both iOS and Android. `initialDelay` ensures the task doesn't run immediately upon registration.

---

Expand Down
11 changes: 11 additions & 0 deletions doc/MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ Impact: Higher App Store ratings, fewer user complaints
- Better responsiveness
- Less UI jank during background execution

### 🆕 New Features in v1.2.3

- **Initial Delay for Periodic Tasks**: You can now delay the very first execution of a periodic task.
```dart
trigger: TaskTrigger.periodic(
const Duration(hours: 1),
initialDelay: const Duration(minutes: 30),
)
```
- **Security Hardening**: Strict validation for URLs and file paths to prevent injection and path traversal attacks.

---

## API Compatibility Matrix
Expand Down
6 changes: 5 additions & 1 deletion doc/use-cases/01-periodic-api-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ Future<void> schedulePeriodicSync() async {
taskId: 'api-sync',

// Run every 1 hour (minimum is 15 minutes on Android)
trigger: TaskTrigger.periodic(const Duration(hours: 1)),
// New in v1.2.3: Use initialDelay to wait for 1 hour before first run
trigger: TaskTrigger.periodic(
const Duration(hours: 1),
initialDelay: const Duration(hours: 1),
),

// Use native HTTP worker (no Flutter Engine needed)
worker: NativeWorker.httpSync(
Expand Down
93 changes: 45 additions & 48 deletions example/integration_test/initialization_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -477,61 +477,58 @@ void main() {
},
);

testWidgets(
'initialize(registerPlugins: true) — Dart flag is true',
(tester) async {
await NativeWorkManager.initialize(
dartWorkers: {'dummy': _dummyCallback},
registerPlugins: true,
);
expect(NativeWorkManager.registerPluginsEnabled, isTrue);
},
);
testWidgets('initialize(registerPlugins: true) — Dart flag is true', (
tester,
) async {
await NativeWorkManager.initialize(
dartWorkers: {'dummy': _dummyCallback},
registerPlugins: true,
);
expect(NativeWorkManager.registerPluginsEnabled, isTrue);
});

testWidgets(
'DartWorker succeeds with registerPlugins=false',
(tester) async {
await NativeWorkManager.initialize(
dartWorkers: {'dummy': _dummyCallback},
registerPlugins: false,
);
testWidgets('DartWorker succeeds with registerPlugins=false', (
tester,
) async {
await NativeWorkManager.initialize(
dartWorkers: {'dummy': _dummyCallback},
registerPlugins: false,
);

final taskId = _id('rp_false_dart');
final eventFuture = _waitEvent(taskId);
final taskId = _id('rp_false_dart');
final eventFuture = _waitEvent(taskId);

await NativeWorkManager.enqueue(
taskId: taskId,
worker: DartWorker(callbackId: 'dummy'),
trigger: const TaskTrigger.oneTime(),
);
await NativeWorkManager.enqueue(
taskId: taskId,
worker: DartWorker(callbackId: 'dummy'),
trigger: const TaskTrigger.oneTime(),
);

final event = await eventFuture;
expect(event, isNotNull, reason: 'DartWorker timed out (rp=false)');
expect(event!.success, isTrue);
},
);
final event = await eventFuture;
expect(event, isNotNull, reason: 'DartWorker timed out (rp=false)');
expect(event!.success, isTrue);
});

testWidgets(
'DartWorker succeeds with registerPlugins=true',
(tester) async {
await NativeWorkManager.initialize(
dartWorkers: {'dummy': _dummyCallback},
registerPlugins: true,
);
testWidgets('DartWorker succeeds with registerPlugins=true', (
tester,
) async {
await NativeWorkManager.initialize(
dartWorkers: {'dummy': _dummyCallback},
registerPlugins: true,
);

final taskId = _id('rp_true_dart');
final eventFuture = _waitEvent(taskId);
final taskId = _id('rp_true_dart');
final eventFuture = _waitEvent(taskId);

await NativeWorkManager.enqueue(
taskId: taskId,
worker: DartWorker(callbackId: 'dummy'),
trigger: const TaskTrigger.oneTime(),
);
await NativeWorkManager.enqueue(
taskId: taskId,
worker: DartWorker(callbackId: 'dummy'),
trigger: const TaskTrigger.oneTime(),
);

final event = await eventFuture;
expect(event, isNotNull, reason: 'DartWorker timed out (rp=true)');
expect(event!.success, isTrue);
},
);
final event = await eventFuture;
expect(event, isNotNull, reason: 'DartWorker timed out (rp=true)');
expect(event!.success, isTrue);
});
});
}
32 changes: 32 additions & 0 deletions example/integration_test/stress_and_system_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,38 @@ void main() {
timeout: const Timeout(Duration(minutes: 8)),
);

testWidgets('Rapid fire enqueue 50 tasks (Zero Delay)', (tester) async {
const count = 50;
final futures = <Future<TaskHandler>>[];

print('[Stress] Starting rapid fire enqueue of $count tasks...');
final stopwatch = Stopwatch()..start();

for (var i = 0; i < count; i++) {
futures.add(
NativeWorkManager.enqueue(
taskId: _id('rapid_$i'),
trigger: TaskTrigger.oneTime(),
worker: DartWorker(callbackId: 'stress_worker'),
),
);
}

final results = await Future.wait(futures);
stopwatch.stop();

final acceptedCount = results
.where((r) => r.scheduleResult == ScheduleResult.accepted)
.length;

print(
'[Stress] Enqueued $count tasks in ${stopwatch.elapsedMilliseconds}ms '
'($acceptedCount accepted)',
);

expect(acceptedCount, count, reason: 'All tasks should be accepted');
});

testWidgets('Rapid-fire Enqueue/Cancel loop', (tester) async {
const iterations = 10;
int successCount = 0;
Expand Down
Loading
Loading