diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3c27d..7c28b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 2.2.0 (2026-08-17) + +## Added + +- Added the language-neutral Runtime Delivery Specification 1.0.0, shared JSON + schema, 11 deterministic fixtures, and a machine-readable Flutter report. +- Added injectable `EventQueue`, `InMemoryEventQueue`, and IO-only atomic + `FileEventQueue` implementations. +- Added offline dispatch, durable event snapshots, selective per-tracker retry, + `queuedTrackerIds`, `QueueFlushResult`, and queued-event diagnostics. + +## Changed + +- Tracker destinations are attempted concurrently while result order remains + deterministic and failures stay isolated. +- `flush()` now replays the offline queue before flushing tracker-owned buffers. +- Concurrent flush calls are serialized to prevent duplicate delivery. +- Retry uses the stored processed event and destinations without rerunning + transformers, routing, sampling, or already-successful destinations. + # 2.1.0 (2026-08-17) ## Added diff --git a/README.md b/README.md index 79751ea..6154f97 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ One call site, multiple tracker destinations, centralized policy. - [FlexTrackClient and dependency injection](#flextrackclient-and-dependency-injection) - [Riverpod](#riverpod) - [Bloc / Cubit](#bloc--cubit) + - [Offline delivery and selective retry](#offline-delivery-and-selective-retry) - [Design philosophy](#design-philosophy) - [Creating events](#creating-events) - [Event flags](#event-flags) @@ -113,7 +114,7 @@ One call site, multiple tracker destinations, centralized policy. ```yaml # pubspec.yaml dependencies: - flex_track: ^2.1.0 + flex_track: ^2.2.0 ``` **Step 2 — implement your tracker** (the package ships no vendor SDKs; you write a thin adapter): @@ -305,6 +306,37 @@ class CheckoutCubit extends Cubit { For **strict** clean architecture, wrap `FlexTrackClient` behind your own `Analytics` interface in the domain module and implement the adapter in infrastructure. +## Offline delivery and selective retry + +Provide an application-owned queue file and connectivity signal when creating +the client. The file queue uses atomic replacement and restores the processed +event with its original ID and UTC timestamp after an app restart. + +```dart +var online = true; +final client = await FlexTrackClient.create( + [FirebaseTracker(), InternalApiTracker()], + queue: FileEventQueue(File('/app-private/flextrack-queue.json')), + onlineProvider: () => online, +); + +final result = await client.track(PurchaseEvent()); +print(result.queuedTrackerIds); // only destinations still awaiting delivery + +final flush = await client.flush(limit: 100); +print(flush.remainingEvents); +``` + +When offline, no tracker is called and every routed destination is queued. When +one tracker fails, successful trackers are never retried. Flush operations are +FIFO, bounded, and serialized; retries use the stored processed event without +rerunning transformers, routing, consent, or sampling. `FileEventQueue` is +available on `dart:io` platforms. Web clients should inject a platform-specific +`EventQueue` or use `InMemoryEventQueue`. + +The normative cross-SDK behavior is documented in +[Runtime Delivery Specification 1.0.0](doc/runtime-delivery-specification.md). + More detail: [doc/flex-track-client.md](doc/flex-track-client.md). --- diff --git a/doc/runtime-delivery-specification.md b/doc/runtime-delivery-specification.md new file mode 100644 index 0000000..e68ccec --- /dev/null +++ b/doc/runtime-delivery-specification.md @@ -0,0 +1,126 @@ +# FlexTrack Runtime Delivery Specification 1.0.0 + +Status: normative, language-neutral contract for Flutter, Kotlin, Swift, and +TypeScript implementations. RFC 2119 terms MUST, SHOULD, and MAY are normative. + +## 1. Scope + +Core Specification 1.0 decides which destinations should receive an event. +Runtime Delivery 1.0 defines tracker lifecycle, dispatch attempts, durable +queuing, replay, concurrency, failure isolation, and observable results. It does +not define vendor payload formats, network reachability detection, encryption at +rest, exponential backoff scheduling, or server acknowledgement protocols. + +## 2. Terms and identity + +- An event occurrence is identified by its non-empty `eventId`. +- A destination is identified by a non-empty tracker ID. +- A queued item is identified by the event occurrence ID and contains an + ordered, duplicate-free list of destinations still awaiting delivery. +- An attempt is one invocation of one tracker for one event occurrence. +- A dispatch is the initial attempt set produced by one `track` operation. +- A flush is one bounded FIFO replay pass over queued items. + +Event identity and timestamp MUST survive transformation, queue serialization, +process restart, and every retry. A retry MUST NOT create a new occurrence. + +## 3. Lifecycle + +Tracker initialization MUST be idempotent per client lifecycle. A client MUST +attempt every registered tracker even if another tracker fails initialization, +and MUST expose initialization failure. Initialization is transactional: after +any failure the client remains uninitialized, successfully initialized peers +are disposed, and a later initialize call MUST attempt every tracker again. +Tracking before successful client +initialization is SDK-defined for Core 1 compatibility; new APIs SHOULD reject +it. Disposal MUST be idempotent, flush tracker-owned buffers, and release +client-owned streams/resources. Queue persistence MUST survive disposal. + +## 4. Initial dispatch + +The runtime MUST transform and route an event exactly once. When online, all +resolved destinations MUST be attempted independently. One synchronous or +asynchronous tracker failure MUST NOT cancel or prevent other attempts. Results +MUST preserve routing destination order even when attempts execute concurrently. + +When offline, no tracker may be invoked. If routing produced destinations, the +processed event and every destination MUST be queued. An event rejected before +routing completion (disabled processor, consent, sampling, or no target) MUST +NOT be queued. + +## 5. Partial success and queue admission + +After an online dispatch, only unsuccessful destinations MUST be queued. +Successful destinations MUST never be included in that queued item. Queue +admission is idempotent by event ID: enqueueing an already-present occurrence +MUST NOT duplicate or reorder it. Callers MUST use unique event IDs for distinct +occurrences. + +## 6. Queue model + +The queue MUST provide FIFO read, enqueue, replace, remove, size, and clear. +Reads MUST require a positive limit and MUST NOT mutate state. Replace and remove +of an absent ID MUST be no-ops. Returned collections MUST not permit callers to +mutate queue state. A durable implementation MUST serialize all mutating +operations and use atomic file replacement or equivalent transactional storage. + +Queued properties MUST be JSON-compatible: null, boolean, finite number, +string, list, and string-keyed map composed recursively. Unsupported values MUST +fail queue persistence visibly; they MUST NOT be stringified or silently lost. +Malformed persisted data MUST raise a corruption/format error and MUST NOT be +silently discarded or partially replayed. + +## 7. Flush and selective retry + +A flush MUST process at most `limit` queued items in FIFO order. A non-positive +limit MUST fail before reading or mutating the queue. An offline flush MUST be a +no-op with zero attempted and delivered events. + +Queued events MUST NOT be transformed or routed again. The stored processed +event and pending destinations are authoritative. Every pending destination is +attempted independently. If all succeed, remove the item. Otherwise replace it +in the same FIFO position with only failed destination IDs and increment +`attempts` exactly once for that flush pass. Successful destinations MUST NOT be +retried by a later flush. + +Items added after a flush begins are outside that pass. Concurrent queue +mutations MUST be serialized. Concurrent flush calls MUST be serialized so one +occurrence/destination cannot be attempted twice concurrently. + +## 8. Results + +Initial dispatch results MUST expose the processed event, routing result, +ordered successful and failed per-tracker outcomes, and queued destination IDs. +Overall success means at least one tracker succeeded; queued-only is not success. + +Flush results MUST expose: + +- `attemptedEvents`: queued items selected at the beginning of the pass; +- `deliveredEvents`: selected items removed because every pending destination + succeeded; +- `remainingEvents`: total queue size after the pass. + +## 9. Error and cancellation semantics + +Tracker errors are data and MUST be isolated into per-tracker results. Runtime, +programmer, queue corruption, serialization, and cancellation errors MUST remain +errors; they MUST NOT be mislabeled as tracker failures. Cancellation MUST stop +the caller's operation and MUST NOT enqueue an artificial failure. SDKs without +structured cancellation MUST document their closest equivalent. + +## 10. Privacy + +Core consent is evaluated before initial queue admission. Runtime 1.0 preserves +that routing decision during replay to guarantee deterministic delivery and to +avoid policy drift. Applications requiring revocation to purge queued data MUST +call queue `clear`; SDKs SHOULD provide a higher-level purge helper. Runtime 1.1 +will define consent-revocation retention policy explicitly. + +## 11. Conformance + +Implementations claiming Runtime 1.0 MUST pass `runtime_mvp_cases.json`, validate +its envelope against `runtime_mvp.schema.json`, and publish a machine-readable +report containing implementation, spec version, fixture version, pass/fail +counts, and case IDs. Platform-specific persistence tests MUST additionally +cover restart recovery, atomic replacement, malformed JSON, invalid event +shape, concurrent mutations, and unsupported property values. diff --git a/example/README.md b/example/README.md index 7e7fed5..37db4b8 100644 --- a/example/README.md +++ b/example/README.md @@ -1,6 +1,6 @@ # flex_track flagship example -This app demonstrates **routing**, **consent**, **multiple trackers** (implemented as **mocks** in this repo—no real Firebase/Mixpanel/Amplitude keys required), and **widget wrappers** (`FlexClickTrack`, `FlexMountTrack`, `FlexTrackRouteObserver` + `FlexTrackRouteViewMixin` on the home shell). +This app demonstrates **routing**, **consent**, **multiple trackers** (implemented as **mocks** in this repo—no real Firebase/Mixpanel/Amplitude keys required), **offline delivery with selective retry**, and **widget wrappers** (`FlexClickTrack`, `FlexMountTrack`, `FlexTrackRouteObserver` + `FlexTrackRouteViewMixin` on the home shell). ## Run locally @@ -16,6 +16,15 @@ On first launch you will see a **privacy / consent** dialog (backed by an in-mem In **debug** mode on mobile or desktop, the app starts the **FlexTrack Inspector** (local HTTP dashboard). Watch the console for `FlexTrack Inspector (open in browser): http://127.0.0.1:7788` and open that URL to see live events and tracker state. (Not available on Flutter Web.) +Open the **Delivery** tab to force the demo offline, queue events, simulate one +failing destination, and flush again. The screen and Inspector both show which +destinations succeeded, which were retained for retry, and the pending count. +The queue is stored under the platform application-support directory. Pending +events survive a full process restart and are replayed automatically on the +next online launch. The Lab's simulated network state is also persisted: an +app closed while Offline starts Offline and retains its queue until you switch +it Online and flush. Consent choices use the platform SharedPreferences store. + ## Integration test Integration tests must target **one** device. If several devices are available (e.g. Linux desktop and Chrome), pick one explicitly: diff --git a/example/integration_test/flex_track_smoke_test.dart b/example/integration_test/flex_track_smoke_test.dart index cff36d5..5f6eb0f 100644 --- a/example/integration_test/flex_track_smoke_test.dart +++ b/example/integration_test/flex_track_smoke_test.dart @@ -1,16 +1,51 @@ +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; import 'package:flex_track_example/main.dart' as app; +import 'package:flex_track_example/runtime/offline_delivery_demo.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('flagship example starts after consent flow', (tester) async { + SharedPreferences.setMockInitialValues({ + 'flex_track_demo_network_available': true, + }); + final supportDirectory = await getApplicationSupportDirectory(); + final queueFile = + File('${supportDirectory.path}/flex_track/event_queue.json'); + if (await queueFile.exists()) await queueFile.delete(); + final persistedQueue = FileEventQueue(queueFile); + final restoredEvent = OfflineDeliveryDemoEvent(sequence: 0); + await persistedQueue.enqueue(QueuedEvent( + event: restoredEvent, + trackerIds: const [ + 'demo_delivery_success', + 'demo_delivery_retry', + ], + )); + await app.main(); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(await FlexTrack.queuedEventCount, 0, + reason: 'the event persisted before startup must be replayed'); + expect( + OfflineDeliveryDemo.instance.successTracker.deliveredEventIds, + contains(restoredEvent.eventId), + ); + expect( + OfflineDeliveryDemo.instance.retryTracker.deliveredEventIds, + contains(restoredEvent.eventId), + ); + await tester.tap(find.text('Accept All')); await tester.pumpAndSettle(); @@ -19,5 +54,28 @@ void main() { expect(find.text('FlexTrack demo'), findsOneWidget); expect(find.textContaining('Widget wrappers'), findsOneWidget); + + await tester.tap(find.text('Delivery')); + await tester.pumpAndSettle(); + expect(find.text('Offline Delivery Lab'), findsOneWidget); + + await tester.tap(find.byKey(const Key('network-toggle'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('track-delivery-event'))); + await tester.pumpAndSettle(); + expect(find.text('Pending events: 1'), findsOneWidget); + + await tester.tap(find.byKey(const Key('network-toggle'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('flush-delivery-queue'))); + await tester.pumpAndSettle(); + expect(find.text('Pending events: 0'), findsOneWidget); + await tester.scrollUntilVisible( + find.byKey(const Key('flush-result')), + 200, + scrollable: find.byType(Scrollable), + ); + expect(find.textContaining('1 attempted · 1 delivered · 0 remaining'), + findsOneWidget); }); } 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 681a9d0..7d69230 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -100,8 +100,6 @@ PODS: - GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - integration_test (0.0.1): - - Flutter - Mixpanel-swift (4.3.0): - Mixpanel-swift/Complete (= 4.3.0) - Mixpanel-swift/Complete (4.3.0) @@ -114,18 +112,13 @@ PODS: - nanopb/decode (2.30910.0) - nanopb/encode (2.30910.0) - PromisesObjC (2.4.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS DEPENDENCIES: - amplitude_flutter (from `.symlinks/plugins/amplitude_flutter/darwin`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - Flutter (from `Flutter`) - - integration_test (from `.symlinks/plugins/integration_test/ios`) - mixpanel_flutter (from `.symlinks/plugins/mixpanel_flutter/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) SPEC REPOS: trunk: @@ -152,12 +145,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_core/ios" Flutter: :path: Flutter - integration_test: - :path: ".symlinks/plugins/integration_test/ios" mixpanel_flutter: :path: ".symlinks/plugins/mixpanel_flutter/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: amplitude_flutter: fd9bf76a1885fe760055877777da18ca4d087088 @@ -174,12 +163,10 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleAppMeasurement: 9abf64b682732fed36da827aa2a68f0221fd2356 GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 Mixpanel-swift: 2192b9a24cf41b870749e4e0e10fbc9822bb2b4a mixpanel_flutter: 950da37cf4deb5e346c3f568de41e6d06a4067ad nanopb: 438bc412db1928dac798aa6fd75726007be04262 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 9e040ad..b91471c 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; EFEDDCE1595DDB2F63E3D061 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8ED52A12575922D0A394C21D /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +66,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; F49B1FD76CA1DE6DAAF940FA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; F518F9F194CBD609D7E0989F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,6 +74,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 01220E6154BA0B2E522EECAE /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -121,6 +124,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -188,6 +192,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -213,6 +220,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -726,6 +736,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index f13e5d2..e63b794 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/main.dart b/example/lib/main.dart index ec7315a..673a354 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -15,6 +15,7 @@ Future main() async { await AnalyticsSetup.initialize(); await GDPRManager.initialize(); + await AnalyticsSetup.flushRecoveredEvents(); runApp(const MyApp()); } diff --git a/example/lib/runtime/offline_delivery_demo.dart b/example/lib/runtime/offline_delivery_demo.dart new file mode 100644 index 0000000..f9721cc --- /dev/null +++ b/example/lib/runtime/offline_delivery_demo.dart @@ -0,0 +1,126 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Mutable, deterministic network and tracker failure controls used only by +/// the example application's Offline Delivery Lab. +class OfflineDeliveryDemo extends ChangeNotifier { + OfflineDeliveryDemo(); + + static final OfflineDeliveryDemo instance = OfflineDeliveryDemo(); + static const _networkAvailableKey = 'flex_track_demo_network_available'; + + bool _isOnline = true; + bool _retryTrackerFails = false; + int _queueSize = 0; + EventProcessingResult? _lastDispatch; + QueueFlushResult? _lastFlush; + SharedPreferences? _preferences; + + final DemoDeliveryTracker successTracker = DemoDeliveryTracker( + id: 'demo_delivery_success', + name: 'Demo reliable destination', + ); + late final DemoDeliveryTracker retryTracker = DemoDeliveryTracker( + id: 'demo_delivery_retry', + name: 'Demo retry destination', + shouldFail: () => _retryTrackerFails, + ); + + bool get isOnline => _isOnline; + bool get retryTrackerFails => _retryTrackerFails; + int get queueSize => _queueSize; + EventProcessingResult? get lastDispatch => _lastDispatch; + QueueFlushResult? get lastFlush => _lastFlush; + + bool get onlineProvider => _isOnline; + + /// Restores the simulated connectivity before FlexTrack starts. This makes + /// process-restart behavior deterministic for the Delivery Lab. + Future initialize() async { + _preferences ??= await SharedPreferences.getInstance(); + _isOnline = _preferences!.getBool(_networkAvailableKey) ?? true; + } + + Future setOnline(bool value) async { + if (_isOnline == value) return; + _isOnline = value; + notifyListeners(); + _preferences ??= await SharedPreferences.getInstance(); + await _preferences!.setBool(_networkAvailableKey, value); + } + + void setRetryTrackerFails(bool value) { + if (_retryTrackerFails == value) return; + _retryTrackerFails = value; + notifyListeners(); + } + + Future track() async { + _lastDispatch = await FlexTrack.track( + OfflineDeliveryDemoEvent( + sequence: successTracker.attemptCount + retryTracker.attemptCount + 1, + ), + ); + await refreshQueueSize(); + return _lastDispatch!; + } + + Future flush() async { + _lastFlush = await FlexTrack.flush(); + await refreshQueueSize(); + return _lastFlush!; + } + + Future refreshQueueSize() async { + _queueSize = await FlexTrack.queuedEventCount; + notifyListeners(); + } +} + +class OfflineDeliveryDemoEvent extends BaseEvent { + OfflineDeliveryDemoEvent({required this.sequence}); + + final int sequence; + + @override + String get name => 'demo_offline_delivery'; + + @override + Map get properties => { + 'sequence': sequence, + 'source': 'offline_delivery_lab', + }; + + @override + bool get requiresConsent => false; +} + +class DemoDeliveryTracker extends BaseTrackerStrategy { + DemoDeliveryTracker({ + required super.id, + required super.name, + this.shouldFail, + }); + + final bool Function()? shouldFail; + int attemptCount = 0; + int successCount = 0; + final List deliveredEventIds = []; + + @override + Future doInitialize() async {} + + @override + Future doTrack(BaseEvent event) async { + // These trackers are registered globally so broad demo rules can resolve + // to them. Keep Lab metrics and intentional failures scoped to its event. + if (event.name != 'demo_offline_delivery') return; + attemptCount++; + if (shouldFail?.call() ?? false) { + throw StateError('Intentional demo failure from $id'); + } + successCount++; + deliveredEventIds.add(event.eventId); + } +} diff --git a/example/lib/screens/home_screen.dart b/example/lib/screens/home_screen.dart index d8d30f0..9b9e18b 100644 --- a/example/lib/screens/home_screen.dart +++ b/example/lib/screens/home_screen.dart @@ -9,6 +9,7 @@ import '../events/user_events.dart'; import '../utils/gdpr_manager.dart'; import 'ecommerce_screen.dart'; import 'event_enrichment_screen.dart'; +import 'offline_delivery_screen.dart'; import 'setting_screen.dart'; import 'user_journey_screen.dart'; @@ -37,6 +38,7 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { const UserJourneyScreen(), const SettingsScreen(), const EventEnrichmentScreen(), + const OfflineDeliveryScreen(), ]; @override @@ -105,6 +107,10 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { icon: Icon(Icons.auto_awesome), label: 'Enrichment', ), + BottomNavigationBarItem( + icon: Icon(Icons.cloud_queue), + label: 'Delivery', + ), ], ), ); @@ -122,6 +128,8 @@ class HomeScreenState extends State with FlexTrackRouteViewMixin { return 'Settings'; case 4: return 'Enrichment'; + case 5: + return 'Delivery'; default: return 'Unknown'; } diff --git a/example/lib/screens/offline_delivery_screen.dart b/example/lib/screens/offline_delivery_screen.dart new file mode 100644 index 0000000..a7d48da --- /dev/null +++ b/example/lib/screens/offline_delivery_screen.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; + +import '../runtime/offline_delivery_demo.dart'; + +class OfflineDeliveryScreen extends StatefulWidget { + const OfflineDeliveryScreen({ + super.key, + this.demo, + }); + + final OfflineDeliveryDemo? demo; + + @override + State createState() => _OfflineDeliveryScreenState(); +} + +class _OfflineDeliveryScreenState extends State { + late final OfflineDeliveryDemo _demo = + widget.demo ?? OfflineDeliveryDemo.instance; + bool _busy = false; + + @override + void initState() { + super.initState(); + _demo.addListener(_refresh); + _demo.refreshQueueSize(); + } + + @override + void dispose() { + _demo.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + Future _run(Future Function() action) async { + setState(() => _busy = true); + try { + await action(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final dispatch = _demo.lastDispatch; + final delivered = dispatch?.trackingResults + .where((result) => result.successful) + .map((result) => result.trackerId) + .toList() ?? + const []; + final failed = dispatch?.trackingResults + .where((result) => !result.successful) + .map((result) => result.trackerId) + .toList() ?? + const []; + + return ListView( + key: const Key('offline-delivery-lab'), + padding: const EdgeInsets.all(16), + children: [ + Text('Offline Delivery Lab', + style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + const Text( + 'Control connectivity and one failing destination, then inspect ' + 'queueing and selective retry here or in FlexTrack Inspector.', + ), + const SizedBox(height: 16), + Card( + child: Column( + children: [ + SwitchListTile( + key: const Key('network-toggle'), + title: const Text('Network available'), + subtitle: Text(_demo.isOnline ? 'Online' : 'Offline'), + value: _demo.isOnline, + onChanged: _busy + ? null + : (value) => _run(() => _demo.setOnline(value)), + ), + SwitchListTile( + key: const Key('failure-toggle'), + title: const Text('Retry destination succeeds'), + subtitle: Text(_demo.retryTrackerFails + ? 'Intentional failure enabled' + : 'Healthy'), + value: !_demo.retryTrackerFails, + onChanged: _busy + ? null + : (value) => _demo.setRetryTrackerFails(!value), + ), + ], + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Pending events: ${_demo.queueSize}', + key: const Key('queue-count'), + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text('Reliable destination: ' + '${_demo.successTracker.successCount} delivered / ' + '${_demo.successTracker.attemptCount} attempts'), + Text('Retry destination: ' + '${_demo.retryTracker.successCount} delivered / ' + '${_demo.retryTracker.attemptCount} attempts'), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + key: const Key('track-delivery-event'), + onPressed: _busy + ? null + : () => _run(() async { + await _demo.track(); + }), + icon: const Icon(Icons.send), + label: const Text('Track event'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + key: const Key('flush-delivery-queue'), + onPressed: _busy + ? null + : () => _run(() async { + await _demo.flush(); + }), + icon: const Icon(Icons.sync), + label: const Text('Flush queue'), + ), + ), + ], + ), + if (_busy) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + if (dispatch != null) ...[ + const SizedBox(height: 16), + _ResultCard( + title: 'Last dispatch', + lines: { + 'Delivered': delivered, + 'Failed': failed, + 'Queued': dispatch.queuedTrackerIds, + }, + ), + ], + if (_demo.lastFlush case final flush?) ...[ + const SizedBox(height: 12), + Card( + key: const Key('flush-result'), + child: ListTile( + title: const Text('Last flush'), + subtitle: Text( + '${flush.attemptedEvents} attempted · ' + '${flush.deliveredEvents} delivered · ' + '${flush.remainingEvents} remaining', + ), + ), + ), + ], + ], + ); + } +} + +class _ResultCard extends StatelessWidget { + const _ResultCard({required this.title, required this.lines}); + + final String title; + final Map> lines; + + @override + Widget build(BuildContext context) { + return Card( + key: const Key('dispatch-result'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final entry in lines.entries) + Text('${entry.key}: ' + '${entry.value.isEmpty ? 'none' : entry.value.join(', ')}'), + ], + ), + ), + ); + } +} diff --git a/example/lib/utils/analytics_setup.dart b/example/lib/utils/analytics_setup.dart index d7835f0..0ef56a7 100644 --- a/example/lib/utils/analytics_setup.dart +++ b/example/lib/utils/analytics_setup.dart @@ -1,21 +1,33 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flex_track/flex_track.dart'; import 'package:flex_track/flex_track_inspector.dart'; +import 'package:path_provider/path_provider.dart'; import '../trackers/firebase_tracker.dart'; import '../trackers/mixpanel_tracker.dart'; import '../trackers/amplitude_tracker.dart'; import '../trackers/custom_api_tracker.dart'; import '../events/app_events.dart'; +import '../runtime/offline_delivery_demo.dart'; class AnalyticsSetup { static Future initialize() async { // Create trackers final trackers = await _createTrackers(); + final deliveryDemo = OfflineDeliveryDemo.instance; + await deliveryDemo.initialize(); + final supportDirectory = await getApplicationSupportDirectory(); + final queue = FileEventQueue( + File('${supportDirectory.path}/flex_track/event_queue.json'), + ); // Set up FlexTrack with advanced routing await FlexTrack.setupWithRouting(trackers, (builder) { return _configureRouting(builder); - }); + }, queue: queue, onlineProvider: () => deliveryDemo.onlineProvider); + + await deliveryDemo.refreshQueueSize(); // Track app startup await FlexTrack.track(AppStartEvent()); @@ -28,6 +40,15 @@ class AnalyticsSetup { } } + /// Replays events restored from disk after consent has been loaded. + /// If the demo is still offline, [FlexTrack.flush] is a safe no-op and the + /// durable queue remains intact for a later manual or startup retry. + static Future flushRecoveredEvents() async { + final result = await FlexTrack.flush(); + await OfflineDeliveryDemo.instance.refreshQueueSize(); + return result; + } + static Future> _createTrackers() async { final trackers = []; @@ -37,6 +58,9 @@ class AnalyticsSetup { showTimestamps: true, colorOutput: true, )); + trackers + ..add(OfflineDeliveryDemo.instance.successTracker) + ..add(OfflineDeliveryDemo.instance.retryTracker); // Add production trackers based on environment if (!kDebugMode) { @@ -86,6 +110,15 @@ class AnalyticsSetup { // Apply GDPR defaults first (highest priority) GDPRDefaults.apply(builder, compliantTrackers: ['firebase', 'custom_api']); + builder + .routeExact('demo_offline_delivery') + .to(['demo_delivery_success', 'demo_delivery_retry']) + .skipConsent() + .noSampling() + .withPriority(100) + .withDescription('Offline Delivery Lab destinations') + .and(); + // Example: same app, different destinations — explicit tracker lists builder .routeExact('demo_free_tier_only') diff --git a/example/lib/utils/gdpr_manager.dart b/example/lib/utils/gdpr_manager.dart index eb387d7..101e6e5 100644 --- a/example/lib/utils/gdpr_manager.dart +++ b/example/lib/utils/gdpr_manager.dart @@ -1,33 +1,18 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flex_track/flex_track.dart'; - -/// Mock SharedPreferences since we don't want to add dependencies -class MockSharedPreferences { - static final Map _storage = {}; - - String? getString(String key) => _storage[key]; - - Future setString(String key, String value) async { - _storage[key] = value; - return true; - } - - static Future getInstance() async { - return MockSharedPreferences(); - } -} +import 'package:shared_preferences/shared_preferences.dart'; class GDPRManager { static const String _consentKey = 'gdpr_consent'; static const String _consentVersionKey = 'gdpr_consent_version'; static const String _currentConsentVersion = '2.0'; - static late MockSharedPreferences _prefs; + static late SharedPreferences _prefs; static ConsentStatus? _currentConsent; static Future initialize() async { - _prefs = await MockSharedPreferences.getInstance(); + _prefs = await SharedPreferences.getInstance(); await _loadConsent(); _applyConsentToFlexTrack(); } diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/example/macos/Podfile b/example/macos/Podfile index 29c8eb3..ff5ddb3 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock new file mode 100644 index 0000000..44e16ee --- /dev/null +++ b/example/macos/Podfile.lock @@ -0,0 +1,161 @@ +PODS: + - amplitude_flutter (0.0.1): + - AmplitudeSwift (~> 1.11) + - Flutter + - FlutterMacOS + - AmplitudeCore (1.4.6) + - AmplitudeSwift (1.18.5): + - AmplitudeCore (< 2.0.0, >= 1.4.6) + - AnalyticsConnector (~> 1.3.0) + - AnalyticsConnector (1.3.1) + - Firebase/Analytics (10.25.0): + - Firebase/Core + - Firebase/Core (10.25.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 10.25.0) + - Firebase/CoreOnly (10.25.0): + - FirebaseCore (= 10.25.0) + - firebase_analytics (10.10.7): + - Firebase/Analytics (= 10.25.0) + - firebase_core + - FlutterMacOS + - firebase_core (2.32.0): + - Firebase/CoreOnly (~> 10.25.0) + - FlutterMacOS + - FirebaseAnalytics (10.25.0): + - FirebaseAnalytics/AdIdSupport (= 10.25.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.25.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseCore (10.25.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.12) + - GoogleUtilities/Logger (~> 7.12) + - FirebaseCoreInternal (10.29.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.29.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - FlutterMacOS (1.0.0) + - GoogleAppMeasurement (10.25.0): + - GoogleAppMeasurement/AdIdSupport (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.25.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.25.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleUtilities/AppDelegateSwizzler (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (7.13.3): + - GoogleUtilities/Privacy + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.3): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.13.3)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - nanopb (2.30910.0): + - nanopb/decode (= 2.30910.0) + - nanopb/encode (= 2.30910.0) + - nanopb/decode (2.30910.0) + - nanopb/encode (2.30910.0) + - PromisesObjC (2.4.1) + +DEPENDENCIES: + - amplitude_flutter (from `Flutter/ephemeral/.symlinks/plugins/amplitude_flutter/darwin`) + - firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + +SPEC REPOS: + trunk: + - AmplitudeCore + - AmplitudeSwift + - AnalyticsConnector + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - GoogleAppMeasurement + - GoogleUtilities + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + amplitude_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/amplitude_flutter/darwin + firebase_analytics: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + amplitude_flutter: fd9bf76a1885fe760055877777da18ca4d087088 + AmplitudeCore: e384984131ec515b92396801ab65b53e8fd73fa7 + AmplitudeSwift: 5a51527b615163c0dbb7bfeb5cb8ced2e7c1a906 + AnalyticsConnector: 3def11199b4ddcad7202c778bde982ec5da0ebb3 + Firebase: 0312a2352584f782ea56f66d91606891d4607f06 + firebase_analytics: 47e1f6453c222417b323ca53b1793f1a9ef395f6 + firebase_core: b5b8b60dad71f93132bbaa21e8d1379367d824f0 + FirebaseAnalytics: ec00fe8b93b41dc6fe4a28784b8e51da0647a248 + FirebaseCore: 7ec4d0484817f12c3373955bc87762d96842d483 + FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 + FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + GoogleAppMeasurement: 9abf64b682732fed36da827aa2a68f0221fd2356 + GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + nanopb: 438bc412db1928dac798aa6fd75726007be04262 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index a282ca7..505207f 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,15 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 2E0B6255401500F469C320DF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A8504EB24CA57C17CA6E7B3 /* Pods_Runner.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 743B8247EA1394F746368817 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79A50F70AA12D19D61366B1C /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +63,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 28D3F100515D27A763601B4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* flex_track_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "flex_track_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* flex_track_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flex_track_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +80,16 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 37572643629FBA2AD4320515 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 37C5B34FA1F4F56D9DDC1A79 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 6617E97E59646DB7B441D92A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 79A50F70AA12D19D61366B1C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9A8504EB24CA57C17CA6E7B3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D4B6747C3157B51DCB057A55 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + EADBFED99CE867507CBBE26C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +97,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 743B8247EA1394F746368817 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +105,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 2E0B6255401500F469C320DF /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +140,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + E01E037B6B8CE3648AD33D78 /* Pods */, ); sourceTree = ""; }; @@ -151,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -175,10 +192,26 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 9A8504EB24CA57C17CA6E7B3 /* Pods_Runner.framework */, + 79A50F70AA12D19D61366B1C /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; }; + E01E037B6B8CE3648AD33D78 /* Pods */ = { + isa = PBXGroup; + children = ( + D4B6747C3157B51DCB057A55 /* Pods-Runner.debug.xcconfig */, + 37C5B34FA1F4F56D9DDC1A79 /* Pods-Runner.release.xcconfig */, + 37572643629FBA2AD4320515 /* Pods-Runner.profile.xcconfig */, + EADBFED99CE867507CBBE26C /* Pods-RunnerTests.debug.xcconfig */, + 28D3F100515D27A763601B4D /* Pods-RunnerTests.release.xcconfig */, + 6617E97E59646DB7B441D92A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -186,6 +219,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 99F61EE377A06D393C727B2E /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +238,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 1B9B4EC8E3458C543F910C00 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 9A49F2C0A1FD0482EA46EA16 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -216,6 +252,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* flex_track_example.app */; productType = "com.apple.product-type.application"; @@ -260,6 +299,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -291,6 +333,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1B9B4EC8E3458C543F910C00 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +393,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 99F61EE377A06D393C727B2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9A49F2C0A1FD0482EA46EA16 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +483,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = EADBFED99CE867507CBBE26C /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +498,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 28D3F100515D27A763601B4D /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +513,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 6617E97E59646DB7B441D92A /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -461,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -700,6 +806,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 2ed52d4..1a833b2 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + + + diff --git a/example/pubspec.lock b/example/pubspec.lock index 817f11f..d22bfb3 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -49,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -151,7 +167,7 @@ packages: path: ".." relative: true source: path - version: "1.0.1" + version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -185,6 +201,14 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http_methods: dependency: transitive description: @@ -206,6 +230,30 @@ packages: description: flutter source: sdk version: "0.0.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: @@ -246,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -278,6 +334,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -286,6 +358,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -334,6 +430,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -539,6 +651,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.4" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index db3279b..c03b3fc 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: mixpanel_flutter: ^2.3.4 cupertino_icons: ^1.0.2 shared_preferences: ^2.5.3 + path_provider: ^2.1.5 amplitude_flutter: ^4.3.1 dev_dependencies: @@ -29,4 +30,4 @@ dev_dependencies: sdk: flutter flutter: - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/example/test/offline_delivery_screen_test.dart b/example/test/offline_delivery_screen_test.dart new file mode 100644 index 0000000..c494a3c --- /dev/null +++ b/example/test/offline_delivery_screen_test.dart @@ -0,0 +1,103 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flex_track_example/runtime/offline_delivery_demo.dart'; +import 'package:flex_track_example/screens/offline_delivery_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late OfflineDeliveryDemo demo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await FlexTrack.reset(); + demo = OfflineDeliveryDemo(); + await demo.initialize(); + await FlexTrack.setupWithRouting( + [demo.successTracker, demo.retryTracker], + (builder) => builder + .routeExact('demo_offline_delivery') + .to(['demo_delivery_success', 'demo_delivery_retry']) + .skipConsent() + .noSampling() + .withPriority(100) + .and(), + onlineProvider: () => demo.onlineProvider, + ); + }); + + tearDown(() => FlexTrack.reset()); + + Future pumpLab(WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: OfflineDeliveryScreen(demo: demo)), + )); + await tester.pumpAndSettle(); + } + + test('restores simulated network state across controller instances', + () async { + await demo.setOnline(false); + + final restored = OfflineDeliveryDemo(); + await restored.initialize(); + + expect(restored.isOnline, isFalse); + restored.dispose(); + }); + + testWidgets('visualizes offline enqueue and successful flush', + (tester) async { + await demo.setOnline(false); + await pumpLab(tester); + + await tester.tap(find.byKey(const Key('track-delivery-event'))); + await tester.pumpAndSettle(); + + expect(find.text('Pending events: 1'), findsOneWidget); + expect( + find.text('Queued: demo_delivery_success, demo_delivery_retry'), + findsOneWidget, + ); + expect(demo.successTracker.attemptCount, 0); + expect(demo.retryTracker.attemptCount, 0); + + await demo.setOnline(true); + await tester.runAsync(demo.flush); + await tester.pump(); + + expect(find.text('Pending events: 0'), findsOneWidget); + await tester.scrollUntilVisible( + find.byKey(const Key('flush-result')), + 200, + scrollable: find.byType(Scrollable), + ); + expect(find.textContaining('1 attempted · 1 delivered · 0 remaining'), + findsOneWidget); + expect(demo.successTracker.successCount, 1); + expect(demo.retryTracker.successCount, 1); + }); + + testWidgets('visualizes partial failure and selective retry', (tester) async { + demo.setRetryTrackerFails(true); + await pumpLab(tester); + + await tester.tap(find.byKey(const Key('track-delivery-event'))); + await tester.pumpAndSettle(); + + expect(find.text('Pending events: 1'), findsOneWidget); + expect(find.text('Delivered: demo_delivery_success'), findsOneWidget); + expect(find.text('Failed: demo_delivery_retry'), findsOneWidget); + expect(find.text('Queued: demo_delivery_retry'), findsOneWidget); + expect(demo.successTracker.attemptCount, 1); + + demo.setRetryTrackerFails(false); + await tester.runAsync(demo.flush); + await tester.pump(); + + expect(demo.successTracker.attemptCount, 1, + reason: 'successful destinations must never be retried'); + expect(demo.retryTracker.attemptCount, 2); + expect(find.text('Pending events: 0'), findsOneWidget); + }); +} diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index fa8a39b..b854f96 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/lib/flex_track.dart b/lib/flex_track.dart index 59edf8c..8310881 100644 --- a/lib/flex_track.dart +++ b/lib/flex_track.dart @@ -53,8 +53,11 @@ export 'src/core/event_dispatch_record.dart'; export 'src/core/flex_track.dart'; export 'src/core/flex_track_client.dart' show FlexTrackClient; export 'src/core/event_processor.dart' - show EventProcessingResult, TrackingResult; + show EventProcessingResult, TrackingResult, QueueFlushResult; export 'src/core/tracker_registry.dart' show TrackerRegistry; +export 'src/runtime/event_queue.dart' + show EventQueue, InMemoryEventQueue, QueuedEvent, QueuedEventSnapshot; +export 'src/runtime/file_event_queue.dart' show FileEventQueue; // ============= EVENT MODELS ============= @@ -143,7 +146,7 @@ export 'src/core/flex_track.dart' show FlexTrack; // ============= VERSION INFO ============= /// FlexTrack package version -const String flexTrackVersion = '2.1.0'; +const String flexTrackVersion = '2.2.0'; /// FlexTrack package description const String flexTrackDescription = diff --git a/lib/src/core/event_dispatch_record.dart b/lib/src/core/event_dispatch_record.dart index 5c6b544..7a1a05c 100644 --- a/lib/src/core/event_dispatch_record.dart +++ b/lib/src/core/event_dispatch_record.dart @@ -9,6 +9,8 @@ class EventDispatchRecord { required this.event, this.targetTrackers = const [], this.successfulTrackerIds = const [], + this.queuedTrackerIds = const [], + this.queueSize = 0, }); final BaseEvent event; @@ -18,4 +20,10 @@ class EventDispatchRecord { /// Subset of [targetTrackers] where `doTrack` completed successfully. final List successfulTrackerIds; + + /// Subset of [targetTrackers] retained for a later delivery attempt. + final List queuedTrackerIds; + + /// Total number of events in the runtime queue after this dispatch. + final int queueSize; } diff --git a/lib/src/core/event_processor.dart b/lib/src/core/event_processor.dart index 58191fb..6c0f043 100644 --- a/lib/src/core/event_processor.dart +++ b/lib/src/core/event_processor.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:flex_track/src/models/event/base_event.dart'; import 'package:flex_track/src/models/event/event_transformer.dart'; import 'package:flutter/foundation.dart'; import '../routing/routing_engine.dart'; import '../exceptions/tracker_exception.dart'; +import '../runtime/event_queue.dart'; import 'tracker_registry.dart'; /// Processes events through the routing system and sends them to appropriate trackers @@ -11,6 +14,9 @@ class EventProcessor { final TrackerRegistry _trackerRegistry; final RoutingEngine _routingEngine; final List _transformers = []; + final EventQueue _queue; + final bool Function() _onlineProvider; + Future _flushTail = Future.value(); bool _hasGeneralConsent = false; bool _hasPIIConsent = false; @@ -19,8 +25,14 @@ class EventProcessor { EventProcessor({ required TrackerRegistry trackerRegistry, required RoutingEngine routingEngine, + EventQueue? queue, + bool Function()? onlineProvider, }) : _trackerRegistry = trackerRegistry, - _routingEngine = routingEngine; + _routingEngine = routingEngine, + _queue = queue ?? InMemoryEventQueue(), + _onlineProvider = onlineProvider ?? _alwaysOnline; + + EventQueue get queue => _queue; /// Get the routing engine (for debugging) RoutingEngine get routingEngine => _routingEngine; @@ -28,6 +40,9 @@ class EventProcessor { /// Whether the processor is enabled bool get isEnabled => _isEnabled; + /// Current connectivity decision supplied by the host application. + bool get isOnline => _onlineProvider(); + /// Current general consent status bool get hasGeneralConsent => _hasGeneralConsent; @@ -130,70 +145,134 @@ class EventProcessor { ); } - // Send event to target trackers - final trackingResults = []; - bool anySuccessful = false; + if (!_onlineProvider()) { + await _queue.enqueue(QueuedEvent( + event: processedEvent, + trackerIds: routingResult.targetTrackers, + )); + return EventProcessingResult( + event: processedEvent, + routingResult: routingResult, + trackingResults: const [], + successful: false, + queuedTrackerIds: routingResult.targetTrackers, + ); + } - for (final trackerId in routingResult.targetTrackers) { - final tracker = _trackerRegistry.get(trackerId); + final trackingResults = await Future.wait( + routingResult.targetTrackers.map( + (trackerId) => _deliver(processedEvent, trackerId), + ), + ); + final failedTrackerIds = [ + for (final result in trackingResults) + if (!result.successful) result.trackerId, + ]; + if (failedTrackerIds.isNotEmpty) { + await _queue.enqueue(QueuedEvent( + event: processedEvent, + trackerIds: failedTrackerIds, + )); + } - if (tracker == null) { - trackingResults.add(TrackingResult( + return EventProcessingResult( + event: processedEvent, + routingResult: routingResult, + trackingResults: trackingResults, + successful: trackingResults.any((result) => result.successful), + queuedTrackerIds: failedTrackerIds, + ); + } + + Future _deliver(BaseEvent event, String trackerId) async { + final tracker = _trackerRegistry.get(trackerId); + + if (tracker == null) { + return TrackingResult( + trackerId: trackerId, + successful: false, + error: TrackerException( + 'Tracker not found: $trackerId', trackerId: trackerId, - successful: false, - error: TrackerException( - 'Tracker not found: $trackerId', - trackerId: trackerId, - eventName: processedEvent.name, - code: 'NOT_FOUND', - ), - )); - continue; - } + eventName: event.name, + code: 'NOT_FOUND', + ), + ); + } - if (!tracker.isEnabled) { - trackingResults.add(TrackingResult( + if (!tracker.isEnabled) { + return TrackingResult( + trackerId: trackerId, + successful: false, + error: TrackerException( + 'Tracker is disabled: $trackerId', trackerId: trackerId, - successful: false, - error: TrackerException( - 'Tracker is disabled: $trackerId', - trackerId: trackerId, - eventName: processedEvent.name, - code: 'DISABLED', - ), - )); - continue; - } + eventName: event.name, + code: 'DISABLED', + ), + ); + } + + try { + await tracker.track(event); + return TrackingResult( + trackerId: trackerId, + successful: true, + ); + } catch (e) { + return TrackingResult( + trackerId: trackerId, + successful: false, + error: e is TrackerException + ? e + : TrackerException( + 'Failed to track event: $e', + trackerId: trackerId, + eventName: event.name, + originalError: e, + ), + ); + } + } + Future flushQueue({int limit = 100}) async { + if (limit <= 0) throw ArgumentError.value(limit, 'limit'); + final completer = Completer(); + _flushTail = _flushTail.then((_) async { try { - await tracker.track(processedEvent); - trackingResults.add(TrackingResult( - trackerId: trackerId, - successful: true, - )); - anySuccessful = true; - } catch (e) { - trackingResults.add(TrackingResult( - trackerId: trackerId, - successful: false, - error: e is TrackerException - ? e - : TrackerException( - 'Failed to track event: $e', - trackerId: trackerId, - eventName: processedEvent.name, - originalError: e, - ), + completer.complete(await _flushQueuePass(limit)); + } catch (error, stack) { + completer.completeError(error, stack); + } + }); + return completer.future; + } + + Future _flushQueuePass(int limit) async { + if (!_onlineProvider()) { + return QueueFlushResult(0, 0, await _queue.size()); + } + final items = await _queue.read(limit: limit); + var delivered = 0; + for (final item in items) { + final results = await Future.wait( + item.trackerIds.map((id) => _deliver(item.event, id)), + ); + final failures = [ + for (final result in results) + if (!result.successful) result.trackerId, + ]; + if (failures.isEmpty) { + await _queue.remove(item.id); + delivered++; + } else { + await _queue.replace(item.copyWith( + trackerIds: failures, + attempts: item.attempts + 1, )); } } - - return EventProcessingResult( - event: processedEvent, - routingResult: routingResult, - trackingResults: trackingResults, - successful: anySuccessful, - ); + return QueueFlushResult(items.length, delivered, await _queue.size()); } /// Process multiple events as a batch @@ -250,12 +329,14 @@ class EventProcessingResult { final RoutingResult routingResult; final List trackingResults; final bool successful; + final List queuedTrackerIds; const EventProcessingResult({ required this.event, required this.routingResult, required this.trackingResults, required this.successful, + this.queuedTrackerIds = const [], }); /// Returns true if the event was routed to at least one tracker @@ -291,6 +372,7 @@ class EventProcessingResult { 'successfulTrackingCount': successfulTrackingCount, 'failedTrackingCount': failedTrackingCount, 'hasErrors': trackingErrors.isNotEmpty, + 'queuedTrackerIds': queuedTrackerIds, }; } @@ -305,6 +387,20 @@ class EventProcessingResult { } } +class QueueFlushResult { + const QueueFlushResult( + this.attemptedEvents, + this.deliveredEvents, + this.remainingEvents, + ); + + final int attemptedEvents; + final int deliveredEvents; + final int remainingEvents; +} + +bool _alwaysOnline() => true; + /// Result of tracking an event with a specific tracker class TrackingResult { final String trackerId; diff --git a/lib/src/core/flex_track.dart b/lib/src/core/flex_track.dart index d0fa1e6..58f33b8 100644 --- a/lib/src/core/flex_track.dart +++ b/lib/src/core/flex_track.dart @@ -6,6 +6,7 @@ import '../models/routing/routing_config.dart'; import '../routing/routing_builder.dart'; import '../routing/routing_engine.dart' show RoutingDebugInfo; import '../strategies/tracker_strategy.dart'; +import '../runtime/event_queue.dart'; import '../exceptions/configuration_exception.dart'; import 'event_dispatch_record.dart'; import 'flex_track_client.dart'; @@ -81,6 +82,8 @@ class FlexTrack { List trackers, { RoutingConfiguration? routing, bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { if (_instance != null) { throw ConfigurationException( @@ -93,6 +96,8 @@ class FlexTrack { trackers, routing: routing, autoInitialize: autoInitialize, + queue: queue, + onlineProvider: onlineProvider, ); _instance = FlexTrack._(client); return _instance!; @@ -103,11 +108,15 @@ class FlexTrack { List trackers, RoutingBuilder Function(RoutingBuilder) configureRouting, { bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { return setup( trackers, routing: _routingFromBuilder(configureRouting), autoInitialize: autoInitialize, + queue: queue, + onlineProvider: onlineProvider, ); } @@ -231,9 +240,10 @@ class FlexTrack { } /// Flush all pending events - static Future flush() async { - await instance._client.flush(); - } + static Future flush({int limit = 100}) => + instance._client.flush(limit: limit); + + static Future get queuedEventCount => instance._client.queuedEventCount; // ========== TRANSFORMERS ========== diff --git a/lib/src/core/flex_track_client.dart b/lib/src/core/flex_track_client.dart index 0131997..c367de4 100644 --- a/lib/src/core/flex_track_client.dart +++ b/lib/src/core/flex_track_client.dart @@ -9,6 +9,7 @@ import '../models/routing/routing_config.dart'; import '../routing/routing_builder.dart'; import '../routing/routing_engine.dart'; import '../strategies/tracker_strategy.dart'; +import '../runtime/event_queue.dart'; import 'event_dispatch_record.dart'; import 'event_processor.dart'; import 'tracker_registry.dart'; @@ -27,6 +28,7 @@ class FlexTrackClient { final TrackerRegistry _trackerRegistry; final EventProcessor _eventProcessor; bool _isInitialized = false; + bool _isDisposed = false; final StreamController _dispatchStreamController = StreamController.broadcast(sync: true); @@ -63,6 +65,8 @@ class FlexTrackClient { List trackers, { RoutingConfiguration? routing, bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { if (trackers.isEmpty) { throw ConfigurationException( @@ -77,6 +81,8 @@ class FlexTrackClient { final eventProcessor = EventProcessor( trackerRegistry: trackerRegistry, routingEngine: routingEngine, + queue: queue, + onlineProvider: onlineProvider, ); final client = FlexTrackClient._( @@ -95,6 +101,8 @@ class FlexTrackClient { List trackers, RoutingBuilder Function(RoutingBuilder) configureRouting, { bool autoInitialize = true, + EventQueue? queue, + bool Function()? onlineProvider, }) async { final routingBuilder = RoutingBuilder(); final configuredBuilder = configureRouting(routingBuilder); @@ -104,6 +112,8 @@ class FlexTrackClient { trackers, routing: routingConfig, autoInitialize: autoInitialize, + queue: queue, + onlineProvider: onlineProvider, ); } @@ -127,7 +137,8 @@ class FlexTrackClient { Future track(BaseEvent event) async { final result = await _eventProcessor.processEvent(event); - _emitDispatchIfDebug(_recordFromResult(result)); + _emitDispatchIfDebug(await _recordFromResult(result)); + _notifyDebugStateIfDebug(); return result; } @@ -135,9 +146,10 @@ class FlexTrackClient { final results = await _eventProcessor.processEvents(events); if (kDebugMode) { for (final result in results) { - _emitDispatchIfDebug(_recordFromResult(result)); + _emitDispatchIfDebug(await _recordFromResult(result)); } } + _notifyDebugStateIfDebug(); return results; } @@ -146,9 +158,10 @@ class FlexTrackClient { final results = await _eventProcessor.processEventsParallel(events); if (kDebugMode) { for (final result in results) { - _emitDispatchIfDebug(_recordFromResult(result)); + _emitDispatchIfDebug(await _recordFromResult(result)); } } + _notifyDebugStateIfDebug(); return results; } @@ -206,7 +219,24 @@ class FlexTrackClient { Future resetTrackers() => _trackerRegistry.reset(); - Future flush() => _trackerRegistry.flush(); + Future flush({int limit = 100}) async { + final result = await _eventProcessor.flushQueue(limit: limit); + // Tracker SDK flush methods may perform network I/O of their own. Keep a + // host-declared offline flush a complete no-op across both queue layers. + if (_eventProcessor.isOnline) { + await _trackerRegistry.flush(); + } + _notifyDebugStateIfDebug(); + return result; + } + + Future flushQueue({int limit = 100}) async { + final result = await _eventProcessor.flushQueue(limit: limit); + _notifyDebugStateIfDebug(); + return result; + } + + Future get queuedEventCount => _eventProcessor.queue.size(); void addTransformer(EventTransformer transformer) => _eventProcessor.addTransformer(transformer); @@ -259,7 +289,8 @@ class FlexTrackClient { } } - static EventDispatchRecord _recordFromResult(EventProcessingResult result) { + Future _recordFromResult( + EventProcessingResult result) async { final targets = List.from(result.routingResult.targetTrackers); final ok = [ for (final t in result.trackingResults) @@ -269,6 +300,8 @@ class FlexTrackClient { event: result.event, targetTrackers: targets, successfulTrackerIds: ok, + queuedTrackerIds: List.from(result.queuedTrackerIds), + queueSize: await _eventProcessor.queue.size(), ); } @@ -285,9 +318,10 @@ class FlexTrackClient { } Future dispose() async { - if (_isInitialized) { - await _trackerRegistry.flush(); - } + if (_isDisposed) return; + _isDisposed = true; + if (_isInitialized) await _trackerRegistry.dispose(); + _isInitialized = false; await _dispatchStreamController.close(); await _debugStateController.close(); } diff --git a/lib/src/core/tracker_registry.dart b/lib/src/core/tracker_registry.dart index 481ed8b..102aefc 100644 --- a/lib/src/core/tracker_registry.dart +++ b/lib/src/core/tracker_registry.dart @@ -8,6 +8,7 @@ class TrackerRegistry { final Map _trackers = {}; final Map _initializationStatus = {}; bool _isInitialized = false; + bool _isDisposed = false; /// Returns true if the registry has been initialized bool get isInitialized => _isInitialized; @@ -126,10 +127,18 @@ class TrackerRegistry { failures[trackerId] = e is Exception ? e : Exception(e.toString()); } } - _isInitialized = true; - // Report any failures if (failures.isNotEmpty) { + for (final entry in _trackers.entries) { + if (_initializationStatus[entry.key] ?? false) { + try { + await _disposeTracker(entry.value); + } catch (_) { + // Initialization failure remains the primary error. + } + _initializationStatus[entry.key] = false; + } + } final failureMessage = failures.entries.map((e) => '${e.key}: ${e.value}').join(', '); @@ -138,6 +147,7 @@ class TrackerRegistry { code: 'INITIALIZATION_FAILURES', ); } + _isInitialized = true; } /// Enable a tracker @@ -233,6 +243,20 @@ class TrackerRegistry { await Future.wait(futures); } + /// Disposes every initialized tracker once, without short-circuiting peers. + Future dispose() async { + if (_isDisposed) return; + _isDisposed = true; + final futures = >[]; + for (final entry in _trackers.entries) { + if (_initializationStatus[entry.key] ?? false) { + futures.add(_disposeTracker(entry.value)); + } + } + await Future.wait(futures, eagerError: false); + _isInitialized = false; + } + /// Track a single event on all enabled trackers Future track(BaseEvent event) async { final futures = []; @@ -340,3 +364,8 @@ class TrackerRegistry { return 'TrackerRegistry(${_trackers.length} trackers, initialized: $_isInitialized)'; } } + +Future _disposeTracker(TrackerStrategy tracker) => + tracker is DisposableTrackerStrategy + ? (tracker as DisposableTrackerStrategy).dispose() + : tracker.flush(); diff --git a/lib/src/inspector/dashboard.dart b/lib/src/inspector/dashboard.dart index baba7ce..1ff51ab 100644 --- a/lib/src/inspector/dashboard.dart +++ b/lib/src/inspector/dashboard.dart @@ -306,6 +306,7 @@ select { cursor: pointer; }

