From cbd975b296e281812c1dcef242f7c442d207e6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Wed, 6 May 2026 23:48:45 +0700 Subject: [PATCH 1/5] chore: prepare release v1.2.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 91d22a3..272f4af 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ migrate_working_dir/ GEMINI.md .gemini/ .gemini_security/ +CLAUSE.md # Claude Code .claude/ From 5e84631d5d631cfc9ea3761e0a3cf5a1ff71920f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 7 May 2026 00:33:31 +0700 Subject: [PATCH 2/5] fix: restore flexMs KotlinLong constructor and clarify bridge comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert `flexMs as? KotlinLong` (always nil) back to explicit `KotlinLong(value: flexMs!)` constructor. The as? cast silently dropped flexInterval for all iOS periodic tasks. - Add KMP-BRIDGE-FROZEN marker to prevent future simplification. - Fix misleading comment: "inherently not running immediately" → clear explanation that runImmediately is forced true to satisfy KMP library. --- .../NativeWorkmanagerPlugin+Enqueue.kt | 4 ++-- .../native_workmanager/KMPSchedulerBridge.swift | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt index cc5a253..16314b5 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt @@ -303,8 +303,8 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res val initialDelayMs = (triggerMap?.get("initialDelayMs") as? Number)?.toLong() ?: 0L var runImmediately = triggerMap?.get("runImmediately") as? Boolean ?: true - // Resolve KMP Library "Ambiguous" conflict: if initial delay is provided, - // the task is inherently not running immediately. + // KMP library rejects runImmediately=false when initialDelayMs>0 ("Ambiguous" error). + // Force true so the library accepts the request; initialDelayMs controls first-run timing. if (initialDelayMs > 0L) { runImmediately = true } diff --git a/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift b/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift index 605eb8e..a9be716 100644 --- a/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift +++ b/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift @@ -73,15 +73,20 @@ class KMPSchedulerBridge { let initialDelayMs = (map["initialDelayMs"] as? NSNumber)?.int64Value ?? 0 var runImmediately = map["runImmediately"] as? Bool ?? true - // Resolve KMP Library "Ambiguous" conflict: if initial delay is provided, - // the task is inherently not running immediately. + // ⚠️ KMP-BRIDGE-FROZEN: flexMs Int64? → KotlinLong? requires explicit constructor. + // Swift cannot cast Int64 to boxed Kotlin types directly. + // Do NOT simplify to 'as? KotlinLong'. + let flexMsValue = flexMs != nil ? KotlinLong(value: flexMs!) : nil + + // KMP library rejects runImmediately=false when initialDelayMs>0 ("Ambiguous" error). + // Force true so the library accepts the request; initialDelayMs controls first-run timing. if initialDelayMs > 0 { runImmediately = true } return TaskTriggerPeriodic( intervalMs: intervalMs, - flexMs: flexMs as? KotlinLong, + flexMs: flexMsValue, initialDelayMs: initialDelayMs, runImmediately: runImmediately ) From edd584126a58cf1521f1cc2a4dec1f747d9dba8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 7 May 2026 00:38:12 +0700 Subject: [PATCH 3/5] safety: multi-layer bridge regression prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test: add PeriodicTrigger parameter combination matrix (8 combos, including issue_26 regression) to task_trigger_test.dart - test: add bridge_parameter_passthrough_test.dart — verifies exact map key names and value types for all trigger types - chore: add scripts/pre-commit-bridge-check.sh + install as git hook; requires 'yes' acknowledgment before committing bridge files - fix: Android logs warning when 'intervalMs' is missing from periodic trigger map instead of silently defaulting - fix: iOS logs error when 'intervalMs' is missing before returning nil --- .../NativeWorkmanagerPlugin+Enqueue.kt | 6 +- .../KMPSchedulerBridge.swift | 1 + scripts/pre-commit-bridge-check.sh | 48 +++++ .../bridge_parameter_passthrough_test.dart | 167 ++++++++++++++++++ test/unit/task_trigger_test.dart | 102 +++++++++++ 5 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 scripts/pre-commit-bridge-check.sh create mode 100644 test/unit/bridge_parameter_passthrough_test.dart diff --git a/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt b/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt index 16314b5..39e9074 100644 --- a/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt +++ b/android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt @@ -298,7 +298,11 @@ internal fun NativeWorkmanagerPlugin.handleEnqueue(call: MethodCall, result: Res val triggerType = triggerMap?.get("type") as? String ?: "oneTime" val trigger: TaskTrigger = when (triggerType) { "periodic" -> { - val intervalMs = (triggerMap?.get("intervalMs") as? Number)?.toLong() ?: 900_000L + val rawIntervalMs = (triggerMap?.get("intervalMs") as? Number)?.toLong() + if (rawIntervalMs == null) { + NativeLogger.w("periodic trigger missing 'intervalMs' — defaulting to 15 min. Dart bridge bug?") + } + val intervalMs = rawIntervalMs ?: 900_000L val flexMs = (triggerMap?.get("flexMs") as? Number)?.toLong() val initialDelayMs = (triggerMap?.get("initialDelayMs") as? Number)?.toLong() ?: 0L var runImmediately = triggerMap?.get("runImmediately") as? Boolean ?: true diff --git a/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift b/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift index a9be716..dfa5bf5 100644 --- a/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift +++ b/ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift @@ -67,6 +67,7 @@ class KMPSchedulerBridge { case "periodic": guard let intervalMs = (map["intervalMs"] as? NSNumber)?.int64Value else { + NativeLogger.d("ERROR: periodic trigger missing 'intervalMs' — rejecting. Dart bridge bug?") return nil } let flexMs = (map["flexMs"] as? NSNumber)?.int64Value diff --git a/scripts/pre-commit-bridge-check.sh b/scripts/pre-commit-bridge-check.sh new file mode 100644 index 0000000..b6d5e30 --- /dev/null +++ b/scripts/pre-commit-bridge-check.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Pre-commit check for KMP bridge files. +# Install: cp scripts/pre-commit-bridge-check.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit + +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +BRIDGE_FILES=( + "ios/native_workmanager/Sources/native_workmanager/KMPSchedulerBridge.swift" + "android/src/main/kotlin/dev/brewkits/native_workmanager/NativeWorkmanagerPlugin+Enqueue.kt" +) + +changed_bridges=() +for file in "${BRIDGE_FILES[@]}"; do + if git diff --cached --name-only | grep -qF "$file"; then + changed_bridges+=("$file") + fi +done + +if [ ${#changed_bridges[@]} -eq 0 ]; then + exit 0 +fi + +echo -e "${RED}⚠️ KMP BRIDGE FILE(S) CHANGED:${NC}" +for f in "${changed_bridges[@]}"; do + echo " • $f" +done +echo "" +echo -e "${YELLOW}Mandatory checklist before committing:${NC}" +echo " 1. KotlinLong/KotlinInt: still using explicit KotlinLong(value:) constructor?" +echo " (NOT 'as? KotlinLong' — that always returns nil silently)" +echo " 2. Every Dart map key parsed by the bridge is still named correctly?" +echo " 3. New/changed parameter has a test in test/unit/bridge_parameter_passthrough_test.dart?" +echo " 4. If this is a second 'cleanup' commit, treated with same scrutiny as the fix commit?" +echo "" +# Skip interactive prompt in CI or non-interactive shells +if [ ! -t 1 ] || [ "${CI:-}" = "true" ] || [ "${SKIP_BRIDGE_CHECK:-}" = "1" ]; then + echo "(non-interactive — skipping prompt, proceeding)" + exit 0 +fi + +echo -n "Type 'yes' to confirm checklist complete: " +read -r answer ()); + expect(map['intervalMs'], equals(const Duration(hours: 2).inMilliseconds)); + }); + + test('flexMs is int when set, null when absent', () { + final withFlex = TaskTrigger.periodic( + const Duration(hours: 1), + flexInterval: const Duration(minutes: 20), + ).toMap(); + expect(withFlex['flexMs'], isA()); + expect(withFlex['flexMs'], equals(const Duration(minutes: 20).inMilliseconds)); + + final noFlex = TaskTrigger.periodic(const Duration(hours: 1)).toMap(); + expect(noFlex['flexMs'], isNull); + }); + + test('initialDelayMs is int when set, null when absent', () { + final withDelay = TaskTrigger.periodic( + const Duration(hours: 1), + initialDelay: const Duration(minutes: 15), + ).toMap(); + expect(withDelay['initialDelayMs'], isA()); + expect(withDelay['initialDelayMs'], + equals(const Duration(minutes: 15).inMilliseconds)); + + final noDelay = TaskTrigger.periodic(const Duration(hours: 1)).toMap(); + expect(noDelay['initialDelayMs'], isNull); + }); + + test('runImmediately is bool', () { + final trueMap = + TaskTrigger.periodic(const Duration(hours: 1)).toMap(); + expect(trueMap['runImmediately'], isA()); + expect(trueMap['runImmediately'], isTrue); + + final falseMap = TaskTrigger.periodic( + const Duration(hours: 1), + runImmediately: false, + ).toMap(); + expect(falseMap['runImmediately'], isA()); + expect(falseMap['runImmediately'], isFalse); + }); + }); + + group('OneTimeTrigger map keys and types', () { + test('all expected keys are present', () { + final map = + TaskTrigger.oneTime(const Duration(minutes: 5)).toMap(); + expect(map.containsKey('type'), isTrue); + expect(map.containsKey('initialDelayMs'), isTrue); + }); + + test('type is exactly "oneTime"', () { + final map = TaskTrigger.oneTime().toMap(); + expect(map['type'], equals('oneTime')); + }); + + test('initialDelayMs is int (milliseconds)', () { + final map = + TaskTrigger.oneTime(const Duration(minutes: 10)).toMap(); + expect(map['initialDelayMs'], isA()); + expect( + map['initialDelayMs'], equals(const Duration(minutes: 10).inMilliseconds)); + }); + + test('zero delay emits 0 not null', () { + final map = TaskTrigger.oneTime().toMap(); + expect(map['initialDelayMs'], equals(0)); + }); + }); + + group('ExactTrigger map keys and types', () { + test('all expected keys are present', () { + final map = TaskTrigger.exact(DateTime(2030)).toMap(); + expect(map.containsKey('type'), isTrue); + expect(map.containsKey('scheduledTimeMs'), isTrue); + }); + + test('type is exactly "exact"', () { + final map = TaskTrigger.exact(DateTime(2030)).toMap(); + expect(map['type'], equals('exact')); + }); + + test('scheduledTimeMs is epoch milliseconds as int', () { + final dt = DateTime(2030, 6, 15, 10, 0, 0); + final map = TaskTrigger.exact(dt).toMap(); + expect(map['scheduledTimeMs'], isA()); + expect(map['scheduledTimeMs'], equals(dt.millisecondsSinceEpoch)); + }); + }); + + group('WindowedTrigger map keys and types', () { + test('all expected keys are present', () { + final map = TaskTrigger.windowed( + earliest: const Duration(hours: 1), + latest: const Duration(hours: 2), + ).toMap(); + expect(map.containsKey('type'), isTrue); + expect(map.containsKey('earliestMs'), isTrue); + expect(map.containsKey('latestMs'), isTrue); + }); + + test('type is exactly "windowed"', () { + final map = TaskTrigger.windowed( + earliest: const Duration(hours: 1), + latest: const Duration(hours: 2), + ).toMap(); + expect(map['type'], equals('windowed')); + }); + + test('earliestMs and latestMs are int milliseconds', () { + final map = TaskTrigger.windowed( + earliest: const Duration(hours: 1), + latest: const Duration(hours: 3), + ).toMap(); + expect(map['earliestMs'], isA()); + expect(map['latestMs'], isA()); + expect(map['earliestMs'], equals(const Duration(hours: 1).inMilliseconds)); + expect(map['latestMs'], equals(const Duration(hours: 3).inMilliseconds)); + }); + }); + }); +} diff --git a/test/unit/task_trigger_test.dart b/test/unit/task_trigger_test.dart index 895eed5..c0704cc 100644 --- a/test/unit/task_trigger_test.dart +++ b/test/unit/task_trigger_test.dart @@ -207,6 +207,108 @@ void main() { expect(str, contains('1:00:00')); expect(str, contains('0:15:00')); }); + + // ── Parameter Combination Matrix ────────────────────────────────────── + // Each combination must have an explicit test. If a combination is not + // listed here, it is NOT protected against regressions. + // Bridge keys verified: 'intervalMs', 'flexMs', 'initialDelayMs', 'runImmediately' + group('parameter combination matrix', () { + // Baseline + test('combo: interval only → defaults', () { + final map = TaskTrigger.periodic( + const Duration(hours: 1), + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 1).inMilliseconds); + expect(map['flexMs'], isNull); + expect(map['initialDelayMs'], isNull); + expect(map['runImmediately'], isTrue); + }); + + test('combo: interval + flex', () { + final map = TaskTrigger.periodic( + const Duration(hours: 6), + flexInterval: const Duration(minutes: 30), + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 6).inMilliseconds); + expect(map['flexMs'], const Duration(minutes: 30).inMilliseconds); + expect(map['initialDelayMs'], isNull); + expect(map['runImmediately'], isTrue); + }); + + test('combo: interval + initialDelay', () { + final map = TaskTrigger.periodic( + const Duration(hours: 1), + initialDelay: const Duration(minutes: 5), + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 1).inMilliseconds); + expect(map['flexMs'], isNull); + expect(map['initialDelayMs'], const Duration(minutes: 5).inMilliseconds); + expect(map['runImmediately'], isTrue); + }); + + test('combo: interval + runImmediately:false', () { + final map = TaskTrigger.periodic( + const Duration(hours: 1), + runImmediately: false, + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 1).inMilliseconds); + expect(map['flexMs'], isNull); + expect(map['initialDelayMs'], isNull); + expect(map['runImmediately'], isFalse); + }); + + test('combo: interval + flex + initialDelay', () { + final map = TaskTrigger.periodic( + const Duration(hours: 6), + flexInterval: const Duration(minutes: 30), + initialDelay: const Duration(minutes: 10), + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 6).inMilliseconds); + expect(map['flexMs'], const Duration(minutes: 30).inMilliseconds); + expect(map['initialDelayMs'], const Duration(minutes: 10).inMilliseconds); + expect(map['runImmediately'], isTrue); + }); + + test('combo: interval + flex + runImmediately:false', () { + final map = TaskTrigger.periodic( + const Duration(hours: 6), + flexInterval: const Duration(minutes: 30), + runImmediately: false, + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 6).inMilliseconds); + expect(map['flexMs'], const Duration(minutes: 30).inMilliseconds); + expect(map['initialDelayMs'], isNull); + expect(map['runImmediately'], isFalse); + }); + + // issue_26: this combination was blocked by an assert in v1.2.4 + test('combo: interval + initialDelay + runImmediately:false [issue_26]', () { + final map = TaskTrigger.periodic( + const Duration(hours: 1), + initialDelay: const Duration(minutes: 30), + runImmediately: false, + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 1).inMilliseconds); + expect(map['flexMs'], isNull); + expect(map['initialDelayMs'], const Duration(minutes: 30).inMilliseconds); + // Dart emits false; native bridge overrides to true when initialDelayMs>0 + // to satisfy KMP library. See KMPSchedulerBridge.swift and +Enqueue.kt. + expect(map['runImmediately'], isFalse); + }); + + test('combo: all four — interval + flex + initialDelay + runImmediately:false', () { + final map = TaskTrigger.periodic( + const Duration(hours: 6), + flexInterval: const Duration(minutes: 30), + initialDelay: const Duration(minutes: 10), + runImmediately: false, + ).toMap(); + expect(map['intervalMs'], const Duration(hours: 6).inMilliseconds); + expect(map['flexMs'], const Duration(minutes: 30).inMilliseconds); + expect(map['initialDelayMs'], const Duration(minutes: 10).inMilliseconds); + expect(map['runImmediately'], isFalse); + }); + }); }); group('ExactTrigger', () { From 955fdf543660acb887fce196b6f6cab951ba328c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 7 May 2026 01:19:35 +0700 Subject: [PATCH 4/5] chore: pre-publish review for v1.2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pubignore: exclude CLAUSE.md, coverage/, test_results.txt - README: fix iOS badge (13.0+ → 14.0+), update install version to ^1.2.5 - CHANGELOG: remove duplicate --- separator and stray performance/acknowledgments block - doc: remove BUG_FIX_VERIFICATION.md from public tracking (moved to doc/internal/) - iOS workers: replace 148 bare print() calls with NativeLogger.d/w/e() - iOS HttpDownloadWorker, HttpUploadWorker: remove internal sprint/T3 planning comments - test: add periodic_trigger_reproduction_test.dart (8 cases, issue_26 regression coverage) - native_workmanager_gen: update README version to ^1.2.5, reformat CHANGELOG --- .pubignore | 3 + CHANGELOG.md | 40 --- README.md | 4 +- doc/BUG_FIX_VERIFICATION.md | 191 ------------- .../periodic_trigger_reproduction_test.dart | 264 ++++++++++++++++++ example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Podfile.lock | 4 +- .../engine/FlutterEngineManager.swift | 26 +- .../workers/DartCallbackWorker.swift | 16 +- .../workers/HttpDownloadWorker.swift | 131 ++++----- .../workers/HttpUploadWorker.swift | 84 +++--- .../workers/ImageProcessWorker.swift | 12 +- .../workers/IosWorker.swift | 2 +- .../workers/MoveToSharedStorageWorker.swift | 8 +- .../workers/ParallelHttpDownloadWorker.swift | 30 +- .../workers/ParallelHttpUploadWorker.swift | 10 +- native_workmanager_gen/CHANGELOG.md | 84 +++--- native_workmanager_gen/README.md | 2 +- 18 files changed, 472 insertions(+), 441 deletions(-) delete mode 100644 doc/BUG_FIX_VERIFICATION.md create mode 100644 example/integration_test/periodic_trigger_reproduction_test.dart diff --git a/.pubignore b/.pubignore index 794c0a7..13b3069 100644 --- a/.pubignore +++ b/.pubignore @@ -1,5 +1,6 @@ GEMINI.md CLAUDE.md +CLAUSE.md CONTRIBUTING.md ROADMAP.md .github/ @@ -10,4 +11,6 @@ benchmark/ scripts/ tool/ assets/ +coverage/ +test_results.txt doc/internal/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cc66ca5..16c2f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- ---- - ## [1.2.5] - 2026-05-06 ### Fixed @@ -93,41 +91,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Global Middleware API**: Global interceptors for task configuration (Headers, RemoteConfig, Logging). - **Code Generation Enhancements**: `native_workmanager_gen` now generates type-safe enqueue wrappers and automatic worker registries from `@WorkerCallback` annotations. - **Task Graphs (DAG)**: Support for complex non-linear task dependencies on Android. - ---- - -### ⚡ **Performance** - -All workers maintain high performance with low resource usage: - -| Worker | Memory Usage | Startup Time | Battery Impact | -|--------|-------------|--------------|----------------| -| HttpDownloadWorker | Low | Fast | Minimal | -| HttpUploadWorker | Low | Fast | Minimal | -| FileDecompressionWorker | Low | Fast | Minimal | -| CryptoWorker | Low | Fast | Minimal | - -**Key:** Streaming I/O keeps memory low regardless of file size. - ---- - -### 🙏 **Acknowledgments** - -Built on [kmpworkmanager v2.4.3](https://github.com/brewkits/kmpworkmanager/releases/tag/v2.4.3) for Kotlin Multiplatform. - ---- - -## Links - -- [GitHub Repository](https://github.com/brewkits/native_workmanager) -- [Issue Tracker](https://github.com/brewkits/native_workmanager/issues) -- [Documentation](https://github.com/brewkits/native_workmanager#readme) -- [KMP WorkManager](https://github.com/brewkits/kmpworkmanager) -- [Migration Guide](doc/MIGRATION_GUIDE.md) - ---- - -**Latest Version:** 1.2.5 -**Status:** Production Ready - Stable release for all production apps -**KMP Parity:** 100% (kmpworkmanager v2.4.3) -**Platforms:** Android | iOS diff --git a/README.md b/README.md index 8b8629e..8f10b21 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ CI MIT Android 8.0+ - iOS 13.0+ + iOS 14.0+