Trackers

+

Offline queue

Consent

Validation

@@ -359,9 +360,11 @@ select { cursor: pointer; } var tb = document.getElementById('trackers'); var cs = document.getElementById('consent'); var vl = document.getElementById('validation'); + var qu = document.getElementById('queue'); tb.innerHTML = ''; cs.innerHTML = ''; vl.innerHTML = ''; + qu.innerHTML = ''; if (!data || data.isSetUp === false) { tb.innerHTML = '
FlexTrack not set up
'; return; @@ -395,6 +398,7 @@ select { cursor: pointer; } applyTrackerRowHighlight(); var con = data.consent || {}; cs.innerHTML = '
general: ' + !!con.general + '
pii: ' + !!con.pii + '
'; + qu.innerHTML = '
pending events: ' + Number(data.queueSize || 0) + '
'; var issues = data.validation || []; if (!issues.length) vl.innerHTML = '
No issues
'; else issues.forEach(function(w){ var d=document.createElement('div'); d.className='warn'; d.textContent=w; vl.appendChild(d); }); @@ -470,7 +474,8 @@ select { cursor: pointer; } if (tid) { var targets = normIds(ev.targetTrackers); var ok = normIds(ev.successfulTrackerIds); - if (targets.indexOf(tid) < 0 && ok.indexOf(tid) < 0) return false; + var queued = normIds(ev.queuedTrackerIds); + if (targets.indexOf(tid) < 0 && ok.indexOf(tid) < 0 && queued.indexOf(tid) < 0) return false; } return true; } @@ -519,7 +524,10 @@ select { cursor: pointer; } '
Routed to (rule targets)
' + '
' + pillsHtml(ev.targetTrackers, '') + '
' + '
Delivered (track succeeded)
' + - '
' + pillsHtml(ev.successfulTrackerIds, 'ok') + '
' + + '
' + pillsHtml(ev.successfulTrackerIds, 'ok') + '
' + + '
Queued for retry
' + + '
' + pillsHtml(ev.queuedTrackerIds, '') + '
' + + '
Queue size after dispatch
' + Number(ev.queueSize || 0) + '
' + '

Properties

' + escapeHtml(JSON.stringify(ev.properties || {}, null, 2)) + '
'; applyTrackerRowHighlight(); renderFeed(); diff --git a/lib/src/inspector/flex_track_inspector_io.dart b/lib/src/inspector/flex_track_inspector_io.dart index 05cee53..7fb62da 100644 --- a/lib/src/inspector/flex_track_inspector_io.dart +++ b/lib/src/inspector/flex_track_inspector_io.dart @@ -156,8 +156,8 @@ class _FlexTrackInspectorServer { ); } - Response _handleStatus(Request request) { - final payload = _buildStatusPayload(); + Future _handleStatus(Request request) async { + final payload = await _buildStatusPayload(); return Response.ok( jsonEncode(payload), headers: {'content-type': 'application/json; charset=utf-8'}, @@ -183,9 +183,7 @@ class _FlexTrackInspectorServer { void _handleWebSocket(WebSocketChannel channel) { _channels.add(channel); - try { - channel.sink.add(jsonEncode(_statusMessageMap())); - } catch (_) {} + _sendInitialStatus(channel); channel.stream.listen( (_) {}, @@ -195,7 +193,13 @@ class _FlexTrackInspectorServer { ); } - Map _buildStatusPayload() { + Future _sendInitialStatus(WebSocketChannel channel) async { + try { + channel.sink.add(jsonEncode(await _statusMessageMap())); + } catch (_) {} + } + + Future> _buildStatusPayload() async { if (!FlexTrack.isSetUp) { return { 'isSetUp': false, @@ -203,6 +207,7 @@ class _FlexTrackInspectorServer { 'trackers': [], 'consent': {}, 'validation': ['FlexTrack is not set up'], + 'queueSize': 0, }; } @@ -221,16 +226,17 @@ class _FlexTrackInspectorServer { 'trackers': trackers, 'consent': FlexTrack.getConsentStatus(), 'validation': FlexTrack.validate(), + 'queueSize': await FlexTrack.queuedEventCount, }; } - Map _statusMessageMap() => { + Future> _statusMessageMap() async => { 'type': 'status', - 'data': _buildStatusPayload(), + 'data': await _buildStatusPayload(), }; - void _broadcastStatus() { - _broadcastWs(jsonEncode(_statusMessageMap())); + Future _broadcastStatus() async { + _broadcastWs(jsonEncode(await _statusMessageMap())); } void _broadcastWs(String message) { diff --git a/lib/src/inspector/inspector_event_buffer.dart b/lib/src/inspector/inspector_event_buffer.dart index f161ee7..0681566 100644 --- a/lib/src/inspector/inspector_event_buffer.dart +++ b/lib/src/inspector/inspector_event_buffer.dart @@ -41,6 +41,8 @@ class InspectorEventRecord { required this.flags, required this.targetTrackers, required this.successfulTrackerIds, + required this.queuedTrackerIds, + required this.queueSize, }); final String id; @@ -51,6 +53,8 @@ class InspectorEventRecord { final Map flags; final List targetTrackers; final List successfulTrackerIds; + final List queuedTrackerIds; + final int queueSize; factory InspectorEventRecord.fromDispatch({ required String id, @@ -71,6 +75,8 @@ class InspectorEventRecord { }, targetTrackers: List.from(dispatch.targetTrackers), successfulTrackerIds: List.from(dispatch.successfulTrackerIds), + queuedTrackerIds: List.from(dispatch.queuedTrackerIds), + queueSize: dispatch.queueSize, ); } @@ -84,6 +90,8 @@ class InspectorEventRecord { 'flags': flags, 'targetTrackers': targetTrackers, 'successfulTrackerIds': successfulTrackerIds, + 'queuedTrackerIds': queuedTrackerIds, + 'queueSize': queueSize, }; } diff --git a/lib/src/runtime/event_queue.dart b/lib/src/runtime/event_queue.dart new file mode 100644 index 0000000..7ea660a --- /dev/null +++ b/lib/src/runtime/event_queue.dart @@ -0,0 +1,163 @@ +import 'dart:collection'; + +import '../models/event/base_event.dart'; +import '../models/routing/event_category.dart'; + +/// A routed event waiting for delivery to one or more tracker destinations. +class QueuedEvent { + QueuedEvent({ + required this.event, + required Iterable trackerIds, + this.attempts = 0, + DateTime? queuedAt, + }) : trackerIds = List.unmodifiable(LinkedHashSet.of(trackerIds)), + queuedAt = (queuedAt ?? DateTime.now()).toUtc() { + if (this.trackerIds.isEmpty) { + throw ArgumentError.value(trackerIds, 'trackerIds', 'Cannot be empty'); + } + if (attempts < 0) { + throw ArgumentError.value(attempts, 'attempts', 'Cannot be negative'); + } + } + + final BaseEvent event; + final List trackerIds; + final int attempts; + final DateTime queuedAt; + + String get id => event.eventId; + + QueuedEvent copyWith({List? trackerIds, int? attempts}) => + QueuedEvent( + event: event, + trackerIds: trackerIds ?? this.trackerIds, + attempts: attempts ?? this.attempts, + queuedAt: queuedAt, + ); + + Map toJson() => { + 'id': id, + 'trackerIds': trackerIds, + 'attempts': attempts, + 'queuedAt': queuedAt.toIso8601String(), + 'event': event.toMap(), + }; + + factory QueuedEvent.fromJson(Map json) => QueuedEvent( + event: QueuedEventSnapshot.fromJson( + (json['event'] as Map).cast(), + ), + trackerIds: (json['trackerIds'] as List).cast(), + attempts: json['attempts'] as int, + queuedAt: DateTime.parse(json['queuedAt'] as String), + ); +} + +/// Storage boundary used by the delivery runtime. +abstract interface class EventQueue { + Future enqueue(QueuedEvent item); + Future> read({required int limit}); + Future replace(QueuedEvent item); + Future remove(String eventId); + Future size(); + Future clear(); +} + +/// FIFO, process-local queue intended for tests and non-durable clients. +class InMemoryEventQueue implements EventQueue { + final Map _items = {}; + + @override + Future enqueue(QueuedEvent item) async { + _items.putIfAbsent(item.id, () => item); + } + + @override + Future> read({required int limit}) async { + _checkLimit(limit); + return List.unmodifiable(_items.values.take(limit)); + } + + @override + Future replace(QueuedEvent item) async { + if (_items.containsKey(item.id)) _items[item.id] = item; + } + + @override + Future remove(String eventId) async => _items.remove(eventId); + + @override + Future size() async => _items.length; + + @override + Future clear() async => _items.clear(); +} + +/// Immutable event representation used for retry and process restoration. +class QueuedEventSnapshot extends BaseEvent { + QueuedEventSnapshot({ + required this.eventName, + required this.eventProperties, + required this.eventCategory, + required this.eventContainsPII, + required this.eventRequiresConsent, + required this.eventIsHighVolume, + required this.eventIsEssential, + required this.eventUserId, + required this.eventSessionId, + required super.eventId, + required super.timestamp, + }); + + factory QueuedEventSnapshot.fromJson(Map json) => + QueuedEventSnapshot( + eventName: json['name'] as String, + eventProperties: (json['properties'] as Map?)?.cast(), + eventCategory: json['category'] == null + ? null + : EventCategory(json['category'] as String), + eventContainsPII: json['containsPII'] as bool? ?? false, + eventRequiresConsent: json['requiresConsent'] as bool? ?? true, + eventIsHighVolume: json['isHighVolume'] as bool? ?? false, + eventIsEssential: json['isEssential'] as bool? ?? false, + eventUserId: json['userId'] as String?, + eventSessionId: json['sessionId'] as String?, + eventId: json['eventId'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + + final String eventName; + final Map? eventProperties; + final EventCategory? eventCategory; + final bool eventContainsPII; + final bool eventRequiresConsent; + final bool eventIsHighVolume; + final bool eventIsEssential; + final String? eventUserId; + final String? eventSessionId; + + @override + String get name => eventName; + @override + Map? get properties => eventProperties; + @override + EventCategory? get category => eventCategory; + @override + bool get containsPII => eventContainsPII; + @override + bool get requiresConsent => eventRequiresConsent; + @override + bool get isHighVolume => eventIsHighVolume; + @override + bool get isEssential => eventIsEssential; + @override + String? get userId => eventUserId; + @override + String? get sessionId => eventSessionId; +} + +void _checkLimit(int limit) { + if (limit <= 0) { + throw ArgumentError.value(limit, 'limit', 'Must be positive'); + } +} diff --git a/lib/src/runtime/event_queue_io.dart b/lib/src/runtime/event_queue_io.dart new file mode 100644 index 0000000..9d73896 --- /dev/null +++ b/lib/src/runtime/event_queue_io.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'event_queue.dart'; + +/// Durable FIFO queue using atomic replacement in an application-owned file. +class FileEventQueue implements EventQueue { + FileEventQueue(this.file); + + final File file; + Future _tail = Future.value(); + + Future _locked(Future Function() operation) { + final completer = Completer(); + _tail = _tail.then((_) async { + try { + completer.complete(await operation()); + } catch (error, stack) { + completer.completeError(error, stack); + } + }); + return completer.future; + } + + @override + Future enqueue(QueuedEvent item) => _mutate((items) { + if (!items.any((value) => value.id == item.id)) items.add(item); + }); + + @override + Future> read({required int limit}) => _locked(() async { + if (limit <= 0) throw ArgumentError.value(limit, 'limit'); + return List.unmodifiable((await _load()).take(limit)); + }); + + @override + Future replace(QueuedEvent item) => _mutate((items) { + final index = items.indexWhere((value) => value.id == item.id); + if (index >= 0) items[index] = item; + }); + + @override + Future remove(String eventId) => + _mutate((items) => items.removeWhere((value) => value.id == eventId)); + + @override + Future size() => _locked(() async => (await _load()).length); + + @override + Future clear() => _locked(() async { + if (await file.exists()) await file.delete(); + }); + + Future _mutate(void Function(List) change) => + _locked(() async { + final items = await _load(); + change(items); + await _persist(items); + }); + + Future> _load() async { + if (!await file.exists() || await file.length() == 0) return []; + final decoded = jsonDecode(await file.readAsString()); + if (decoded is! List) { + throw const FormatException('FlexTrack queue root must be a JSON array'); + } + return decoded + .map((value) => + QueuedEvent.fromJson((value as Map).cast())) + .toList(); + } + + Future _persist(List items) async { + await file.parent.create(recursive: true); + final temporary = File('${file.path}.tmp'); + await temporary.writeAsString( + jsonEncode(items.map((value) => value.toJson()).toList()), + flush: true, + ); + await temporary.rename(file.path); + } +} diff --git a/lib/src/runtime/event_queue_stub.dart b/lib/src/runtime/event_queue_stub.dart new file mode 100644 index 0000000..ff3b89b --- /dev/null +++ b/lib/src/runtime/event_queue_stub.dart @@ -0,0 +1,23 @@ +import 'event_queue.dart'; + +/// File persistence is unavailable on this platform. +class FileEventQueue implements EventQueue { + FileEventQueue(Object file) { + throw UnsupportedError('FileEventQueue requires dart:io'); + } + + Never _unsupported() => + throw UnsupportedError('FileEventQueue requires dart:io'); + @override + Future clear() => _unsupported(); + @override + Future enqueue(QueuedEvent item) => _unsupported(); + @override + Future> read({required int limit}) => _unsupported(); + @override + Future remove(String eventId) => _unsupported(); + @override + Future replace(QueuedEvent item) => _unsupported(); + @override + Future size() => _unsupported(); +} diff --git a/lib/src/runtime/file_event_queue.dart b/lib/src/runtime/file_event_queue.dart new file mode 100644 index 0000000..d817f14 --- /dev/null +++ b/lib/src/runtime/file_event_queue.dart @@ -0,0 +1 @@ +export 'event_queue_stub.dart' if (dart.library.io) 'event_queue_io.dart'; diff --git a/lib/src/strategies/tracker_strategy.dart b/lib/src/strategies/tracker_strategy.dart index 9f14329..8d2a33d 100644 --- a/lib/src/strategies/tracker_strategy.dart +++ b/lib/src/strategies/tracker_strategy.dart @@ -87,3 +87,8 @@ abstract class TrackerStrategy { }; } } + +/// Optional lifecycle capability for trackers that own disposable resources. +abstract interface class DisposableTrackerStrategy { + Future dispose(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 417c9ba..d4eaa09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flex_track description: A flexible analytics tracking system for Flutter with intelligent routing, GDPR compliance, and multi-platform support -version: 2.1.0 +version: 2.2.0 homepage: https://flextrack.taghizadeh.dev/ repository: https://github.com/alirezat66/flex_track issue_tracker: https://github.com/alirezat66/flex_track/issues diff --git a/test/contract/runtime_mvp_conformance_test.dart b/test/contract/runtime_mvp_conformance_test.dart new file mode 100644 index 0000000..caf432b --- /dev/null +++ b/test/contract/runtime_mvp_conformance_test.dart @@ -0,0 +1,212 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _casesPath = 'test/fixtures/conformance/runtime_mvp_cases.json'; +const _schemaPath = 'test/fixtures/conformance/runtime_mvp.schema.json'; +const _reportPath = 'test/fixtures/conformance/flutter_runtime_report.json'; + +void main() { + final fixture = _json(_casesPath); + final cases = (fixture['cases'] as List).cast>(); + + test('runtime fixture envelope and case identities are valid', () { + final schema = _json(_schemaPath); + expect(schema[r'$schema'], 'https://json-schema.org/draft/2020-12/schema'); + expect(fixture[r'$schema'], 'runtime_mvp.schema.json'); + expect(fixture['specVersion'], '1.0.0'); + expect(fixture['fixtureVersion'], matches(r'^1\.[0-9]+\.[0-9]+$')); + expect(cases.map((value) => value['id']).toSet().length, cases.length); + for (final value in cases) { + expect(value.keys.toSet(), {'id', 'behavior', 'input', 'expected'}); + expect(['offline', 'partialFailure', 'flush', 'queue', 'lifecycle'], + contains(value['behavior'])); + } + }); + + for (final value in cases) { + test('runtime conformance: ${value['id']}', () async { + expect(await _run(value), value['expected'], + reason: value['id'] as String); + }); + } + + test('runtime report covers every conformance case', () { + final report = _json(_reportPath); + expect(report['implementation'], 'flutter'); + expect(report['specVersion'], fixture['specVersion']); + expect(report['fixtureVersion'], fixture['fixtureVersion']); + expect(report['total'], cases.length); + expect(report['passed'], cases.length); + expect(report['failed'], 0); + expect(report['caseIds'], cases.map((value) => value['id']).toList()); + }); +} + +Future> _run(Map value) async { + final input = value['input'] as Map; + switch (value['behavior']) { + case 'offline': + final setup = await _client( + targets: (input['targets'] as List).cast(), + online: false, + ); + final result = await setup.client.track(_Event('event-1')); + return { + 'attempted': setup.trackers.expand((value) => value.events).toList(), + 'queued': result.queuedTrackerIds, + 'queueSize': await setup.queue.size(), + }; + case 'partialFailure': + final setup = await _client( + targets: (input['targets'] as List).cast(), + failing: (input['failing'] as List).cast().toSet(), + ); + final result = await setup.client.track(_Event('event-1')); + return { + 'successful': [ + for (final item in result.trackingResults) + if (item.successful) item.trackerId, + ], + 'queued': result.queuedTrackerIds, + 'queueSize': await setup.queue.size(), + }; + case 'flush': + final pending = (input['pending'] as List).cast(); + final online = input['online'] as bool; + final onlineState = _OnlineState(online); + final setup = await _client( + targets: pending, + failing: (input['failing'] as List).cast().toSet(), + onlineState: onlineState, + ); + await setup.queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: pending, + )); + final result = await setup.client.flushQueue(); + final remaining = await setup.queue.read(limit: 10); + return { + 'attemptedEvents': result.attemptedEvents, + 'deliveredEvents': result.deliveredEvents, + 'remainingEvents': result.remainingEvents, + 'pending': remaining.isEmpty ? [] : remaining.single.trackerIds, + if (remaining.isNotEmpty || !online) + 'attempts': remaining.isEmpty ? 0 : remaining.single.attempts, + }; + case 'queue': + return _queueCase(input); + case 'lifecycle': + final tracker = _Tracker('analytics'); + final client = + await FlexTrackClient.create([tracker], autoInitialize: false); + for (var i = 0; i < (input['initializeCalls'] as int); i++) { + await client.initialize(); + } + return {'trackerInitializeCalls': tracker.initializeCalls}; + default: + throw StateError('Unsupported behavior ${value['behavior']}'); + } +} + +Future> _queueCase(Map input) async { + final queue = InMemoryEventQueue(); + final ids = (input['eventIds'] as List).cast(); + for (final id in ids) { + await queue + .enqueue(QueuedEvent(event: _Event(id), trackerIds: const ['a'])); + } + if (input['operation'] == 'replace') { + final first = (await queue.read(limit: 10)).first; + await queue.replace(first.copyWith(attempts: 1)); + } + final values = await queue.read(limit: input['limit'] as int? ?? 10); + return { + 'eventIds': values.map((value) => value.id).toList(), + 'queueSize': await queue.size(), + if (input['operation'] == 'replace') + 'attempts': values.map((value) => value.attempts).toList(), + }; +} + +Future<_Setup> _client({ + required List targets, + Set failing = const {}, + bool online = true, + _OnlineState? onlineState, +}) async { + final queue = InMemoryEventQueue(); + final trackers = + targets.map((id) => _Tracker(id, failing: failing.contains(id))).toList(); + final config = RoutingConfiguration( + rules: targets.isEmpty + ? const [] + : [ + RoutingRule( + targetGroup: TrackerGroup('fixture', targets), + requireConsent: false, + ), + ], + ); + final state = onlineState ?? _OnlineState(online); + final client = await FlexTrackClient.create( + trackers.isEmpty ? [_Tracker('unused')] : trackers, + routing: config, + queue: queue, + onlineProvider: () => state.value, + ); + return _Setup(client, queue, trackers); +} + +Map _json(String path) => + (jsonDecode(File(path).readAsStringSync()) as Map).cast(); + +class _Event extends BaseEvent { + _Event(String id) : super(eventId: id, timestamp: DateTime.utc(2026, 8, 17)); + @override + String get name => 'purchase'; + @override + Map get properties => const {'plan': 'pro'}; + @override + bool get requiresConsent => false; +} + +class _Tracker extends TrackerStrategy { + _Tracker(this.id, {this.failing = false}); + @override + final String id; + final bool failing; + final List events = []; + int initializeCalls = 0; + bool _enabled = true; + @override + String get name => id; + @override + bool get isEnabled => _enabled; + @override + Future initialize() async => initializeCalls++; + @override + Future track(BaseEvent event) async { + events.add(id); + if (failing) throw StateError('failure'); + } + + @override + void enable() => _enabled = true; + @override + void disable() => _enabled = false; +} + +class _OnlineState { + _OnlineState(this.value); + bool value; +} + +class _Setup { + const _Setup(this.client, this.queue, this.trackers); + final FlexTrackClient client; + final InMemoryEventQueue queue; + final List<_Tracker> trackers; +} diff --git a/test/core/tracker_registry_test.dart b/test/core/tracker_registry_test.dart index 2afc051..d00c2d0 100644 --- a/test/core/tracker_registry_test.dart +++ b/test/core/tracker_registry_test.dart @@ -176,11 +176,9 @@ void main() { } catch (e) { print(e); } - expect(registry.isInitialized, - isTrue); // Registry is marked as initialized even with failures + expect(registry.isInitialized, isFalse); expect(registry.isTrackerInitialized('tracker1'), isFalse); - expect(registry.isTrackerInitialized('tracker2'), - isTrue); // Other trackers should still initialize + expect(registry.isTrackerInitialized('tracker2'), isFalse); }); test('should enable a tracker', () { diff --git a/test/fixtures/conformance/flutter_runtime_report.json b/test/fixtures/conformance/flutter_runtime_report.json new file mode 100644 index 0000000..e8bcec4 --- /dev/null +++ b/test/fixtures/conformance/flutter_runtime_report.json @@ -0,0 +1,21 @@ +{ + "implementation": "flutter", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "total": 11, + "passed": 11, + "failed": 0, + "caseIds": [ + "offline.queue-all-targets", + "offline.no-target-no-queue", + "delivery.partial-failure", + "delivery.all-success", + "flush.selective-retry-success", + "flush.retain-only-failures", + "flush.offline-noop", + "queue.duplicate-id-idempotent", + "queue.fifo-limit", + "queue.replace-preserves-position", + "lifecycle.initialize-idempotent" + ] +} diff --git a/test/fixtures/conformance/runtime_mvp.schema.json b/test/fixtures/conformance/runtime_mvp.schema.json new file mode 100644 index 0000000..a00ac28 --- /dev/null +++ b/test/fixtures/conformance/runtime_mvp.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flextrack.taghizadeh.dev/schemas/runtime-mvp-1.0.0.json", + "title": "FlexTrack Runtime MVP fixtures", + "type": "object", + "required": ["specVersion", "fixtureVersion", "cases"], + "additionalProperties": false, + "properties": { + "specVersion": {"const": "1.0.0"}, + "fixtureVersion": {"type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$"}, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "behavior", "input", "expected"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "behavior": { + "enum": ["offline", "partialFailure", "flush", "queue", "lifecycle"] + }, + "input": {"type": "object"}, + "expected": {"type": "object"} + } + } + } + } +} diff --git a/test/fixtures/conformance/runtime_mvp_cases.json b/test/fixtures/conformance/runtime_mvp_cases.json new file mode 100644 index 0000000..babcca2 --- /dev/null +++ b/test/fixtures/conformance/runtime_mvp_cases.json @@ -0,0 +1,73 @@ +{ + "$schema": "runtime_mvp.schema.json", + "specVersion": "1.0.0", + "fixtureVersion": "1.0.0", + "cases": [ + { + "id": "offline.queue-all-targets", + "behavior": "offline", + "input": {"targets": ["analytics", "archive"]}, + "expected": {"attempted": [], "queued": ["analytics", "archive"], "queueSize": 1} + }, + { + "id": "offline.no-target-no-queue", + "behavior": "offline", + "input": {"targets": []}, + "expected": {"attempted": [], "queued": [], "queueSize": 0} + }, + { + "id": "delivery.partial-failure", + "behavior": "partialFailure", + "input": {"targets": ["analytics", "archive"], "failing": ["archive"]}, + "expected": {"successful": ["analytics"], "queued": ["archive"], "queueSize": 1} + }, + { + "id": "delivery.all-success", + "behavior": "partialFailure", + "input": {"targets": ["analytics", "archive"], "failing": []}, + "expected": {"successful": ["analytics", "archive"], "queued": [], "queueSize": 0} + }, + { + "id": "flush.selective-retry-success", + "behavior": "flush", + "input": {"pending": ["archive"], "failing": [], "online": true}, + "expected": {"attemptedEvents": 1, "deliveredEvents": 1, "remainingEvents": 0, "pending": []} + }, + { + "id": "flush.retain-only-failures", + "behavior": "flush", + "input": {"pending": ["analytics", "archive"], "failing": ["archive"], "online": true}, + "expected": {"attemptedEvents": 1, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["archive"], "attempts": 1} + }, + { + "id": "flush.offline-noop", + "behavior": "flush", + "input": {"pending": ["analytics"], "failing": [], "online": false}, + "expected": {"attemptedEvents": 0, "deliveredEvents": 0, "remainingEvents": 1, "pending": ["analytics"], "attempts": 0} + }, + { + "id": "queue.duplicate-id-idempotent", + "behavior": "queue", + "input": {"operation": "duplicate", "eventIds": ["event-1", "event-1"]}, + "expected": {"eventIds": ["event-1"], "queueSize": 1} + }, + { + "id": "queue.fifo-limit", + "behavior": "queue", + "input": {"operation": "read", "eventIds": ["event-1", "event-2", "event-3"], "limit": 2}, + "expected": {"eventIds": ["event-1", "event-2"], "queueSize": 3} + }, + { + "id": "queue.replace-preserves-position", + "behavior": "queue", + "input": {"operation": "replace", "eventIds": ["event-1", "event-2"]}, + "expected": {"eventIds": ["event-1", "event-2"], "queueSize": 2, "attempts": [1, 0]} + }, + { + "id": "lifecycle.initialize-idempotent", + "behavior": "lifecycle", + "input": {"initializeCalls": 2}, + "expected": {"trackerInitializeCalls": 1} + } + ] +} diff --git a/test/inspector/inspector_event_buffer_test.dart b/test/inspector/inspector_event_buffer_test.dart index 8015254..1c7ca87 100644 --- a/test/inspector/inspector_event_buffer_test.dart +++ b/test/inspector/inspector_event_buffer_test.dart @@ -23,11 +23,19 @@ class _BufEvent extends BaseEvent { bool get isEssential => true; } -EventDispatchRecord _rec(BaseEvent e, {List? t, List? ok}) { +EventDispatchRecord _rec( + BaseEvent e, { + List? t, + List? ok, + List? queued, + int queueSize = 0, +}) { return EventDispatchRecord( event: e, targetTrackers: t ?? const [], successfulTrackerIds: ok ?? const [], + queuedTrackerIds: queued ?? const [], + queueSize: queueSize, ); } @@ -66,10 +74,30 @@ void main() { expect(p['category'], 'business'); expect(p['targetTrackers'], ['console', 'firebase']); expect(p['successfulTrackerIds'], ['console']); + expect(p['queuedTrackerIds'], isEmpty); + expect(p['queueSize'], 0); expect(p['flags'], { 'essential': true, 'highVolume': false, 'containsPII': false, }); }); + + test('toEventPayload includes offline queue fields', () { + final buf = InspectorEventBuffer(); + final rec = buf.append( + _rec( + _BufEvent('offline'), + t: const ['reliable', 'retry'], + ok: const ['reliable'], + queued: const ['retry'], + queueSize: 3, + ), + 'uuid', + '12:00:00.001', + ); + + expect(rec.toEventPayload()['queuedTrackerIds'], ['retry']); + expect(rec.toEventPayload()['queueSize'], 3); + }); } diff --git a/test/runtime/event_delivery_runtime_test.dart b/test/runtime/event_delivery_runtime_test.dart new file mode 100644 index 0000000..80b55b3 --- /dev/null +++ b/test/runtime/event_delivery_runtime_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('retry never reruns transformers or redelivers successful targets', + () async { + var transforms = 0; + final success = _Tracker('success'); + final retry = _Tracker('retry', failCount: 1); + final client = await _client([success, retry]); + client.addTransformer((event) { + transforms++; + return EnrichedEvent(event, const {'transform': 'once'}); + }); + + final first = await client.track(_Event('event-1')); + expect(first.queuedTrackerIds, ['retry']); + await client.flushQueue(); + + expect(transforms, 1); + expect(success.events, hasLength(1)); + expect(retry.events, hasLength(2)); + expect(retry.events.last.properties, containsPair('transform', 'once')); + expect(await client.queuedEventCount, 0); + }); + + test('concurrent flush calls cannot duplicate a queued delivery', () async { + final gate = Completer(); + final tracker = _Tracker('analytics', gate: gate.future); + final queue = InMemoryEventQueue(); + final client = await _client([tracker], queue: queue); + await queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: const ['analytics'], + )); + + final first = client.flushQueue(); + final second = client.flushQueue(); + await Future.delayed(Duration.zero); + expect(tracker.events, hasLength(1)); + gate.complete(); + + expect((await first).deliveredEvents, 1); + expect((await second).attemptedEvents, 0); + expect(tracker.events, hasLength(1)); + }); + + test('offline flush does not deliver queue or flush tracker SDKs', () async { + final tracker = _Tracker('analytics'); + final queue = InMemoryEventQueue(); + await queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: const ['analytics'], + )); + final client = await _client( + [tracker], + queue: queue, + onlineProvider: () => false, + ); + + final result = await client.flush(); + + expect(result.attemptedEvents, 0); + expect(result.deliveredEvents, 0); + expect(result.remainingEvents, 1); + expect(tracker.events, isEmpty); + expect(tracker.flushCount, 0); + expect(await client.queuedEventCount, 1); + }); + + test('queue read result cannot mutate in-memory queue state', () async { + final queue = InMemoryEventQueue(); + await queue.enqueue(QueuedEvent( + event: _Event('event-1'), + trackerIds: const ['analytics'], + )); + final result = await queue.read(limit: 10); + + expect(() => result.clear(), throwsUnsupportedError); + expect(() => result.single.trackerIds.clear(), throwsUnsupportedError); + expect(await queue.size(), 1); + }); + + test('serialization failure never replaces a valid durable queue', () async { + final directory = + await Directory.systemTemp.createTemp('flextrack-atomic-'); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/queue.json'); + final queue = FileEventQueue(file); + await queue.enqueue(QueuedEvent( + event: _Event('valid'), + trackerIds: const ['analytics'], + )); + final originalBytes = await file.readAsBytes(); + + await expectLater( + queue.enqueue(QueuedEvent( + event: _UnsupportedEvent('invalid'), + trackerIds: const ['analytics'], + )), + throwsA(anything), + ); + expect(await file.readAsBytes(), originalBytes); + expect((await queue.read(limit: 10)).single.id, 'valid'); + }); +} + +Future _client( + List<_Tracker> trackers, { + EventQueue? queue, + bool Function()? onlineProvider, +}) => + FlexTrackClient.create( + trackers, + queue: queue, + onlineProvider: onlineProvider, + routing: RoutingConfiguration( + rules: [ + RoutingRule( + targetGroup: TrackerGroup( + 'all', + trackers.map((value) => value.id).toList(), + ), + requireConsent: false, + ), + ], + ), + ); + +class _Event extends BaseEvent { + _Event(String id) : super(eventId: id, timestamp: DateTime.utc(2026, 8, 17)); + @override + String get name => 'purchase'; + @override + Map get properties => const {'plan': 'pro'}; + @override + bool get requiresConsent => false; +} + +class _UnsupportedEvent extends _Event { + _UnsupportedEvent(super.id); + @override + Map get properties => {'unsupported': Object()}; +} + +class _Tracker extends TrackerStrategy { + _Tracker(this.id, {this.failCount = 0, this.gate}); + @override + final String id; + int failCount; + final Future? gate; + final List events = []; + int flushCount = 0; + bool _enabled = true; + @override + String get name => id; + @override + bool get isEnabled => _enabled; + @override + Future initialize() async {} + @override + Future track(BaseEvent event) async { + events.add(event); + if (gate != null) await gate; + if (failCount > 0) { + failCount--; + throw StateError('failure'); + } + } + + @override + Future flush() async { + flushCount++; + } + + @override + void enable() => _enabled = true; + @override + void disable() => _enabled = false; +} diff --git a/test/runtime/file_event_queue_test.dart b/test/runtime/file_event_queue_test.dart new file mode 100644 index 0000000..ef42188 --- /dev/null +++ b/test/runtime/file_event_queue_test.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory directory; + late File file; + + setUp(() async { + directory = await Directory.systemTemp.createTemp('flextrack-queue-'); + file = File('${directory.path}/queue.json'); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + test('survives recreation and preserves event identity and metadata', + () async { + final original = _Event('stable-id'); + await FileEventQueue(file).enqueue( + QueuedEvent(event: original, trackerIds: const ['a', 'b']), + ); + + final restored = (await FileEventQueue(file).read(limit: 10)).single; + expect(restored.id, original.eventId); + expect(restored.event.timestamp, original.timestamp); + expect(restored.event.name, original.name); + expect(restored.event.properties, original.properties); + expect(restored.trackerIds, ['a', 'b']); + }); + + test('malformed JSON fails visibly without deleting persisted bytes', + () async { + await file.writeAsString('{broken'); + final queue = FileEventQueue(file); + + await expectLater(queue.read(limit: 10), throwsFormatException); + expect(await file.readAsString(), '{broken'); + }); + + test('invalid persisted shape fails visibly', () async { + await file.writeAsString('{}'); + await expectLater(FileEventQueue(file).size(), throwsFormatException); + }); + + test('concurrent enqueue operations are serialized without loss', () async { + final queue = FileEventQueue(file); + await Future.wait([ + for (var i = 0; i < 50; i++) + queue.enqueue(QueuedEvent( + event: _Event('event-$i'), + trackerIds: const ['analytics'], + )), + ]); + + expect(await queue.size(), 50); + expect( + (await queue.read(limit: 50)).map((value) => value.id), + [for (var i = 0; i < 50; i++) 'event-$i'], + ); + }); + + test('non-positive read limit does not mutate storage', () async { + final queue = FileEventQueue(file); + await queue + .enqueue(QueuedEvent(event: _Event('one'), trackerIds: const ['a'])); + await expectLater(queue.read(limit: 0), throwsArgumentError); + expect(await queue.size(), 1); + }); +} + +class _Event extends BaseEvent { + _Event(String id) + : super(eventId: id, timestamp: DateTime.utc(2026, 8, 17, 12, 30)); + @override + String get name => 'purchase'; + @override + Map get properties => const { + 'plan': 'pro', + 'nested': {'enabled': true}, + 'items': [1, 'two'], + }; + @override + bool get requiresConsent => false; +} diff --git a/test/runtime/tracker_lifecycle_runtime_test.dart b/test/runtime/tracker_lifecycle_runtime_test.dart new file mode 100644 index 0000000..33ecb7d --- /dev/null +++ b/test/runtime/tracker_lifecycle_runtime_test.dart @@ -0,0 +1,69 @@ +import 'package:flex_track/flex_track.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'partial initialization failure rolls back and retry attempts all trackers', + () async { + final healthy = _LifecycleTracker('healthy'); + final flaky = _LifecycleTracker('flaky', initializeFailures: 1); + final client = await FlexTrackClient.create( + [healthy, flaky], + autoInitialize: false, + ); + + await expectLater( + client.initialize(), throwsA(isA())); + expect(client.isInitialized, isFalse); + expect(healthy.initializeCalls, 1); + expect(healthy.disposeCalls, 1); + + await client.initialize(); + expect(client.isInitialized, isTrue); + expect(healthy.initializeCalls, 2); + expect(flaky.initializeCalls, 2); + }); + + test('client disposal releases initialized trackers exactly once', () async { + final tracker = _LifecycleTracker('tracker'); + final client = await FlexTrackClient.create([tracker]); + + await client.dispose(); + await client.dispose(); + + expect(tracker.disposeCalls, 1); + expect(client.isInitialized, isFalse); + }); +} + +class _LifecycleTracker extends TrackerStrategy + implements DisposableTrackerStrategy { + _LifecycleTracker(this.id, {this.initializeFailures = 0}); + @override + final String id; + int initializeFailures; + int initializeCalls = 0; + int disposeCalls = 0; + bool _enabled = true; + @override + String get name => id; + @override + bool get isEnabled => _enabled; + @override + Future initialize() async { + initializeCalls++; + if (initializeFailures > 0) { + initializeFailures--; + throw StateError('initialize failed'); + } + } + + @override + Future track(BaseEvent event) async {} + @override + Future dispose() async => disposeCalls++; + @override + void enable() => _enabled = true; + @override + void disable() => _enabled = false; +}