--- @@ -46,7 +46,7 @@ No boilerplate. No native code to write. No `AndroidManifest.xml` changes. Each ```yaml dependencies: - native_workmanager: ^1.2.4 + native_workmanager: ^1.2.5 ``` **2. Initialize once in `main()`:** diff --git a/doc/BUG_FIX_VERIFICATION.md b/doc/BUG_FIX_VERIFICATION.md deleted file mode 100644 index 7e0972e..0000000 --- a/doc/BUG_FIX_VERIFICATION.md +++ /dev/null @@ -1,191 +0,0 @@ -# WorkManager 2.10.0+ Bug Fix Verification - -## Bug Report - -**Issue:** IllegalStateException: Not implemented at androidx.work.CoroutineWorker.getForegroundInfo(CoroutineWorker.kt:92) -**Severity:** Critical - All Android users affected - -## Root Cause Analysis - -### The Problem - -WorkManager 2.10.0+ changed internal behavior: -- For expedited OneTime tasks, WorkManager now calls `getForegroundInfoAsync()` in the execution path -- This method requires `getForegroundInfo()` to be overridden -- **kmpworkmanager < 2.3.3** did not override this method -- Default `CoroutineWorker` implementation throws `IllegalStateException: Not implemented` - -### Impact - -All tasks using: -- `setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)` (default for OneTime tasks) -- WorkManager 2.10.0 or higher - -Would crash immediately upon execution. - -## The Fix - -### kmpworkmanager 2.3.3 - -**File:** `kmpworker/src/androidMain/kotlin/dev/brewkits/kmpworkmanager/background/data/KmpWorker.kt` - -```kotlin -override suspend fun getForegroundInfo(): ForegroundInfo { - ensureNotificationChannel() - val title = applicationContext.getString(R.string.kmp_worker_notification_title) - val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_popup_sync) - .setContentTitle(title) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setSilent(true) - .setOngoing(false) - .build() - return ForegroundInfo(NOTIFICATION_ID, notification) -} -``` - -**Key Points:** -- Overrides `getForegroundInfo()` to provide valid notification -- Uses string resources for i18n support -- Creates minimal-priority, silent notification -- Separate channel ID (`kmp_worker_tasks`) from `KmpHeavyWorker` (`kmp_heavy_worker_channel`) - -### Additional Fixes in 2.3.3 - -1. **Chain Heavy-Task Routing Bug** - - `NativeTaskScheduler.createWorkRequest()` was using `KmpWorker` for all chain tasks - - Fixed: Heavy tasks (`isHeavyTask=true`) now correctly use `KmpHeavyWorker` - -2. **Notification Localization** - - Added `res/values/strings.xml` with 5 notification string resources - - Host apps can override per locale (e.g., `res/values-ja/strings.xml`) - - Backward compatible: Falls back to hardcoded English if resources not found - -### native_workmanager 1.0.4 - -**File:** `android/build.gradle` - -```gradle -// Before (workaround): -api("androidx.work:work-runtime-ktx:2.9.1") // Pinned to avoid crash - -// After (proper fix): -api("dev.brewkits:kmpworkmanager:2.3.3") // Upgraded from 2.3.1 -api("androidx.work:work-runtime-ktx:2.10.1") // Now safe to use 2.10.1+ -``` - -## Verification - -### Build Verification - -✅ **Maven Central Availability** -```bash -$ curl -s "https://repo1.maven.org/maven2/dev/brewkits/kmpworkmanager/2.3.3/kmpworkmanager-2.3.3.pom" | head -5 - - - 4.0.0 - dev.brewkits - kmpworkmanager - 2.3.3 -``` - -✅ **Clean Build from Maven Central** -```bash -$ cd native_workmanager/example -$ flutter clean -$ flutter pub get -$ flutter build apk --debug - -Running Gradle task 'assembleDebug'... 21.1s -✓ Built build/app/outputs/flutter-apk/app-debug.apk -``` - -### Test Coverage - -#### Integration Test -**File:** `example/integration_test/workmanager_2_10_bug_fix_test.dart` - -Tests: -1. ✅ OneTime expedited task (original crash scenario) -2. ✅ Multiple concurrent expedited tasks (stress test) -3. ✅ Periodic task (non-expedited, should still work) -4. ✅ Task chain with expedited tasks -5. ✅ Notification localization support - -#### Interactive Demo -**File:** `example/lib/screens/bug_fix_demo_screen.dart` - -Visual demo proving bug fix: -- Shows bug information and fix details -- Runs 5 test scenarios: - - OneTime expedited task - - 3 concurrent expedited tasks - - 2-step task chain -- Real-time status updates (running → passed/failed) -- Summary dialog showing pass/fail counts - -**Access:** Open example app → "🐛 Bug Fix" tab - -### Runtime Verification - -Expected behavior on Android with WorkManager 2.10.1+: -- ✅ Expedited tasks execute without crash -- ✅ Notification appears briefly in system tray (silent, min priority) -- ✅ Tasks complete successfully -- ✅ No `IllegalStateException` in logcat - -## Release Timeline - -| Date | Version | Event | -|------|---------|-------| -| 2026-02-16 | native_workmanager 1.0.3 | Initial bug report from community user | -| 2026-02-17 | kmpworkmanager 2.3.3 | Fix released to Maven Central | -| 2026-02-18 | native_workmanager 1.0.4 | Updated dependency, bug verified fixed | - -## Migration Guide - -### For Existing Users - -**No code changes required!** Just upgrade: - -```yaml -# pubspec.yaml -dependencies: - native_workmanager: ^1.2.2 # was ^1.2.1 -``` - -Then: -```bash -flutter pub get -flutter clean -flutter build apk -``` - -### Notification Localization (Optional) - -Host apps can override notification strings: - -**Example: Japanese localization** - -Create `android/app/src/main/res/values-ja/strings.xml`: -```xml - - バックグラウンドタスク - バックグラウンドタスクを実行中 - 重いタスク - タスクを処理中 - 重いタスクを処理中… - -``` - -Android will automatically use the correct locale based on device language. - -## Conclusion - -✅ **Bug completely fixed** -- Root cause: Missing `getForegroundInfo()` override in kmpworkmanager -- Solution: Added proper override in 2.3.3 -- Verification: Both Android + iOS builds pass, demo confirms no crashes -- Bonus fixes: Chain routing bug + notification i18n support - -**All users should upgrade to the latest stable version (^1.2.2) immediately.** diff --git a/example/integration_test/periodic_trigger_reproduction_test.dart b/example/integration_test/periodic_trigger_reproduction_test.dart new file mode 100644 index 0000000..0ab0270 --- /dev/null +++ b/example/integration_test/periodic_trigger_reproduction_test.dart @@ -0,0 +1,264 @@ +// ignore_for_file: avoid_print +// ============================================================ +// Periodic Trigger Reproduction & Regression Tests +// ============================================================ +// +// Covers all valid TaskTrigger.periodic parameter combinations, +// including Issue #26 (initialDelay + runImmediately: false). +// +// Run on a real device or emulator: +// cd example && flutter test integration_test/periodic_trigger_reproduction_test.dart +// ============================================================ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:native_workmanager/native_workmanager.dart'; + +/// Unique task IDs to avoid collisions across test runs. +String _id(String name) => + 'prt_${name}_${DateTime.now().millisecondsSinceEpoch}'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + await NativeWorkManager.initialize(); + // Cancel any leftover tasks from previous runs. + await NativeWorkManager.cancelAll(); + }); + + tearDownAll(() async { + await NativeWorkManager.cancelAll(); + }); + + group('Periodic Trigger – parameter acceptance', () { + // ────────────────────────────────────────────────────────── + // REGRESSION – Issue #26 + // Previously an AssertionError was thrown for this combo because + // "initialDelay + runImmediately: false seemed contradictory". + // Both params are independently valid; the OS decides ordering. + // ────────────────────────────────────────────────────────── + testWidgets( + 'issue_26: periodic with initialDelay + runImmediately:false is accepted', + (tester) async { + final id = _id('issue_26'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: TaskTrigger.periodic( + const Duration(minutes: 15), + initialDelay: const Duration(minutes: 5), + runImmediately: false, + ), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect( + result.scheduleResult, + ScheduleResult.accepted, + reason: + 'issue_26: periodic with initialDelay + runImmediately:false must be accepted', + ); + + final status = await NativeWorkManager.getTaskStatus(taskId: id); + expect(status, isNotNull, reason: 'Task must be tracked after enqueue'); + + await NativeWorkManager.cancel(taskId: id); + }, + ); + + // ────────────────────────────────────────────────────────── + // Baseline: minimum valid periodic task (15-min interval only) + // ────────────────────────────────────────────────────────── + testWidgets('periodic – minimum interval (15 min) is accepted', + (tester) async { + final id = _id('min_interval'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.periodic(Duration(minutes: 15)), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect( + result.scheduleResult, + ScheduleResult.accepted, + reason: 'Minimum 15-min periodic must be accepted', + ); + + await NativeWorkManager.cancel(taskId: id); + }); + + // ────────────────────────────────────────────────────────── + // flexInterval: tests the KMP bridge flexMs conversion. + // A wrong cast (flexMs as? KotlinLong) silently returns nil — + // the KotlinLong(value:) constructor is the correct path. + // ────────────────────────────────────────────────────────── + testWidgets('periodic – with flexInterval is accepted', (tester) async { + final id = _id('flex'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: TaskTrigger.periodic( + const Duration(minutes: 15), + flexInterval: const Duration(minutes: 5), + ), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect( + result.scheduleResult, + ScheduleResult.accepted, + reason: 'Periodic with flexInterval must be accepted', + ); + + await NativeWorkManager.cancel(taskId: id); + }); + + // ────────────────────────────────────────────────────────── + // initialDelay alone (no runImmediately override) + // ────────────────────────────────────────────────────────── + testWidgets('periodic – with initialDelay is accepted', (tester) async { + final id = _id('initial_delay'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: TaskTrigger.periodic( + const Duration(minutes: 15), + initialDelay: const Duration(minutes: 1), + ), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect( + result.scheduleResult, + ScheduleResult.accepted, + reason: 'Periodic with initialDelay must be accepted', + ); + + await NativeWorkManager.cancel(taskId: id); + }); + + // ────────────────────────────────────────────────────────── + // runImmediately: true (explicit, should behave same as default) + // ────────────────────────────────────────────────────────── + testWidgets('periodic – with runImmediately:true is accepted', + (tester) async { + final id = _id('run_immediately'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: TaskTrigger.periodic( + const Duration(minutes: 15), + runImmediately: true, + ), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect( + result.scheduleResult, + ScheduleResult.accepted, + reason: 'Periodic with runImmediately:true must be accepted', + ); + + await NativeWorkManager.cancel(taskId: id); + }); + + // ────────────────────────────────────────────────────────── + // All params combined: interval + flex + initialDelay + runImmediately:false + // This is the most complete form and must not be blocked by any assert. + // ────────────────────────────────────────────────────────── + testWidgets( + 'issue_26: periodic with all params (interval+flex+initialDelay+runImmediately:false) is accepted', + (tester) async { + final id = _id('all_params'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: TaskTrigger.periodic( + const Duration(minutes: 15), + flexInterval: const Duration(minutes: 5), + initialDelay: const Duration(minutes: 1), + runImmediately: false, + ), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect( + result.scheduleResult, + ScheduleResult.accepted, + reason: + 'issue_26: periodic with all params must be accepted without error', + ); + + final status = await NativeWorkManager.getTaskStatus(taskId: id); + expect(status, isNotNull, + reason: 'Task must be tracked after full-param enqueue'); + + await NativeWorkManager.cancel(taskId: id); + }, + ); + + // ────────────────────────────────────────────────────────── + // Cancel verification: task must disappear from tracking after cancel. + // Protects against state leaks between test runs. + // ────────────────────────────────────────────────────────── + testWidgets('periodic – cancel removes task from tracking', (tester) async { + final id = _id('cancel_verify'); + + final result = await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.periodic(Duration(minutes: 15)), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + ); + + expect(result.scheduleResult, ScheduleResult.accepted); + + await NativeWorkManager.cancel(taskId: id); + + // After cancel, task must not be in a runnable/pending state. + final status = await NativeWorkManager.getTaskStatus(taskId: id); + expect( + status, + anyOf(isNull, equals(TaskStatus.cancelled)), + reason: 'Cancelled periodic task must not remain pending', + ); + }); + + // ────────────────────────────────────────────────────────── + // ExistingPolicy.replace: re-enqueueing the same ID replaces it. + // ────────────────────────────────────────────────────────── + testWidgets('periodic – REPLACE policy updates existing task', + (tester) async { + final id = _id('policy_replace'); + + // First enqueue + final r1 = await NativeWorkManager.enqueue( + taskId: id, + trigger: const TaskTrigger.periodic(Duration(minutes: 15)), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + existingPolicy: ExistingTaskPolicy.keep, + ); + expect(r1.scheduleResult, ScheduleResult.accepted); + + // Replace with a different flex window + final r2 = await NativeWorkManager.enqueue( + taskId: id, + trigger: TaskTrigger.periodic( + const Duration(minutes: 15), + flexInterval: const Duration(minutes: 5), + ), + worker: NativeWorker.httpSync(url: 'https://example.com/sync'), + existingPolicy: ExistingTaskPolicy.replace, + ); + + expect( + r2.scheduleResult, + ScheduleResult.accepted, + reason: 'REPLACE on an existing periodic task must be accepted', + ); + + await NativeWorkManager.cancel(taskId: id); + }); + }); +} diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 47d771e..7b98781 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,7 +2,7 @@ PODS: - Flutter (1.0.0) - integration_test (0.0.1): - Flutter - - native_workmanager (1.2.1): + - native_workmanager (1.2.4): - Flutter - shared_preferences_foundation (0.0.1): - Flutter @@ -32,7 +32,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - native_workmanager: caf01cc52808bde88cc17e0fa96d85cddb15c4d4 + native_workmanager: 80ddfef9e4b768fbc19a4540c5f1a4290959c4a0 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 diff --git a/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift b/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift index 71ebd67..f4d996a 100644 --- a/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift +++ b/ios/native_workmanager/Sources/native_workmanager/engine/FlutterEngineManager.swift @@ -121,9 +121,9 @@ class FlutterEngineManager { let initTime = Date().timeIntervalSince(startTime) if !wasEngineAlive { - print("FlutterEngineManager: Cold start - Engine initialized in \(Int(initTime * 1000))ms") + NativeLogger.d("FlutterEngineManager: Cold start - Engine initialized in \(Int(initTime * 1000))ms") } else { - print("FlutterEngineManager: Warm start - Engine already alive") + NativeLogger.d("FlutterEngineManager: Warm start - Engine already alive") } // Execute callback with timeout @@ -148,10 +148,10 @@ class FlutterEngineManager { group.cancelAll() let totalTime = Date().timeIntervalSince(startTime) - print("FlutterEngineManager: Callback (handle: \(callbackHandle)) completed in \(Int(totalTime * 1000))ms") + NativeLogger.d("FlutterEngineManager: Callback (handle: \(callbackHandle)) completed in \(Int(totalTime * 1000))ms") if disposeImmediately { - print("FlutterEngineManager: Aggressively disposing engine to free RAM") + NativeLogger.d("FlutterEngineManager: Aggressively disposing engine to free RAM") dispose() } else { // Original behavior: Keep engine alive for 5 minutes @@ -168,7 +168,7 @@ class FlutterEngineManager { // Dart isolate is hung — dispose the engine so the next task gets a fresh start. // Without this, isInitialized stays true and subsequent DartWorker tasks inherit // a hung MethodChannel whose invokeMethod replies will never arrive. - print("FlutterEngineManager: Callback timed out — disposing hung engine") + NativeLogger.d("FlutterEngineManager: Callback timed out — disposing hung engine") dispose() throw FlutterEngineError.timeout } @@ -184,7 +184,7 @@ class FlutterEngineManager { /// Dispose internals. Must be called with `queue` already held — never acquires the lock itself. private func _disposeInternal() { - print("FlutterEngineManager: Disposing engine...") + NativeLogger.d("FlutterEngineManager: Disposing engine...") methodChannel?.setMethodCallHandler(nil) methodChannel = nil engine = nil @@ -192,7 +192,7 @@ class FlutterEngineManager { disposalWorkItem?.cancel() disposalWorkItem = nil lastUsedTimestamp = nil - print("FlutterEngineManager: Engine disposed") + NativeLogger.d("FlutterEngineManager: Engine disposed") } // MARK: - Private Methods @@ -210,7 +210,7 @@ class FlutterEngineManager { let saved = UserDefaults.standard.object(forKey: FlutterEngineManager.callbackHandleKey) as? Int64 if let s = saved { self.callbackHandle = s - print("FlutterEngineManager: Restored callback handle from storage: \(s)") + NativeLogger.d("FlutterEngineManager: Restored callback handle from storage: \(s)") } } } @@ -250,7 +250,7 @@ class FlutterEngineManager { return } - print("FlutterEngineManager: Initializing Flutter Engine...") + NativeLogger.d("FlutterEngineManager: Initializing Flutter Engine...") // Create engine let engine = FlutterEngine(name: "native_workmanager_background") @@ -316,7 +316,7 @@ class FlutterEngineManager { if call.method == "dartReady" { result(nil) - print("FlutterEngineManager: Dart side ready") + NativeLogger.d("FlutterEngineManager: Dart side ready") // All writes to isReady AND continuation-resume happen on self.queue (serial). self.queue.async { @@ -355,7 +355,7 @@ class FlutterEngineManager { guard let self = self else { return } if !isReady { - print("FlutterEngineManager: Timeout waiting for Dart ready signal") + NativeLogger.d("FlutterEngineManager: Timeout waiting for Dart ready signal") self.completeInitialization(error: FlutterEngineError.dartReadyTimeout) } } @@ -399,7 +399,7 @@ class FlutterEngineManager { ] channel.invokeMethod("executeCallback", arguments: args) { result in if let error = result as? FlutterError { - print("FlutterEngineManager: Callback error: \(error.message ?? "unknown")") + NativeLogger.e("FlutterEngineManager: Callback error: \(error.message ?? "unknown")") continuation.resume(returning: false) } else if let success = result as? Bool { continuation.resume(returning: success) @@ -455,7 +455,7 @@ class FlutterEngineManager { let idleTime = Date().timeIntervalSince(lastUsed) if idleTime >= FlutterEngineManager.idleTimeoutSeconds { - print("FlutterEngineManager: Auto-disposing after \(Int(idleTime))s idle") + NativeLogger.d("FlutterEngineManager: Auto-disposing after \(Int(idleTime))s idle") // Call _disposeInternal() directly — queue is already held here, // so calling dispose() (which does queue.sync) would deadlock. self._disposeInternal() diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/DartCallbackWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/DartCallbackWorker.swift index 3daf8b8..691f9c1 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/DartCallbackWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/DartCallbackWorker.swift @@ -60,13 +60,13 @@ class DartCallbackWorker: IosWorker { func doWork(input: String?, env: KMPWorkManager.WorkerEnvironment) async throws -> WorkerResult { guard let input = input, !input.isEmpty else { - print("DartCallbackWorker: Error - Empty or null input") + NativeLogger.e("DartCallbackWorker: Error - Empty or null input") return WorkerResult.failure(message: "Empty or null input") } // Parse configuration guard let data = input.data(using: .utf8) else { - print("DartCallbackWorker: Error - Invalid UTF-8 encoding") + NativeLogger.e("DartCallbackWorker: Error - Invalid UTF-8 encoding") return WorkerResult.failure(message: "Invalid input encoding") } @@ -74,11 +74,11 @@ class DartCallbackWorker: IosWorker { do { config = try JSONDecoder().decode(Config.self, from: data) } catch { - print("DartCallbackWorker: Error parsing JSON config: \(error)") + NativeLogger.e("DartCallbackWorker: Error parsing JSON config: \(error)") return WorkerResult.failure(message: "Error parsing JSON config: \(error.localizedDescription)") } - print("DartCallbackWorker: Executing callback '\(config.callbackId)' (handle: \(config.callbackHandle), autoDispose: \(config.shouldAutoDispose))") + NativeLogger.d("DartCallbackWorker: Executing callback '\(config.callbackId)' (handle: \(config.callbackHandle), autoDispose: \(config.shouldAutoDispose))") let startTime = Date() let engineWasAlive = FlutterEngineManager.shared.isEngineAlive @@ -97,18 +97,18 @@ class DartCallbackWorker: IosWorker { if result { if engineWasAlive { - print("DartCallbackWorker: Success (warm start) - \(Int(executionTime * 1000))ms") + NativeLogger.d("DartCallbackWorker: Success (warm start) - \(Int(executionTime * 1000))ms") } else { - print("DartCallbackWorker: Success (cold start) - \(Int(executionTime * 1000))ms") + NativeLogger.d("DartCallbackWorker: Success (cold start) - \(Int(executionTime * 1000))ms") } return WorkerResult.success(message: "Callback returned true") } else { - print("DartCallbackWorker: Failed - Callback returned false") + NativeLogger.e("DartCallbackWorker: Failed - Callback returned false") return WorkerResult.failure(message: "Callback returned false") } } catch { let executionTime = Date().timeIntervalSince(startTime) - print("DartCallbackWorker: Error - \(error.localizedDescription) (\(Int(executionTime * 1000))ms)") + NativeLogger.e("DartCallbackWorker: Error - \(error.localizedDescription) (\(Int(executionTime * 1000))ms)") return WorkerResult.failure(message: "Execution error: \(error.localizedDescription)") } } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift index e7acc5b..5b6c39c 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/HttpDownloadWorker.swift @@ -48,7 +48,7 @@ class HttpDownloadWorker: IosWorker { func stop() { isStopped = true currentDownloadTask?.cancel() - print("HttpDownloadWorker: Stop signal received, download cancelled.") + NativeLogger.d("HttpDownloadWorker: Stop signal received, download cancelled.") } struct Config: Codable { @@ -62,22 +62,15 @@ class HttpDownloadWorker: IosWorker { let useBackgroundSession: Bool? let skipExisting: Bool? - // Sprint 1 - Feature 5: Advanced file handling let onDuplicate: String? // "overwrite" (default) | "rename" | "skip" let moveToPublicDownloads: Bool? // Copy to iOS Downloads folder after download let saveToGallery: Bool? // Save image/video to Photos library after download let extractAfterDownload: Bool? // Extract archive after download let extractPath: String? // Destination directory for extraction let deleteArchiveAfterExtract: Bool? // Delete archive after successful extraction - - // Sprint 3 - Cookie support let cookies: [String: String]? - - // Sprint 3 - Auth layer let authToken: String? let authHeaderTemplate: String? // Default: "Bearer {accessToken}" - - // T3-6 - Bandwidth throttling (bytes/s; nil = no limit) let bandwidthLimitBytesPerSecond: Int64? var timeout: TimeInterval { @@ -107,13 +100,13 @@ class HttpDownloadWorker: IosWorker { func doWork(input: String?, env: KMPWorkManager.WorkerEnvironment) async throws -> WorkerResult { guard let input = input, !input.isEmpty else { - print("HttpDownloadWorker: Error - Empty or null input") + NativeLogger.e("HttpDownloadWorker: Error - Empty or null input") return .failure(message: "Empty or null input") } // Parse configuration guard let data = input.data(using: .utf8) else { - print("HttpDownloadWorker: Error - Invalid UTF-8 encoding") + NativeLogger.e("HttpDownloadWorker: Error - Invalid UTF-8 encoding") return .failure(message: "Invalid input encoding") } @@ -127,14 +120,14 @@ class HttpDownloadWorker: IosWorker { do { config = try JSONDecoder().decode(Config.self, from: data) } catch { - print("HttpDownloadWorker: Error parsing JSON config: \(error)") + NativeLogger.e("HttpDownloadWorker: Error parsing JSON config: \(error)") return .failure(message: "Invalid JSON config: \(error.localizedDescription)") } // Skip download if destination already exists and skipExisting is enabled // (only for non-directory paths where we already know the final filename) if config.shouldSkipExisting && !config.isDirectory && FileManager.default.fileExists(atPath: config.savePath) { - print("HttpDownloadWorker: skipExisting=true and file already exists — skipping") + NativeLogger.w("HttpDownloadWorker: skipExisting=true and file already exists — skipping") let size = (try? FileManager.default.attributesOfItem(atPath: config.savePath))?[.size] as? Int64 ?? 0 return .success( message: "File already exists, download skipped", @@ -146,7 +139,7 @@ class HttpDownloadWorker: IosWorker { if !config.isDirectory { let duplicate = config.effectiveOnDuplicate if duplicate == "skip" && FileManager.default.fileExists(atPath: config.savePath) { - print("HttpDownloadWorker: onDuplicate=skip and file exists — skipping") + NativeLogger.w("HttpDownloadWorker: onDuplicate=skip and file exists — skipping") let size = (try? FileManager.default.attributesOfItem(atPath: config.savePath))?[.size] as? Int64 ?? 0 return .success( message: "File already exists, download skipped (onDuplicate=skip)", @@ -163,13 +156,13 @@ class HttpDownloadWorker: IosWorker { // Validate URL scheme (prevent file://, ftp://, etc.) guard let url = SecurityValidator.validateURL(config.url) else { - print("HttpDownloadWorker: Error - Invalid or unsafe URL") + NativeLogger.e("HttpDownloadWorker: Error - Invalid or unsafe URL") return .failure(message: "Invalid or unsafe URL") } // Validate file path is within app sandbox guard SecurityValidator.validateFilePath(config.savePath) else { - print("HttpDownloadWorker: Error - File path outside app sandbox") + NativeLogger.e("HttpDownloadWorker: Error - File path outside app sandbox") return .failure(message: "File path outside app sandbox") } @@ -192,9 +185,9 @@ class HttpDownloadWorker: IosWorker { do { try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) - print("HttpDownloadWorker: Created directory: \(parentDir.path)") + NativeLogger.d("HttpDownloadWorker: Created directory: \(parentDir.path)") } catch { - print("HttpDownloadWorker: Error creating directory: \(error)") + NativeLogger.e("HttpDownloadWorker: Error creating directory: \(error)") return .failure(message: "Failed to create directory: \(error.localizedDescription)") } } @@ -214,13 +207,13 @@ class HttpDownloadWorker: IosWorker { let attributes = try FileManager.default.attributesOfItem(atPath: tempURL.path) if let fileSize = attributes[.size] as? Int64, fileSize > 0 { existingBytes = fileSize - print("HttpDownloadWorker: Found existing partial download: \(existingBytes) bytes") + NativeLogger.d("HttpDownloadWorker: Found existing partial download: \(existingBytes) bytes") } else { // Delete empty temp file try? FileManager.default.removeItem(at: tempURL) } } catch { - print("HttpDownloadWorker: Error reading temp file: \(error)") + NativeLogger.e("HttpDownloadWorker: Error reading temp file: \(error)") try? FileManager.default.removeItem(at: tempURL) } } else if FileManager.default.fileExists(atPath: tempURL.path) { @@ -230,8 +223,8 @@ class HttpDownloadWorker: IosWorker { // Sanitize URL for logging let sanitizedURL = SecurityValidator.sanitizedURL(config.url) - print("HttpDownloadWorker: Downloading \(sanitizedURL)") - print(" Save to: \(destinationURL.lastPathComponent)") + NativeLogger.d("HttpDownloadWorker: Downloading \(sanitizedURL)") + NativeLogger.d(" Save to: \(destinationURL.lastPathComponent)") // Build request var request = URLRequest(url: url) @@ -249,7 +242,7 @@ class HttpDownloadWorker: IosWorker { request.setValue(stored.trimmingCharacters(in: .whitespacesAndNewlines), forHTTPHeaderField: HttpConstants.headerIfRange) } - print("HttpDownloadWorker: Resuming download from byte \(existingBytes)") + NativeLogger.d("HttpDownloadWorker: Resuming download from byte \(existingBytes)") } // Add custom headers @@ -258,27 +251,19 @@ class HttpDownloadWorker: IosWorker { request.setValue(value, forHTTPHeaderField: key) } } - - // Sprint 3: Cookie support if let cookies = config.cookies, !cookies.isEmpty { let cookieHeader = cookies.map { "\($0.key)=\($0.value)" }.joined(separator: "; ") request.addValue(cookieHeader, forHTTPHeaderField: HttpConstants.headerCookie) } - - // Sprint 3: Auth layer if let authToken = config.authToken { let headerValue = config.effectiveAuthHeaderTemplate .replacingOccurrences(of: "{accessToken}", with: authToken) request.addValue(headerValue, forHTTPHeaderField: HttpConstants.headerAuthorization) } - - // T3-7: HMAC-SHA256 request signing let rawDictForSigning = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) if let signingCfg = RequestSigner.Config.from(rawDictForSigning?["requestSigning"] as? [String: Any]) { RequestSigner.sign(request: &request, config: signingCfg) } - - // Sprint 2: per-host concurrency — block here until a permit is available. // The permit is released after the download completes (success, failure, or skip). let host = URL(string: config.url)?.host ?? config.url HostConcurrencyManager.shared.acquire(host: host) @@ -296,8 +281,6 @@ class HttpDownloadWorker: IosWorker { existingBytes: existingBytes ) } - - // T3-6: Throttled foreground download (iOS 15+) when bandwidth limit is configured if #available(iOS 15.0, *), let bwLimit = config.bandwidthLimitBytesPerSecond, bwLimit > 0 { return await throttledForegroundDownload( @@ -310,7 +293,7 @@ class HttpDownloadWorker: IosWorker { taskIdForProgress: taskIdForProgress ) } else if let bwLimit = config.bandwidthLimitBytesPerSecond, bwLimit > 0 { - print("HttpDownloadWorker: bandwidth throttling requires iOS 15+, proceeding unthrottled") + NativeLogger.d("HttpDownloadWorker: bandwidth throttling requires iOS 15+, proceeding unthrottled") } // Execute download using foreground session (iOS 13+ compatible) @@ -333,7 +316,7 @@ class HttpDownloadWorker: IosWorker { // Handle errors if let error = error { - print("HttpDownloadWorker: Error - \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: Error - \(error.localizedDescription)") try? FileManager.default.removeItem(at: tempURL) continuation.resume(returning: .failure(message: error.localizedDescription)) return @@ -341,7 +324,7 @@ class HttpDownloadWorker: IosWorker { guard let location = location, let httpResponse = response as? HTTPURLResponse else { - print("HttpDownloadWorker: Error - Invalid response") + NativeLogger.e("HttpDownloadWorker: Error - Invalid response") continuation.resume(returning: .failure(message: "Invalid response")) return } @@ -355,7 +338,7 @@ class HttpDownloadWorker: IosWorker { // 416 Range Not Satisfiable: our .tmp is stale (server file changed). Delete and signal retry. if statusCode == HttpConstants.rangeNotSatisfiable { - print("HttpDownloadWorker: 416 Range Not Satisfiable — deleting stale .tmp, restart on retry") + NativeLogger.d("HttpDownloadWorker: 416 Range Not Satisfiable — deleting stale .tmp, restart on retry") try? FileManager.default.removeItem(at: tempURL) try? FileManager.default.removeItem(atPath: tempURL.path + HttpConstants.etagSidecarSuffix) continuation.resume(returning: .failure( @@ -365,7 +348,7 @@ class HttpDownloadWorker: IosWorker { } if !isPartialContent && !isFullContent { - print("HttpDownloadWorker: Failed - Status \(statusCode)") + NativeLogger.e("HttpDownloadWorker: Failed - Status \(statusCode)") try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "HTTP \(statusCode)")) return @@ -375,7 +358,7 @@ class HttpDownloadWorker: IosWorker { let contentLength = httpResponse.expectedContentLength if contentLength > 0 { if !SecurityValidator.validateContentLength(contentLength) { - print("HttpDownloadWorker: Error - Content too large") + NativeLogger.e("HttpDownloadWorker: Error - Content too large") try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "Download size exceeds limit")) return @@ -383,7 +366,7 @@ class HttpDownloadWorker: IosWorker { // Check disk space if !SecurityValidator.hasEnoughDiskSpace(requiredBytes: contentLength, targetURL: destinationURL) { - print("HttpDownloadWorker: Error - Insufficient disk space") + NativeLogger.e("HttpDownloadWorker: Error - Insufficient disk space") try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "Insufficient disk space")) return @@ -392,14 +375,12 @@ class HttpDownloadWorker: IosWorker { // Log resume status if isResumingDownload { - print("HttpDownloadWorker: Resume confirmed - Server sent 206 Partial Content") + NativeLogger.d("HttpDownloadWorker: Resume confirmed - Server sent 206 Partial Content") } else if existingBytes > 0 && statusCode == HttpConstants.httpOk { - print("HttpDownloadWorker: Server doesn't support resume - Starting from beginning") + NativeLogger.d("HttpDownloadWorker: Server doesn't support resume - Starting from beginning") try? FileManager.default.removeItem(at: tempURL) try? FileManager.default.removeItem(atPath: tempURL.path + HttpConstants.etagSidecarSuffix) } - - // Feature 4: Resolve filename from Content-Disposition or URL when savePath is a directory let cdHeader = httpResponse.value(forHTTPHeaderField: "Content-Disposition") let serverSuggestedName: String? = self.parseFilenameFromContentDisposition(cdHeader) if config.isDirectory { @@ -410,11 +391,11 @@ class HttpDownloadWorker: IosWorker { // LOGIC-001: keep tempURL as the sentinel file in directory mode so that partial // download data is preserved across retries. Only destinationURL changes here; // the final rename moves tempURL (sentinel) → destinationURL (resolved name). - print("HttpDownloadWorker: Directory mode — resolved filename: \(name)") + NativeLogger.d("HttpDownloadWorker: Directory mode — resolved filename: \(name)") // skipExisting check for directory mode (now we know actual path) if config.shouldSkipExisting && FileManager.default.fileExists(atPath: destinationURL.path) { - print("HttpDownloadWorker: skipExisting=true and resolved file exists — skipping") + NativeLogger.w("HttpDownloadWorker: skipExisting=true and resolved file exists — skipping") let size = (try? FileManager.default.attributesOfItem(atPath: destinationURL.path))?[.size] as? Int64 ?? 0 try? FileManager.default.removeItem(at: location) var skipData: [String: Any] = ["filePath": destinationURL.path, "fileSize": size, "skipped": true] @@ -477,26 +458,26 @@ class HttpDownloadWorker: IosWorker { // Verify checksum if expected checksum is provided if let expectedChecksum = config.expectedChecksum { - print("HttpDownloadWorker: Verifying checksum with \(config.effectiveChecksumAlgorithm)...") + NativeLogger.d("HttpDownloadWorker: Verifying checksum with \(config.effectiveChecksumAlgorithm)...") guard let actualChecksum = self.calculateChecksum(fileURL: tempURL, algorithm: config.effectiveChecksumAlgorithm, taskId: taskIdForProgress) else { - print("HttpDownloadWorker: Error - Failed to calculate checksum") + NativeLogger.e("HttpDownloadWorker: Error - Failed to calculate checksum") try? FileManager.default.removeItem(at: tempURL) continuation.resume(returning: .failure(message: "Failed to calculate checksum")) return } if actualChecksum.caseInsensitiveCompare(expectedChecksum) != .orderedSame { - print("HttpDownloadWorker: Checksum verification failed!") - print(" Expected: \(expectedChecksum)") - print(" Actual: \(actualChecksum)") - print(" Algorithm: \(config.effectiveChecksumAlgorithm)") + NativeLogger.e("HttpDownloadWorker: Checksum verification failed!") + NativeLogger.d(" Expected: \(expectedChecksum)") + NativeLogger.d(" Actual: \(actualChecksum)") + NativeLogger.d(" Algorithm: \(config.effectiveChecksumAlgorithm)") try? FileManager.default.removeItem(at: tempURL) continuation.resume(returning: .failure(message: "Checksum verification failed (expected: \(expectedChecksum), actual: \(actualChecksum))")) return } - print("HttpDownloadWorker: Checksum verified: \(actualChecksum)") + NativeLogger.d("HttpDownloadWorker: Checksum verified: \(actualChecksum)") } // Capture content type and final URL @@ -517,8 +498,8 @@ class HttpDownloadWorker: IosWorker { // Clean up ETag sidecar after successful download try? FileManager.default.removeItem(atPath: tempURL.path + HttpConstants.etagSidecarSuffix) - print("HttpDownloadWorker: Success - Downloaded \(finalFileSize) bytes") - print("HttpDownloadWorker: Saved to: \(destinationURL.path)") + NativeLogger.d("HttpDownloadWorker: Success - Downloaded \(finalFileSize) bytes") + NativeLogger.d("HttpDownloadWorker: Saved to: \(destinationURL.path)") // Post-download actions self.performPostDownloadActions(config: config, filePath: destinationURL.path) @@ -537,7 +518,7 @@ class HttpDownloadWorker: IosWorker { data: resultData )) } catch { - print("HttpDownloadWorker: Error moving file - \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: Error moving file - \(error.localizedDescription)") try? FileManager.default.removeItem(at: tempURL) try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "Failed to move file: \(error.localizedDescription)")) @@ -765,26 +746,26 @@ class HttpDownloadWorker: IosWorker { // Verify checksum if expected checksum is provided if let expectedChecksum = config.expectedChecksum { - print("HttpDownloadWorker: Verifying checksum with \(config.effectiveChecksumAlgorithm)...") + NativeLogger.d("HttpDownloadWorker: Verifying checksum with \(config.effectiveChecksumAlgorithm)...") guard let actualChecksum = self.calculateChecksum(fileURL: location, algorithm: config.effectiveChecksumAlgorithm, taskId: nil) else { - print("HttpDownloadWorker: Error - Failed to calculate checksum") + NativeLogger.e("HttpDownloadWorker: Error - Failed to calculate checksum") try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "Failed to calculate checksum")) return } if actualChecksum.lowercased() != expectedChecksum.lowercased() { - print("HttpDownloadWorker: Checksum verification failed!") - print(" Expected: \(expectedChecksum)") - print(" Actual: \(actualChecksum)") - print(" Algorithm: \(config.effectiveChecksumAlgorithm)") + NativeLogger.e("HttpDownloadWorker: Checksum verification failed!") + NativeLogger.d(" Expected: \(expectedChecksum)") + NativeLogger.d(" Actual: \(actualChecksum)") + NativeLogger.d(" Algorithm: \(config.effectiveChecksumAlgorithm)") try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "Checksum verification failed (expected: \(expectedChecksum), actual: \(actualChecksum))")) return } - print("HttpDownloadWorker: Checksum verified: \(actualChecksum)") + NativeLogger.d("HttpDownloadWorker: Checksum verified: \(actualChecksum)") } // Remove destination if exists @@ -793,8 +774,8 @@ class HttpDownloadWorker: IosWorker { // Move to final destination try FileManager.default.moveItem(at: location, to: destinationURL) - print("HttpDownloadWorker: Background download success - Downloaded \(finalFileSize) bytes") - print("HttpDownloadWorker: Saved to: \(config.savePath)") + NativeLogger.d("HttpDownloadWorker: Background download success - Downloaded \(finalFileSize) bytes") + NativeLogger.d("HttpDownloadWorker: Saved to: \(config.savePath)") // Return success with rich data continuation.resume(returning: .success( @@ -808,13 +789,13 @@ class HttpDownloadWorker: IosWorker { ] )) } catch { - print("HttpDownloadWorker: Error moving file - \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: Error moving file - \(error.localizedDescription)") try? FileManager.default.removeItem(at: location) continuation.resume(returning: .failure(message: "Failed to move file: \(error.localizedDescription)")) } case .failure(let error): - print("HttpDownloadWorker: Background download failed - \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: Background download failed - \(error.localizedDescription)") continuation.resume(returning: .failure(message: "Background download failed: \(error.localizedDescription)")) } } @@ -859,9 +840,9 @@ class HttpDownloadWorker: IosWorker { PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL) }, completionHandler: { success, error in if let error = error { - print("HttpDownloadWorker: saveToGallery image error: \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: saveToGallery image error: \(error.localizedDescription)") } else if success { - print("HttpDownloadWorker: Image saved to gallery") + NativeLogger.d("HttpDownloadWorker: Image saved to gallery") } }) } else if ["mp4", "mov", "m4v"].contains(ext) { @@ -869,9 +850,9 @@ class HttpDownloadWorker: IosWorker { PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL) }, completionHandler: { success, error in if let error = error { - print("HttpDownloadWorker: saveToGallery video error: \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: saveToGallery video error: \(error.localizedDescription)") } else if success { - print("HttpDownloadWorker: Video saved to gallery") + NativeLogger.d("HttpDownloadWorker: Video saved to gallery") } }) } @@ -887,16 +868,16 @@ class HttpDownloadWorker: IosWorker { try FileManager.default.removeItem(at: destURL) } try FileManager.default.copyItem(at: fileURL, to: destURL) - print("HttpDownloadWorker: Copied to public Downloads: \(destURL.path)") + NativeLogger.d("HttpDownloadWorker: Copied to public Downloads: \(destURL.path)") } catch { - print("HttpDownloadWorker: moveToPublicDownloads error: \(error.localizedDescription)") + NativeLogger.e("HttpDownloadWorker: moveToPublicDownloads error: \(error.localizedDescription)") } } } // extractAfterDownload: disabled in v1.1.0 for Zero Dependencies if config.effectiveExtractAfterDownload { - print("HttpDownloadWorker: extractAfterDownload is disabled in v1.1.0 to achieve Zero Dependencies. Please use the Dart 'archive' package.") + NativeLogger.d("HttpDownloadWorker: extractAfterDownload is disabled in v1.1.0 to achieve Zero Dependencies. Please use the Dart 'archive' package.") } } @@ -988,12 +969,12 @@ class HttpDownloadWorker: IosWorker { return digest.map { String(format: "%02x", $0) }.joined() default: - print("HttpDownloadWorker: Unsupported checksum algorithm: \(algorithm)") + NativeLogger.d("HttpDownloadWorker: Unsupported checksum algorithm: \(algorithm)") return nil } } else { // Fallback for iOS < 13 (CryptoKit not available) - print("HttpDownloadWorker: CryptoKit requires iOS 13+") + NativeLogger.d("HttpDownloadWorker: CryptoKit requires iOS 13+") return nil } } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift index e27dc94..f968bfd 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/HttpUploadWorker.swift @@ -146,13 +146,13 @@ class HttpUploadWorker: IosWorker { func doWork(input: String?, env: KMPWorkManager.WorkerEnvironment) async throws -> WorkerResult { guard let input = input, !input.isEmpty else { - print("HttpUploadWorker: Error - Empty or null input") + NativeLogger.e("HttpUploadWorker: Error - Empty or null input") return .failure(message: "Empty or null input") } // Parse configuration guard let data = input.data(using: .utf8) else { - print("HttpUploadWorker: Error - Invalid UTF-8 encoding") + NativeLogger.e("HttpUploadWorker: Error - Invalid UTF-8 encoding") return .failure(message: "Invalid input encoding") } @@ -160,13 +160,13 @@ class HttpUploadWorker: IosWorker { do { config = try JSONDecoder().decode(Config.self, from: data) } catch { - print("HttpUploadWorker: Error parsing JSON config: \(error)") + NativeLogger.e("HttpUploadWorker: Error parsing JSON config: \(error)") return .failure(message: "Invalid JSON config: \(error.localizedDescription)") } // Validate URL scheme (prevent file://, ftp://, etc.) guard let url = SecurityValidator.validateURL(config.url) else { - print("HttpUploadWorker: Error - Invalid or unsafe URL") + NativeLogger.e("HttpUploadWorker: Error - Invalid or unsafe URL") return .failure(message: "Invalid or unsafe URL") } @@ -176,12 +176,12 @@ class HttpUploadWorker: IosWorker { // Validate upload mode if isRawBodyUpload && !fileConfigs.isEmpty { - print("HttpUploadWorker: Error - Cannot mix raw body and file upload") + NativeLogger.e("HttpUploadWorker: Error - Cannot mix raw body and file upload") return .failure(message: "Cannot use both body/bodyBytes and filePath/files") } if !isRawBodyUpload && fileConfigs.isEmpty { - print("HttpUploadWorker: Error - No data to upload") + NativeLogger.e("HttpUploadWorker: Error - No data to upload") return .failure(message: "No data to upload (provide body/bodyBytes or filePath/files)") } @@ -197,19 +197,19 @@ class HttpUploadWorker: IosWorker { for fileConfig in fileConfigs { // Validate file path guard SecurityValidator.validateFilePath(fileConfig.filePath) else { - print("HttpUploadWorker: Error - File path outside sandbox: \(fileConfig.filePath)") + NativeLogger.e("HttpUploadWorker: Error - File path outside sandbox: \(fileConfig.filePath)") return .failure(message: "File path outside sandbox") } let fileURL = URL(fileURLWithPath: fileConfig.filePath) guard FileManager.default.fileExists(atPath: fileConfig.filePath) else { - print("HttpUploadWorker: Error - File not found: \(fileConfig.filePath)") + NativeLogger.e("HttpUploadWorker: Error - File not found: \(fileConfig.filePath)") return .failure(message: "File not found: \(fileURL.lastPathComponent)") } // Validate file size guard SecurityValidator.validateFileSize(fileURL) else { - print("HttpUploadWorker: Error - File too large: \(fileConfig.filePath)") + NativeLogger.e("HttpUploadWorker: Error - File too large: \(fileConfig.filePath)") return .failure(message: "File size exceeds limit") } @@ -220,7 +220,7 @@ class HttpUploadWorker: IosWorker { totalSize += fileSize } } catch { - print("HttpUploadWorker: Error reading file: \(error)") + NativeLogger.e("HttpUploadWorker: Error reading file: \(error)") return .failure(message: "Failed to read file: \(fileURL.lastPathComponent)") } @@ -233,12 +233,12 @@ class HttpUploadWorker: IosWorker { // Sanitize logging let sanitizedURL = SecurityValidator.sanitizedURL(config.url) - print("HttpUploadWorker: Uploading to \(sanitizedURL)") - print(" Files: \(validatedFiles.count), Total Size: \(totalSize) bytes") + NativeLogger.d("HttpUploadWorker: Uploading to \(sanitizedURL)") + NativeLogger.d(" Files: \(validatedFiles.count), Total Size: \(totalSize) bytes") for (index, (fileURL, fileName, mimeType)) in validatedFiles.enumerated() { let fileAttributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) let fileSize = fileAttributes?[.size] as? Int64 ?? 0 - print(" [\(index)] \(fileName) (\(fileSize) bytes, \(mimeType))") + NativeLogger.d(" [\(index)] \(fileName) (\(fileSize) bytes, \(mimeType))") } // 🚀 Use background session if enabled (v2.3.0+) @@ -276,12 +276,10 @@ class HttpUploadWorker: IosWorker { do { bodyTempURL = try buildMultipartBodyToFile(boundary: boundary, fields: config.fields, files: fileTuples) } catch { - print("HttpUploadWorker: Error building multipart body: \(error)") + NativeLogger.e("HttpUploadWorker: Error building multipart body: \(error)") return .failure(message: "Failed to build upload body: \(error.localizedDescription)") } defer { try? FileManager.default.removeItem(at: bodyTempURL) } - - // T3-7: HMAC-SHA256 request signing let uploadRawDict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] if let signingDict = uploadRawDict?["requestSigning"] as? [String: Any], let signingCfg: RequestSigner.Config = RequestSigner.Config.from(signingDict) { @@ -299,13 +297,13 @@ class HttpUploadWorker: IosWorker { let (data, response) = try await uploadSession.upload(for: request, fromFile: bodyTempURL) guard let httpResponse = response as? HTTPURLResponse else { - print("HttpUploadWorker: Error - Invalid response type") + NativeLogger.e("HttpUploadWorker: Error - Invalid response type") return .failure(message: "Invalid response type") } // Validate response body size guard SecurityValidator.validateResponseSize(data) else { - print("HttpUploadWorker: Error - Response body too large") + NativeLogger.e("HttpUploadWorker: Error - Response body too large") return .failure(message: "Response body too large") } @@ -316,8 +314,8 @@ class HttpUploadWorker: IosWorker { if success { // Truncate response for logging let truncatedResponse = SecurityValidator.truncateForLogging(responseBody, maxLength: 200) - print("HttpUploadWorker: Success - Status \(statusCode)") - print("HttpUploadWorker: Response: \(truncatedResponse)") + NativeLogger.d("HttpUploadWorker: Success - Status \(statusCode)") + NativeLogger.d("HttpUploadWorker: Response: \(truncatedResponse)") return .success( message: "Uploaded \(validatedFiles.count) file(s), \(totalSize) bytes", @@ -332,8 +330,8 @@ class HttpUploadWorker: IosWorker { } else { // Truncate error body for logging let truncatedError = SecurityValidator.truncateForLogging(responseBody, maxLength: 200) - print("HttpUploadWorker: Failed - Status \(statusCode)") - print("HttpUploadWorker: Error: \(truncatedError)") + NativeLogger.e("HttpUploadWorker: Failed - Status \(statusCode)") + NativeLogger.e("HttpUploadWorker: Error: \(truncatedError)") // 401 + token refresh: attempt refresh and retry once if statusCode == 401, let refreshConfig = uploadTokenRefreshConfig { @@ -363,7 +361,7 @@ class HttpUploadWorker: IosWorker { return .failure(message: "HTTP \(statusCode)", shouldRetry: statusCode >= 500) } } catch { - print("HttpUploadWorker: Error - \(error.localizedDescription)") + NativeLogger.e("HttpUploadWorker: Error - \(error.localizedDescription)") return .failure(message: error.localizedDescription, shouldRetry: true) } } @@ -372,28 +370,28 @@ class HttpUploadWorker: IosWorker { private func handleRawBodyUpload(config: Config, url: URL, rawInputData: Data) async -> WorkerResult { // Validate content type is provided guard let contentType = config.contentType, !contentType.isEmpty else { - print("HttpUploadWorker: Error - contentType is required for raw body upload") + NativeLogger.e("HttpUploadWorker: Error - contentType is required for raw body upload") return .failure(message: "contentType is required for raw body upload") } let sanitizedURL = SecurityValidator.sanitizedURL(config.url) - print("HttpUploadWorker: Uploading raw body to \(sanitizedURL)") - print(" Content-Type: \(contentType)") + NativeLogger.d("HttpUploadWorker: Uploading raw body to \(sanitizedURL)") + NativeLogger.d(" Content-Type: \(contentType)") // Build request body let requestBody: Data if let body = config.body { requestBody = body.data(using: .utf8) ?? Data() - print(" Body: \(body.count) characters (\(requestBody.count) bytes)") + NativeLogger.d(" Body: \(body.count) characters (\(requestBody.count) bytes)") } else if let bodyBytes = config.bodyBytes { guard let decodedData = Data(base64Encoded: bodyBytes) else { - print("HttpUploadWorker: Error - Failed to decode base64 bodyBytes") + NativeLogger.e("HttpUploadWorker: Error - Failed to decode base64 bodyBytes") return .failure(message: "Invalid base64 bodyBytes") } requestBody = decodedData - print(" Body: \(requestBody.count) bytes (from base64)") + NativeLogger.d(" Body: \(requestBody.count) bytes (from base64)") } else { - print("HttpUploadWorker: Error - No body or bodyBytes provided") + NativeLogger.e("HttpUploadWorker: Error - No body or bodyBytes provided") return .failure(message: "No body or bodyBytes provided") } @@ -409,8 +407,6 @@ class HttpUploadWorker: IosWorker { request.setValue(value, forHTTPHeaderField: key) } } - - // T3-7: HMAC-SHA256 request signing (raw body path) let rawBodyDict = (try? JSONSerialization.jsonObject(with: rawInputData)) as? [String: Any] if let signingDict = rawBodyDict?["requestSigning"] as? [String: Any], let signingCfg: RequestSigner.Config = RequestSigner.Config.from(signingDict) { @@ -428,13 +424,13 @@ class HttpUploadWorker: IosWorker { let (data, response) = try await rawBodySession.upload(for: request, from: requestBody) guard let httpResponse = response as? HTTPURLResponse else { - print("HttpUploadWorker: Error - Invalid response type") + NativeLogger.e("HttpUploadWorker: Error - Invalid response type") return .failure(message: "Invalid response type") } // Validate response body size guard SecurityValidator.validateResponseSize(data) else { - print("HttpUploadWorker: Error - Response body too large") + NativeLogger.e("HttpUploadWorker: Error - Response body too large") return .failure(message: "Response body too large") } @@ -445,8 +441,8 @@ class HttpUploadWorker: IosWorker { if success { // Truncate response for logging let truncatedResponse = SecurityValidator.truncateForLogging(responseBody, maxLength: 200) - print("HttpUploadWorker: Success - Status \(statusCode)") - print("HttpUploadWorker: Response: \(truncatedResponse)") + NativeLogger.d("HttpUploadWorker: Success - Status \(statusCode)") + NativeLogger.d("HttpUploadWorker: Response: \(truncatedResponse)") return .success( message: "Uploaded raw body", @@ -460,8 +456,8 @@ class HttpUploadWorker: IosWorker { } else { // Truncate error body for logging let truncatedError = SecurityValidator.truncateForLogging(responseBody, maxLength: 200) - print("HttpUploadWorker: Failed - Status \(statusCode)") - print("HttpUploadWorker: Error: \(truncatedError)") + NativeLogger.e("HttpUploadWorker: Failed - Status \(statusCode)") + NativeLogger.e("HttpUploadWorker: Error: \(truncatedError)") // 401 + token refresh: attempt refresh and retry once if statusCode == 401, let refreshConfig = rawBodyTokenRefreshConfig { @@ -490,7 +486,7 @@ class HttpUploadWorker: IosWorker { return .failure(message: "HTTP \(statusCode)", shouldRetry: statusCode >= 500) } } catch { - print("HttpUploadWorker: Error - \(error.localizedDescription)") + NativeLogger.e("HttpUploadWorker: Error - \(error.localizedDescription)") return .failure(message: error.localizedDescription, shouldRetry: true) } } @@ -513,7 +509,7 @@ class HttpUploadWorker: IosWorker { validatedFiles: [(url: URL, fileName: String, mimeType: String)], totalSize: Int64 ) async -> WorkerResult { - print("HttpUploadWorker: Using background URLSession for upload") + NativeLogger.d("HttpUploadWorker: Using background URLSession for upload") // NET-001/NET-004: Build multipart body by streaming files to a temp file (avoids OOM). let fileConfigs = config.getFileConfigs() @@ -524,7 +520,7 @@ class HttpUploadWorker: IosWorker { do { tempFileURL = try buildMultipartBodyToFile(boundary: boundary, fields: config.fields, files: fileTuplesBg) } catch { - print("HttpUploadWorker: Error building multipart body: \(error)") + NativeLogger.e("HttpUploadWorker: Error building multipart body: \(error)") return .failure(message: "Failed to build upload body: \(error.localizedDescription)") } defer { try? FileManager.default.removeItem(at: tempFileURL) } @@ -554,7 +550,7 @@ class HttpUploadWorker: IosWorker { let success = (200..<300).contains(statusCode) if success { - print("HttpUploadWorker: Background upload succeeded - Status \(statusCode)") + NativeLogger.d("HttpUploadWorker: Background upload succeeded - Status \(statusCode)") continuation.resume(returning: .success( message: "Uploaded \(validatedFiles.count) file(s) via background session", @@ -567,12 +563,12 @@ class HttpUploadWorker: IosWorker { ] )) } else { - print("HttpUploadWorker: Background upload failed - Status \(statusCode)") + NativeLogger.e("HttpUploadWorker: Background upload failed - Status \(statusCode)") continuation.resume(returning: .failure(message: "HTTP \(statusCode)")) } case .failure(let error): - print("HttpUploadWorker: Background upload failed: \(error.localizedDescription)") + NativeLogger.e("HttpUploadWorker: Background upload failed: \(error.localizedDescription)") continuation.resume(returning: .failure(message: "Background upload failed: \(error.localizedDescription)")) } } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/ImageProcessWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/ImageProcessWorker.swift index 87424fe..fd371de 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/ImageProcessWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/ImageProcessWorker.swift @@ -74,7 +74,7 @@ class ImageProcessWorker: IosWorker { // Parse configuration guard let data = input.data(using: .utf8) else { - print("ImageProcessWorker: Error - Invalid UTF-8 encoding") + NativeLogger.e("ImageProcessWorker: Error - Invalid UTF-8 encoding") return .failure(message: "Invalid input encoding") } @@ -136,7 +136,7 @@ class ImageProcessWorker: IosWorker { return .failure(message: "Failed to crop image") } processedImage = croppedImage - print("ImageProcessWorker: Cropped to: \(Int(processedImage.size.width))x\(Int(processedImage.size.height))") + NativeLogger.d("ImageProcessWorker: Cropped to: \(Int(processedImage.size.width))x\(Int(processedImage.size.height))") } // Resize if needed @@ -149,7 +149,7 @@ class ImageProcessWorker: IosWorker { maxHeight: CGFloat(maxHeight), maintainAspectRatio: config.shouldMaintainAspectRatio ) - print("ImageProcessWorker: Resized to: \(Int(processedImage.size.width))x\(Int(processedImage.size.height))") + NativeLogger.d("ImageProcessWorker: Resized to: \(Int(processedImage.size.width))x\(Int(processedImage.size.height))") } } @@ -194,12 +194,12 @@ class ImageProcessWorker: IosWorker { let processedSize = Int64(data.count) let compressionRatio = originalSize > 0 ? String(format: "%.1f", (Float(processedSize) / Float(originalSize)) * 100) : "N/A" - print("ImageProcessWorker: Processed image saved: \(processedSize) bytes (\(compressionRatio)% of original)") + NativeLogger.d("ImageProcessWorker: Processed image saved: \(processedSize) bytes (\(compressionRatio)% of original)") // Delete original if requested if config.shouldDeleteOriginal && inputURL.path != outputURL.path { try? FileManager.default.removeItem(at: inputURL) - print("ImageProcessWorker: Deleted original file") + NativeLogger.d("ImageProcessWorker: Deleted original file") } return .success( @@ -234,7 +234,7 @@ class ImageProcessWorker: IosWorker { let imageBounds = CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height) let intersection = cropRect.intersection(imageBounds) guard !intersection.isEmpty else { - print("ImageProcessWorker: Invalid crop rectangle") + NativeLogger.d("ImageProcessWorker: Invalid crop rectangle") return nil } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/IosWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/IosWorker.swift index f11b5f8..c33ea29 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/IosWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/IosWorker.swift @@ -115,7 +115,7 @@ public class IosWorkerFactory { case "PdfWorker": return PdfWorker() default: - print("IosWorkerFactory: Unknown worker class: \(className)") + NativeLogger.d("IosWorkerFactory: Unknown worker class: \(className)") return nil } } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/MoveToSharedStorageWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/MoveToSharedStorageWorker.swift index 0832a7e..8fe2bc1 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/MoveToSharedStorageWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/MoveToSharedStorageWorker.swift @@ -70,14 +70,14 @@ class MoveToSharedStorageWorker: IosWorker { request.addResource(with: .photo, fileURL: sourceURL, options: nil) }) { success, error in if success { - print("MoveToSharedStorageWorker: Saved to Photo Library: \(fileName)") + NativeLogger.d("MoveToSharedStorageWorker: Saved to Photo Library: \(fileName)") continuation.resume(returning: .success( message: "Saved to Photo Library", data: ["fileName": fileName, "storageType": "photos"] )) } else { let msg = error?.localizedDescription ?? "Unknown error" - print("MoveToSharedStorageWorker: Photo Library error: \(msg)") + NativeLogger.e("MoveToSharedStorageWorker: Photo Library error: \(msg)") continuation.resume(returning: .failure(message: "Failed to save to Photo Library: \(msg)")) } } @@ -123,13 +123,13 @@ class MoveToSharedStorageWorker: IosWorker { } try FileManager.default.copyItem(at: sourceURL, to: destURL) - print("MoveToSharedStorageWorker: Copied to Documents: \(destURL.path)") + NativeLogger.d("MoveToSharedStorageWorker: Copied to Documents: \(destURL.path)") return .success( message: "Saved to Documents", data: ["filePath": destURL.path, "fileName": fileName, "storageType": "documents"] ) } catch { - print("MoveToSharedStorageWorker: Documents copy failed: \(error.localizedDescription)") + NativeLogger.e("MoveToSharedStorageWorker: Documents copy failed: \(error.localizedDescription)") return .failure(message: "Failed to copy to Documents: \(error.localizedDescription)") } } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpDownloadWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpDownloadWorker.swift index cd3b441..23bef84 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpDownloadWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpDownloadWorker.swift @@ -78,7 +78,7 @@ class ParallelHttpDownloadWorker: IosWorker { // Skip download if destination already exists and skipExisting is enabled if (config.skipExisting ?? false) && FileManager.default.fileExists(atPath: config.savePath) { - print("ParallelHttpDownloadWorker: skipExisting=true and file already exists — skipping") + NativeLogger.w("ParallelHttpDownloadWorker: skipExisting=true and file already exists — skipping") let size = (try? FileManager.default.attributesOfItem(atPath: config.savePath))?[.size] as? Int64 ?? 0 return .success( message: "File already exists, download skipped", @@ -108,14 +108,14 @@ class ParallelHttpDownloadWorker: IosWorker { let ar = http.value(forHTTPHeaderField: "Accept-Ranges")?.lowercased() == "bytes" return (cl, ar) } catch { - print("ParallelHttpDownloadWorker: HEAD failed, fallback: \(error.localizedDescription)") + NativeLogger.e("ParallelHttpDownloadWorker: HEAD failed, fallback: \(error.localizedDescription)") return (-1, false) } }() // ── Step 2: Fallback if server does not support range requests ──────── if !acceptsRanges || contentLength <= 0 { - print("ParallelHttpDownloadWorker: No range support or unknown size — sequential fallback") + NativeLogger.w("ParallelHttpDownloadWorker: No range support or unknown size — sequential fallback") return await downloadSequential( session: session, url: url, config: config, destinationURL: destinationURL, taskId: taskId, @@ -124,7 +124,7 @@ class ParallelHttpDownloadWorker: IosWorker { ) } - print("ParallelHttpDownloadWorker: Content-Length=\(contentLength) chunks=\(config.effectiveNumChunks)") + NativeLogger.d("ParallelHttpDownloadWorker: Content-Length=\(contentLength) chunks=\(config.effectiveNumChunks)") // ── Step 3: Compute byte ranges ─────────────────────────────────────── let numChunks = config.effectiveNumChunks @@ -175,7 +175,7 @@ class ParallelHttpDownloadWorker: IosWorker { // ── Step 7: Merge parts → temp file ────────────────────────────────── let tempURL = URL(fileURLWithPath: config.savePath + ".tmp") - print("ParallelHttpDownloadWorker: Merging \(numChunks) chunks") + NativeLogger.d("ParallelHttpDownloadWorker: Merging \(numChunks) chunks") do { // Resolve symlinks before writing (iOS /var → /private/var) @@ -217,14 +217,14 @@ class ParallelHttpDownloadWorker: IosWorker { // ── Step 8: Checksum ─────────────────────────────────────────── if let expected = config.expectedChecksum { - print("ParallelHttpDownloadWorker: Verifying checksum (\(config.effectiveChecksumAlgorithm))...") + NativeLogger.d("ParallelHttpDownloadWorker: Verifying checksum (\(config.effectiveChecksumAlgorithm))...") guard let actual = calculateChecksum(fileURL: resolvedTemp, algorithm: config.effectiveChecksumAlgorithm) else { return .failure(message: "Failed to calculate checksum") } if actual.caseInsensitiveCompare(expected) != .orderedSame { return .failure(message: "Checksum mismatch (expected: \(expected), actual: \(actual))") } - print("ParallelHttpDownloadWorker: Checksum OK: \(actual)") + NativeLogger.d("ParallelHttpDownloadWorker: Checksum OK: \(actual)") } // ── Step 9: Atomic rename ────────────────────────────────────── @@ -239,7 +239,7 @@ class ParallelHttpDownloadWorker: IosWorker { let finalSize = (try? FileManager.default.attributesOfItem(atPath: resolvedDest.path))?[.size] as? Int64 ?? 0 reportProgress(taskId: taskId, progress: 100, message: "Download complete", bytesDownloaded: finalSize, totalBytes: totalBytes) - print("ParallelHttpDownloadWorker: Success — \(finalSize) bytes at \(config.savePath)") + NativeLogger.d("ParallelHttpDownloadWorker: Success — \(finalSize) bytes at \(config.savePath)") return .success( message: "Downloaded \(finalSize) bytes (\(numChunks) parallel chunks)", @@ -252,7 +252,7 @@ class ParallelHttpDownloadWorker: IosWorker { ] ) } catch { - print("ParallelHttpDownloadWorker: Merge failed: \(error.localizedDescription)") + NativeLogger.e("ParallelHttpDownloadWorker: Merge failed: \(error.localizedDescription)") return .failure(message: "Merge failed: \(error.localizedDescription)") } } @@ -279,7 +279,7 @@ class ParallelHttpDownloadWorker: IosWorker { // Resume: skip if already complete let existingSize = (try? FileManager.default.attributesOfItem(atPath: partURL.path))?[.size] as? Int64 ?? 0 if existingSize >= expectedSize { - print("ParallelHttpDownloadWorker: Chunk \(index) already complete, skipping") + NativeLogger.w("ParallelHttpDownloadWorker: Chunk \(index) already complete, skipping") downloadedAtomic.add(existingSize) return true } @@ -290,7 +290,7 @@ class ParallelHttpDownloadWorker: IosWorker { config.headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) } if let sc = signingConfig { RequestSigner.sign(request: &request, config: sc) } - print("ParallelHttpDownloadWorker: Chunk \(index) — bytes=\(resumeFrom)-\(rangeEnd)") + NativeLogger.d("ParallelHttpDownloadWorker: Chunk \(index) — bytes=\(resumeFrom)-\(rangeEnd)") do { var (data, response) = try await session.data(for: request) @@ -311,7 +311,7 @@ class ParallelHttpDownloadWorker: IosWorker { guard let http = response as? HTTPURLResponse, (200 ..< 300).contains(http.statusCode) else { let code = (response as? HTTPURLResponse)?.statusCode ?? -1 - print("ParallelHttpDownloadWorker: Chunk \(index) HTTP \(code)") + NativeLogger.d("ParallelHttpDownloadWorker: Chunk \(index) HTTP \(code)") return false } @@ -341,10 +341,10 @@ class ParallelHttpDownloadWorker: IosWorker { networkSpeed: speed, timeRemainingMs: etaMs ) } - print("ParallelHttpDownloadWorker: Chunk \(index) done (\(data.count) bytes)") + NativeLogger.d("ParallelHttpDownloadWorker: Chunk \(index) done (\(data.count) bytes)") return true } catch { - print("ParallelHttpDownloadWorker: Chunk \(index) error: \(error.localizedDescription)") + NativeLogger.e("ParallelHttpDownloadWorker: Chunk \(index) error: \(error.localizedDescription)") return false } } @@ -509,7 +509,7 @@ class ParallelHttpDownloadWorker: IosWorker { }) {} return h.finalize().map { String(format: "%02x", $0) }.joined() default: - print("ParallelHttpDownloadWorker: Unsupported algorithm: \(algorithm)") + NativeLogger.d("ParallelHttpDownloadWorker: Unsupported algorithm: \(algorithm)") return nil } } diff --git a/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpUploadWorker.swift b/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpUploadWorker.swift index 9de2818..bdb2256 100644 --- a/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpUploadWorker.swift +++ b/ios/native_workmanager/Sources/native_workmanager/workers/ParallelHttpUploadWorker.swift @@ -115,7 +115,7 @@ class ParallelHttpUploadWorker: IosWorker { )) } - print("ParallelHttpUploadWorker: Uploading \(resolvedFiles.count) files to \(host)" + + NativeLogger.d("ParallelHttpUploadWorker: Uploading \(resolvedFiles.count) files to \(host)" + " maxConcurrent=\(config.effectiveMaxConcurrent) maxRetries=\(config.effectiveMaxRetries)") let sessionConfig = URLSessionConfiguration.default @@ -147,7 +147,7 @@ class ParallelHttpUploadWorker: IosWorker { while attempt <= config.effectiveMaxRetries { if attempt > 0 { - print("ParallelHttpUploadWorker: Retry \(attempt)/\(config.effectiveMaxRetries) for \(rf.resolvedName)") + NativeLogger.d("ParallelHttpUploadWorker: Retry \(attempt)/\(config.effectiveMaxRetries) for \(rf.resolvedName)") } // Per-host concurrency gate (DispatchSemaphore.wait — OK on cooperative pool thread). @@ -183,7 +183,7 @@ class ParallelHttpUploadWorker: IosWorker { let done = uploadedCount progressLock.unlock() - print("ParallelHttpUploadWorker: [\(i)] \(rf.resolvedName) uploaded (\(done)/\(resolvedFiles.count))") + NativeLogger.d("ParallelHttpUploadWorker: [\(i)] \(rf.resolvedName) uploaded (\(done)/\(resolvedFiles.count))") if let taskId = taskId { let pct = Int(min(99, Double(done) / Double(resolvedFiles.count) * 100)) @@ -208,7 +208,7 @@ class ParallelHttpUploadWorker: IosWorker { attempt += 1 } - print("ParallelHttpUploadWorker: [\(i)] \(rf.resolvedName) failed after \(attempt) attempt(s): \(lastError)") + NativeLogger.e("ParallelHttpUploadWorker: [\(i)] \(rf.resolvedName) failed after \(attempt) attempt(s): \(lastError)") return (i, [ "fileName": rf.resolvedName, "filePath": rf.fileURL.path, @@ -236,7 +236,7 @@ class ParallelHttpUploadWorker: IosWorker { ProgressReporter.shared.clearTask(taskId) } - print("ParallelHttpUploadWorker: Done — \(succeeded) succeeded, \(failed) failed, \(totalBytes) bytes total") + NativeLogger.e("ParallelHttpUploadWorker: Done — \(succeeded) succeeded, \(failed) failed, \(totalBytes) bytes total") if succeeded == 0 { return .failure(message: "All \(fileResults.count) file uploads failed") diff --git a/native_workmanager_gen/CHANGELOG.md b/native_workmanager_gen/CHANGELOG.md index 144e79f..5fa500d 100644 --- a/native_workmanager_gen/CHANGELOG.md +++ b/native_workmanager_gen/CHANGELOG.md @@ -1,48 +1,68 @@ -## 1.2.5 - 2026-05-06 +# Changelog -- Bump version to 1.2.5 to synchronize with the `native_workmanager` package release. +All notable changes to this project will be documented in this file. -## 1.2.3 - 2026-04-24 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -- Bump version to 1.2.3 to synchronize with the `native_workmanager` package release. +--- -## 1.0.4 - 2026-04-20 +## [1.2.5] - 2026-05-06 -- Widen `analyzer` constraint from `^12.0.0` to `>=10.0.0 <13.0.0` so that - Flutter projects whose SDK pins `meta` to `1.17.0` (e.g. Flutter 3.41.x) - can resolve the dependency without conflict — `analyzer 10.x` requires - `meta ^1.15.0` which satisfies the pin. -- Fix deprecation: replace `getDisplayString(withNullability: false)` with - `getDisplayString()` (the `withNullability` parameter was deprecated across - all supported analyzer versions). +### Changed +- Version bump to match `native_workmanager` 1.2.5 release. -## 1.0.3 +--- -- Require analyzer `>=12.0.0` and Dart SDK `>=3.9.0` to match pub.dev analysis - environment and gain access to stable analyzer 12.x APIs. -- Replace `FunctionElement` (removed in analyzer 12.x / Dart 3.11+) with - `ElementKind.FUNCTION` check and `TopLevelFunctionElement` cast. -- Replace `element.parameters` (renamed to `formalParameters` in analyzer 12.x) - with `fn.formalParameters` throughout validation logic. -- Replace `element.name` (now `String?` in analyzer 12.x) with - `element.displayName` (always non-null `String`) throughout. -- Drop redundant `enclosingElement is! LibraryElement` guard — annotated - top-level functions always satisfy `kind == ElementKind.FUNCTION`. +## [1.2.3] - 2026-04-24 -## 1.0.2 +### Changed +- Version bump to match `native_workmanager` 1.2.3 release. -- Replace `TypeChecker.fromRuntime` (removed in source_gen 4.x) with `TypeChecker.fromUrl` - — removes `dart:mirrors` dependency and fixes static analysis on pub.dev. -- Remove `native_workmanager` from runtime dependencies (only needed at build time via URI). +--- -## 1.0.1 +## [1.0.4] - 2026-04-20 -- Widen dependency constraints: `build <5`, `source_gen <5`, `analyzer <13`, `build_runner <4`. -- Add dartdoc to `workerCallbackBuilder` and `WorkerCallbackGenerator` constructor. -- Add example demonstrating codegen setup. +### Fixed +- Widened `analyzer` constraint from `^12.0.0` to `>=10.0.0 <13.0.0` to resolve + `meta` version conflict on Flutter 3.41.x (`analyzer 10.x` requires `meta ^1.15.0`). +- Replaced deprecated `getDisplayString(withNullability: false)` with `getDisplayString()`. -## 1.0.0 +--- +## [1.0.3] + +### Changed +- Require `analyzer >=12.0.0` and Dart SDK `>=3.9.0`. +- Replaced `FunctionElement` (removed in analyzer 12.x) with `ElementKind.FUNCTION` check + and `TopLevelFunctionElement` cast. +- Replaced `element.parameters` with `fn.formalParameters` (renamed in analyzer 12.x). +- Replaced `element.name` with `element.displayName` (now `String?` in analyzer 12.x). + +--- + +## [1.0.2] + +### Fixed +- Replaced `TypeChecker.fromRuntime` (removed in source_gen 4.x) with `TypeChecker.fromUrl` — + removes `dart:mirrors` dependency and fixes static analysis on pub.dev. +- Removed `native_workmanager` from runtime dependencies (only needed at build time via URI). + +--- + +## [1.0.1] + +### Changed +- Widened dependency constraints: `build <5`, `source_gen <5`, `analyzer <13`, `build_runner <4`. + +### Added +- Dartdoc to `workerCallbackBuilder` and `WorkerCallbackGenerator` constructor. +- Example demonstrating codegen setup. + +--- + +## [1.0.0] + +### Added - Initial release: `@WorkerCallback` annotation code generator for `native_workmanager`. - Generates type-safe callback IDs and worker registry from annotated top-level functions. - Validates callback signature (`Future` return type, `String?` parameter). diff --git a/native_workmanager_gen/README.md b/native_workmanager_gen/README.md index 3fda6cf..4b9c33d 100644 --- a/native_workmanager_gen/README.md +++ b/native_workmanager_gen/README.md @@ -8,7 +8,7 @@ Generates type-safe Dart callback IDs and a worker registry from `@WorkerCallbac ```yaml dev_dependencies: - native_workmanager_gen: ^1.0.4 + native_workmanager_gen: ^1.2.5 build_runner: ^2.4.0 ``` From 0aa79bf89529d874fdc34d1fe20c1e96c705f18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Thu, 7 May 2026 01:21:24 +0700 Subject: [PATCH 5/5] style: fix dart format in test files --- .../bridge_parameter_passthrough_test.dart | 25 ++++++++++--------- test/unit/task_trigger_test.dart | 19 +++++++++----- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/test/unit/bridge_parameter_passthrough_test.dart b/test/unit/bridge_parameter_passthrough_test.dart index 3f393c4..5042bdb 100644 --- a/test/unit/bridge_parameter_passthrough_test.dart +++ b/test/unit/bridge_parameter_passthrough_test.dart @@ -43,7 +43,8 @@ void main() { test('intervalMs is int (milliseconds)', () { final map = TaskTrigger.periodic(const Duration(hours: 2)).toMap(); expect(map['intervalMs'], isA()); - expect(map['intervalMs'], equals(const Duration(hours: 2).inMilliseconds)); + expect( + map['intervalMs'], equals(const Duration(hours: 2).inMilliseconds)); }); test('flexMs is int when set, null when absent', () { @@ -52,7 +53,8 @@ void main() { flexInterval: const Duration(minutes: 20), ).toMap(); expect(withFlex['flexMs'], isA()); - expect(withFlex['flexMs'], equals(const Duration(minutes: 20).inMilliseconds)); + expect(withFlex['flexMs'], + equals(const Duration(minutes: 20).inMilliseconds)); final noFlex = TaskTrigger.periodic(const Duration(hours: 1)).toMap(); expect(noFlex['flexMs'], isNull); @@ -72,8 +74,7 @@ void main() { }); test('runImmediately is bool', () { - final trueMap = - TaskTrigger.periodic(const Duration(hours: 1)).toMap(); + final trueMap = TaskTrigger.periodic(const Duration(hours: 1)).toMap(); expect(trueMap['runImmediately'], isA()); expect(trueMap['runImmediately'], isTrue); @@ -88,8 +89,7 @@ void main() { group('OneTimeTrigger map keys and types', () { test('all expected keys are present', () { - final map = - TaskTrigger.oneTime(const Duration(minutes: 5)).toMap(); + final map = TaskTrigger.oneTime(const Duration(minutes: 5)).toMap(); expect(map.containsKey('type'), isTrue); expect(map.containsKey('initialDelayMs'), isTrue); }); @@ -100,11 +100,10 @@ void main() { }); test('initialDelayMs is int (milliseconds)', () { - final map = - TaskTrigger.oneTime(const Duration(minutes: 10)).toMap(); + final map = TaskTrigger.oneTime(const Duration(minutes: 10)).toMap(); expect(map['initialDelayMs'], isA()); - expect( - map['initialDelayMs'], equals(const Duration(minutes: 10).inMilliseconds)); + expect(map['initialDelayMs'], + equals(const Duration(minutes: 10).inMilliseconds)); }); test('zero delay emits 0 not null', () { @@ -159,8 +158,10 @@ void main() { ).toMap(); expect(map['earliestMs'], isA()); expect(map['latestMs'], isA()); - expect(map['earliestMs'], equals(const Duration(hours: 1).inMilliseconds)); - expect(map['latestMs'], equals(const Duration(hours: 3).inMilliseconds)); + expect( + map['earliestMs'], equals(const Duration(hours: 1).inMilliseconds)); + expect( + map['latestMs'], equals(const Duration(hours: 3).inMilliseconds)); }); }); }); diff --git a/test/unit/task_trigger_test.dart b/test/unit/task_trigger_test.dart index c0704cc..8a33fb2 100644 --- a/test/unit/task_trigger_test.dart +++ b/test/unit/task_trigger_test.dart @@ -242,7 +242,8 @@ void main() { ).toMap(); expect(map['intervalMs'], const Duration(hours: 1).inMilliseconds); expect(map['flexMs'], isNull); - expect(map['initialDelayMs'], const Duration(minutes: 5).inMilliseconds); + expect( + map['initialDelayMs'], const Duration(minutes: 5).inMilliseconds); expect(map['runImmediately'], isTrue); }); @@ -265,7 +266,8 @@ void main() { ).toMap(); expect(map['intervalMs'], const Duration(hours: 6).inMilliseconds); expect(map['flexMs'], const Duration(minutes: 30).inMilliseconds); - expect(map['initialDelayMs'], const Duration(minutes: 10).inMilliseconds); + expect(map['initialDelayMs'], + const Duration(minutes: 10).inMilliseconds); expect(map['runImmediately'], isTrue); }); @@ -282,7 +284,8 @@ void main() { }); // issue_26: this combination was blocked by an assert in v1.2.4 - test('combo: interval + initialDelay + runImmediately:false [issue_26]', () { + test('combo: interval + initialDelay + runImmediately:false [issue_26]', + () { final map = TaskTrigger.periodic( const Duration(hours: 1), initialDelay: const Duration(minutes: 30), @@ -290,13 +293,16 @@ void main() { ).toMap(); expect(map['intervalMs'], const Duration(hours: 1).inMilliseconds); expect(map['flexMs'], isNull); - expect(map['initialDelayMs'], const Duration(minutes: 30).inMilliseconds); + expect(map['initialDelayMs'], + const Duration(minutes: 30).inMilliseconds); // Dart emits false; native bridge overrides to true when initialDelayMs>0 // to satisfy KMP library. See KMPSchedulerBridge.swift and +Enqueue.kt. expect(map['runImmediately'], isFalse); }); - test('combo: all four — interval + flex + initialDelay + runImmediately:false', () { + test( + 'combo: all four — interval + flex + initialDelay + runImmediately:false', + () { final map = TaskTrigger.periodic( const Duration(hours: 6), flexInterval: const Duration(minutes: 30), @@ -305,7 +311,8 @@ void main() { ).toMap(); expect(map['intervalMs'], const Duration(hours: 6).inMilliseconds); expect(map['flexMs'], const Duration(minutes: 30).inMilliseconds); - expect(map['initialDelayMs'], const Duration(minutes: 10).inMilliseconds); + expect(map['initialDelayMs'], + const Duration(minutes: 10).inMilliseconds); expect(map['runImmediately'], isFalse); }); });