diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..60589eb --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,48 @@ +name: Publish to pub.dev + +# Two ways to trigger: +# +# 1. Automatic — push a tag: +# git tag v5.0.10 +# git push origin v5.0.10 +# +# 2. Manual via GitHub web UI — go to Actions → Publish to pub.dev → Run workflow +# IMPORTANT: select the tag (e.g. v5.0.10) from the "Use workflow from" dropdown, +# NOT a branch. pub.dev requires ref_type=tag for OIDC even on workflow_dispatch. +# Also requires "Allow workflow_dispatch" to be enabled on pub.dev package admin page. + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' # tag pattern on pub.dev: v{{version}} + workflow_dispatch: + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed lib/ + + - name: Analyze + run: flutter analyze --no-fatal-infos + + - name: Run tests + run: flutter test + + publish: + name: Publish + needs: test + permissions: + id-token: write # Required for OIDC authentication with pub.dev + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..9c8206f --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,38 @@ +name: Tag and Release + +# Run this first. It creates the git tag which then automatically +# triggers the "Publish to pub.dev" workflow. + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (must match pubspec.yaml, e.g. 5.0.10)' + required: true + +jobs: + tag: + name: Create tag + runs-on: ubuntu-latest + permissions: + contents: write # Required to push a tag + + steps: + - uses: actions/checkout@v4 + + - name: Validate version sync (pubspec.yaml vs input) + run: | + PUBSPEC_VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}') + INPUT_VERSION="${{ github.event.inputs.version }}" + if [ "$PUBSPEC_VERSION" != "$INPUT_VERSION" ]; then + echo "❌ Version mismatch: pubspec.yaml has $PUBSPEC_VERSION but input is $INPUT_VERSION" + exit 1 + fi + echo "✅ Version $PUBSPEC_VERSION confirmed" + + - name: Create and push tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v${{ github.event.inputs.version }}" + git push origin "v${{ github.event.inputs.version }}" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..b167650 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,64 @@ +name: Validate + +# Runs on every push to main and on pull requests. +# Verifies that the package is ready to publish without actually publishing. + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + name: Run tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed lib/ + + - name: Analyze + run: flutter analyze --no-fatal-infos + + - name: Run tests + run: flutter test + + publish-dry-run: + name: Publish dry run + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dart-lang/setup-dart@v1 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Validate version sync (pubspec.yaml vs source code) + run: | + PUBSPEC_VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}') + SOURCE_VERSION=$(grep 'flutterPluginVersion' lib/plugin_mappintelligence.dart | grep -oP '"\K[^"]+') + if [ "$PUBSPEC_VERSION" != "$SOURCE_VERSION" ]; then + echo "❌ Version mismatch: pubspec.yaml=$PUBSPEC_VERSION, plugin_mappintelligence.dart=$SOURCE_VERSION" + exit 1 + fi + echo "✅ Version $PUBSPEC_VERSION in sync" + + - name: Publish dry run + run: dart pub publish --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index e05d951..271bb54 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## 5.0.10 +- WebTrackingController: added optional `navigationDelegate` parameter allowing clients to provide their own NavigationDelegate; all callbacks (onPageStarted, onPageFinished, onProgress, onWebResourceError, onNavigationRequest) are preserved alongside the plugin's tracking logic +- WebTrackingController: client-provided onPageFinished now fires after EverID injection completes; onLoad is invoked only on successful page load; added null guard for malformed WebView messages - Updated Kotlin to 2.3.0 (plugin and example) - Updated Android Gradle Plugin to 8.13.2 and Gradle wrapper to 8.13 - Bumped compileSdkVersion and targetSdkVersion to 36 diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md new file mode 100644 index 0000000..37b0c7c --- /dev/null +++ b/INSTRUCTIONS.md @@ -0,0 +1,261 @@ +# Developer Instructions + +This document covers everything needed to develop, test, and publish the `plugin_mappintelligence` Flutter plugin. + +--- + +## Table of Contents + +1. [Project Setup](#1-project-setup) +2. [Running Tests](#2-running-tests) +3. [Running the Example App](#3-running-the-example-app) +4. [Making a Release](#4-making-a-release) +5. [Publishing to pub.dev — One-Time Setup](#5-publishing-to-pubdev--one-time-setup) +6. [Publishing a New Version](#6-publishing-a-new-version) +7. [Verifying the Publish Workflows Locally](#7-verifying-the-publish-workflows-locally) +8. [Troubleshooting](#8-troubleshooting) + +--- + +## 1. Project Setup + +**Requirements:** +- Flutter SDK (stable channel) — [install guide](https://docs.flutter.dev/get-started/install) +- Dart SDK (included with Flutter) +- Android Studio or Xcode for platform-specific development + +**Clone and install:** +```bash +git clone https://github.com/mapp-digital/Mapp-Intelligence-Flutter-Tracking.git +cd Mapp-Intelligence-Flutter-Tracking +flutter pub get +cd example && flutter pub get && cd .. +``` + +--- + +## 2. Running Tests + +**All unit tests:** +```bash +flutter test +``` + +**Specific test file:** +```bash +flutter test test/web_tracking_controller_test.dart +flutter test test/plugin_mappintelligence_test.dart +``` + +**With verbose output:** +```bash +flutter test --reporter expanded +``` + +**What the tests cover:** + +| File | Coverage | +|---|---| +| `test/plugin_mappintelligence_test.dart` | All public API methods, channel argument verification, version sync between `pubspec.yaml` and source code | +| `test/web_tracking_controller_test.dart` | NavigationDelegate forwarding, EverID injection ordering, onLoad success/failure behavior, JavaScript channel dispatch | + +**Version sync test** — one test specifically reads `pubspec.yaml` at runtime and asserts it matches the hardcoded version string in `lib/plugin_mappintelligence.dart`. This will fail if you bump one but forget the other: + +``` +flutter test --name "version sync" +``` + +--- + +## 3. Running the Example App + +```bash +cd example +flutter run +``` + +The example app demonstrates all tracking features: +- Page tracking, action tracking, campaign tracking +- Ecommerce and media tracking +- WebView session linking +- Exception tracking +- Form tracking + +--- + +## 4. Making a Release + +Follow these steps in order before publishing: + +### Step 1 — Update version in `pubspec.yaml` +```yaml +version: 5.0.11 +``` + +### Step 2 — Update version in `lib/plugin_mappintelligence.dart` +Find `_updateCustomParams()` and update the hardcoded string to match: +```dart +final flutterPluginVersion = "5.0.11"; +``` + +### Step 3 — Update `CHANGELOG.md` +Add a new section at the top: +```markdown +## 5.0.11 +- Description of changes +``` + +### Step 4 — Run the version sync test to confirm both versions match +```bash +flutter test --name "version sync" +``` + +### Step 5 — Run all tests +```bash +flutter test +``` + +### Step 6 — Run a publish dry run to catch any packaging issues +```bash +dart pub publish --dry-run +``` + +### Step 7 — Commit and push to `main` +```bash +git add -A +git commit -m "chore: release 5.0.11" +git push origin main +``` + +The `validate.yml` GitHub Actions workflow will automatically run all tests and a dry run on push to `main`. Wait for it to pass before proceeding. + +--- + +## 5. Publishing to pub.dev — One-Time Setup + +This only needs to be done once per package. It authorizes GitHub Actions to publish using OIDC (no credentials or tokens required). + +### Step 1 — Enable automated publishing on pub.dev + +1. Go to `https://pub.dev/packages/plugin_mappintelligence/admin` +2. Sign in with the account that owns the package +3. Scroll to **Automated publishing** +4. Click **Enable publishing from GitHub Actions** +5. Fill in: + - **Repository:** `mapp-digital/Mapp-Intelligence-Flutter-Tracking` + - **Tag pattern:** `v{{version}}` +6. Enable the **"Allow workflow_dispatch"** checkbox +7. Click **Save** + +### Step 2 — Verify the GitHub Actions workflows are present + +Confirm these three files exist in the repository: + +``` +.github/workflows/validate.yml ← runs on every push to main / PR +.github/workflows/tag-release.yml ← creates a git tag from the web UI +.github/workflows/publish.yml ← publishes to pub.dev when tag is pushed +``` + +No secrets or tokens need to be added to the repository. Authentication is handled automatically via OIDC. + +--- + +## 6. Publishing a New Version + +After completing [Section 4](#4-making-a-release) and [Section 5](#5-publishing-to-pubdev--one-time-setup): + +### Option A — Trigger from GitHub web UI (recommended) + +1. Go to the repository on GitHub +2. Click **Actions** tab +3. Select **Tag and Release** workflow from the left sidebar +4. Click **Run workflow** +5. Enter the version number (e.g. `5.0.11`) — must match `pubspec.yaml` exactly +6. Click **Run workflow** + +This will: +- Validate the version matches `pubspec.yaml` +- Create and push git tag `v5.0.11` +- Automatically trigger the **Publish to pub.dev** workflow +- Run all tests, then publish the package + +### Option B — Trigger manually from the terminal + +```bash +git tag v5.0.11 +git push origin v5.0.11 +``` + +This triggers the **Publish to pub.dev** workflow directly. + +### Monitor the publish + +Go to `https://github.com/mapp-digital/Mapp-Intelligence-Flutter-Tracking/actions` to watch the workflow run. A successful run means the package is live on pub.dev within a few minutes. + +--- + +## 7. Verifying the Publish Workflows Locally + +The actual OIDC publish step requires GitHub's infrastructure and cannot be fully replicated locally. However, every other step can be verified: + +### Verify formatting +```bash +dart format --output=none --set-exit-if-changed lib/ +``` + +### Verify static analysis +```bash +flutter analyze --no-fatal-infos +``` + +### Verify tests pass +```bash +flutter test +``` + +### Verify the package is publishable (dry run) +```bash +dart pub publish --dry-run +``` + +A successful dry run means the package structure, `pubspec.yaml`, and all required fields are valid. The only step that cannot be tested locally is the OIDC authentication handshake with pub.dev — this is verified by the one-time setup in Section 5. + +### Verify the tag-release workflow logic manually +```bash +# Simulate what tag-release.yml does: +PUBSPEC_VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}') +echo "pubspec.yaml version: $PUBSPEC_VERSION" +# Confirm the tag does not already exist: +git tag | grep "v$PUBSPEC_VERSION" +``` + +--- + +## 8. Troubleshooting + +### "Version mismatch" error in Tag and Release workflow +The version you entered does not match `pubspec.yaml`. Update `pubspec.yaml` and push to `main` before triggering the workflow. + +### "publishing is not allowed from workflow_dispatch events" +The **"Allow workflow_dispatch"** checkbox is not enabled on pub.dev. Go to `pub.dev/packages/plugin_mappintelligence/admin` and enable it. + +### "publishing is only allowed from tag refType" +The publish workflow was triggered on a branch ref, not a tag. This happens if you manually trigger `publish.yml` from a branch. Always use `tag-release.yml` to create the tag first, or trigger `publish.yml` only after the tag exists. + +### "tag already exists" +```bash +# Delete local tag +git tag -d v5.0.11 +# Delete remote tag (use with caution) +git push origin --delete v5.0.11 +``` + +### pub.dev dry run fails with "7 issues found" +Run `dart pub publish --dry-run` locally to see the exact issues. Common causes: +- Missing `description` in `pubspec.yaml` +- Files referenced in `pubspec.yaml` that don't exist +- Dart files with analysis errors + +### OIDC authentication fails in CI +Verify the pub.dev admin setup in Section 5 is complete and the repository name matches exactly (`mapp-digital/Mapp-Intelligence-Flutter-Tracking`). diff --git a/README.md b/README.md index f0d3c22..1064fff 100755 --- a/README.md +++ b/README.md @@ -1,10 +1,342 @@ -# Mapp-Intelligence-Flutter-Tracking -[Site](https://mapp.com/) | -[Docs](https://documentation.mapp.com/latest/en/flutter-software-development-kit-19109838.html) | -[Support](https://support.webtrekk.com/) | +# Mapp Intelligence Flutter Plugin -The MappIntelligence SDK allows you to track user activities, screen flow and media usage for an App. All data is send to the MappIntelligence tracking system for further analysis. +[![pub.dev](https://img.shields.io/pub/v/plugin_mappintelligence.svg)](https://pub.dev/packages/plugin_mappintelligence) +[![Platform](https://img.shields.io/badge/platform-android%20%7C%20ios-lightgrey.svg)](https://pub.dev/packages/plugin_mappintelligence) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A flutter plugin for integrating MappIntelligence SDK. Supports Android and iOS. +A Flutter plugin for integrating the [Mapp Intelligence](https://mapp.com/) SDK into Android and iOS applications. Track user activity, screen flow, media usage, ecommerce events, and link app sessions with WebView sessions — all from a single Dart API. - \ No newline at end of file +[Documentation](https://documentation.mapp.com/latest/en/flutter-software-development-kit-19109838.html) | [pub.dev](https://pub.dev/packages/plugin_mappintelligence) | [Support](https://support.webtrekk.com/) | [Changelog](CHANGELOG.md) + +--- + +## Requirements + +| | Minimum version | +|---|---| +| Flutter | 1.20.0 | +| Dart SDK | 2.17.0 | +| Android | API 21 (Android 5.0) | +| iOS | 12.0 | + +--- + +## Installation + +Add the plugin to your `pubspec.yaml`: + +```yaml +dependencies: + plugin_mappintelligence: ^5.0.10 +``` + +Then run: + +```bash +flutter pub get +``` + +--- + +## Setup + +### Android + +No native setup required. All initialization is done from Dart. + +### iOS + +No native setup required. All initialization is done from Dart. + +--- + +## Initialization + +Initialize the SDK as early as possible, typically in `main()`: + +```dart +import 'package:plugin_mappintelligence/plugin_mappintelligence.dart'; +import 'package:plugin_mappintelligence/object_tracking_classes.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await PluginMappintelligence.initialize( + ['794940687426749'], + 'https://tracker.mapp.com', + ); + + await PluginMappintelligence.setLogLevel(LogLevel.debug); + await PluginMappintelligence.setBatchSupportEnabledWithSize(true, 150); + await PluginMappintelligence.setRequestInterval(60); + await PluginMappintelligence.enableCrashTracking(ExceptionType.allExceptionTypes); + await PluginMappintelligence.build(); // Android only — required to finalize config + + runApp(MyApp()); +} +``` + +--- + +## Usage + +### Page Tracking + +Track a simple page view: + +```dart +await PluginMappintelligence.trackPage('Home'); +``` + +Track a page with custom parameters: + +```dart +await PluginMappintelligence.trackPage('ProductDetail', { + 'product_id': '12345', + 'category': 'shoes', +}); +``` + +Track a page with full event data: + +```dart +final event = PageViewEvent('Checkout'); +event.pageParameters = PageParameters() + ..searchTerm = 'running shoes' + ..params = {1: 'organic'}; +event.ecommerceParameters = EcommerceParameters() + ..currency = 'EUR' + ..orderValue = 89.99; + +await PluginMappintelligence.trackPageWithCustomData(event); +``` + +### Automatic Page Tracking with Navigator + +Add `MappAnalyticsObserver` to your `MaterialApp` to track navigation automatically: + +```dart +import 'package:plugin_mappintelligence/tracking/mapp_analytics_observer.dart'; +import 'package:plugin_mappintelligence/tracking/tracking_events.dart'; + +MaterialApp( + navigatorObservers: [ + MappAnalyticsObserver([TrackingEvents.PUSH]), + ], + // ... +) +``` + +### Action Tracking + +```dart +final event = ActionEvent('AddToCart'); +event.eventParameters = EventParameters() + ..parameters = {1: 'product_id', 2: 'quantity'}; + +await PluginMappintelligence.trackAction(event); +``` + +### Ecommerce Tracking + +```dart +final product = Product() + ..name = 'Running Shoes' + ..cost = 89.99 + ..quantity = 1; + +final ecommerce = EcommerceParameters() + ..products = [product] + ..status = Status.purchased + ..currency = 'EUR' + ..orderID = 'ORD-9876' + ..orderValue = 89.99; + +final event = PageViewEvent('OrderConfirmation'); +event.ecommerceParameters = ecommerce; + +await PluginMappintelligence.trackPageWithCustomData(event); +``` + +### Media Tracking + +```dart +final mediaParams = MediaParameters('intro_video.mp4') + ..action = 'play' + ..duration = 180 + ..position = 0 + ..soundVolume = 1.0; + +final event = MediaEvent('VideoPlayer', mediaParams); +await PluginMappintelligence.trackMedia(event); +``` + +### Campaign / URL Tracking + +```dart +// Track a deeplink URL with a media code +await PluginMappintelligence.trackUrl('https://example.com/promo', 'SUMMER2024'); + +// Track without a media code +await PluginMappintelligence.trackUrl('https://example.com/landing', null); +``` + +### Exception Tracking + +```dart +try { + // ... +} catch (e, stack) { + await PluginMappintelligence.trackExceptionWithNameAndMessage( + e.runtimeType.toString(), + stack.toString(), + ); +} +``` + +### Privacy & Consent + +```dart +// Opt in — enable tracking +await PluginMappintelligence.optIn(); + +// Opt out — disable tracking and optionally send remaining data +await PluginMappintelligence.optOutAndSendCurrentData(true); + +// Anonymous tracking — track without persisting identifiers +await PluginMappintelligence.setAnonymousTracking(true, ['email', 'phone']); +``` + +### EverID Management + +```dart +// Get the current EverID +final everId = await PluginMappintelligence.getEverID(); + +// Set a specific EverID +await PluginMappintelligence.setEverId('custom-ever-id'); +``` + +--- + +## WebView Session Linking + +To keep tracking sessions continuous between your native app and a WebView, use `WebTrackingController`. It injects the app's EverID into the WebView and forwards tracking events from the web page back to the native SDK. + +### Basic usage + +```dart +import 'package:plugin_mappintelligence/WebTrackingController.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class _MyWebViewState extends State { + late final WebViewController _controller; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted); + + WebTrackingController(controller: _controller); + + _controller.loadRequest(Uri.parse('https://your-tracked-page.com')); + } + + @override + Widget build(BuildContext context) { + return WebViewWidget(controller: _controller); + } +} +``` + +### With custom NavigationDelegate callbacks + +Pass your own `NavigationDelegate` — all callbacks are preserved alongside the plugin's tracking logic: + +```dart +WebTrackingController( + controller: _controller, + navigationDelegate: NavigationDelegate( + onPageStarted: (url) { + print('Loading: $url'); + }, + onPageFinished: (url) { + // Fires after EverID has been injected into the page + print('Loaded: $url'); + }, + onProgress: (progress) { + print('Progress: $progress%'); + }, + onWebResourceError: (error) { + print('Error: ${error.description}'); + }, + onNavigationRequest: (request) { + // Block specific URLs if needed + if (request.url.contains('blocked.com')) { + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + ), + onLoad: () { + // Called after EverID injection completes successfully + print('EverID injected'); + }, + onMessage: (message) { + // Called when the WebView sends a tracking event + print('WebView message: $message'); + }, +); +``` + +The WebView page must use the `ReactNativeWebView` JavaScript bridge to send tracking events: + +```javascript +// Track a page from the WebView +window.ReactNativeWebView.postMessage(JSON.stringify({ + method: 'trackCustomPage', + name: 'WebProductDetail', + params: '{"product":"shoes"}' +})); + +// Track an event from the WebView +window.ReactNativeWebView.postMessage(JSON.stringify({ + method: 'trackCustomEvent', + name: 'WebAddToCart', + params: '{"product":"shoes"}' +})); +``` + +--- + +## Configuration Reference + +| Method | Description | +|---|---| +| `initialize(trackIds, trackDomain)` | Initialize the SDK with tracking IDs and domain | +| `setLogLevel(LogLevel)` | Set logging verbosity (`all`, `debug`, `warning`, `error`, `none`) | +| `setBatchSupportEnabledWithSize(bool, int)` | Enable batching requests with a maximum queue size | +| `setRequestInterval(int)` | Set the interval in seconds between batch sends | +| `setRequestPerQueue(int)` | Set the maximum number of requests per queue | +| `setSendAppVersionInEveryRequest(bool)` | Include app version in every tracking request | +| `enableCrashTracking(ExceptionType)` | Enable automatic crash/exception tracking | +| `setAnonymousTracking(bool, List)` | Enable anonymous mode, optionally suppressing specific fields | +| `setUserMatchingEnabled(bool)` | Enable cross-device user matching | +| `setEnableBackgroundSendout(bool)` | Send queued requests when the app moves to background | +| `setTemporarySessionId(String)` | Set a temporary session identifier | +| `optIn()` | Enable tracking after opt-out | +| `optOutAndSendCurrentData(bool)` | Disable tracking, optionally flushing queued data | +| `build()` | Finalize configuration — Android only, call after all configuration | + +--- + +## Contributing + +See [INSTRUCTIONS.md](INSTRUCTIONS.md) for the full development, testing, and release workflow. + +--- + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/example/lib/Webview.dart b/example/lib/Webview.dart index ad7e655..f29cf0d 100755 --- a/example/lib/Webview.dart +++ b/example/lib/Webview.dart @@ -27,7 +27,27 @@ class _WebviewScreenState extends State { _controller = WebViewController(); _controller.setJavaScriptMode(JavaScriptMode.unrestricted); - WebTrackingController(controller: _controller); + WebTrackingController( + controller: _controller, + navigationDelegate: NavigationDelegate( + onPageStarted: (String url) { + print('Client onPageStarted: $url'); + }, + onPageFinished: (String url) { + print('Client onPageFinished: $url'); + }, + onProgress: (int progress) { + print('Client onProgress: $progress%'); + }, + onWebResourceError: (WebResourceError error) { + print('Client onWebResourceError: ${error.description}'); + }, + onNavigationRequest: (NavigationRequest request) { + print('Client onNavigationRequest: ${request.url}'); + return NavigationDecision.navigate; + }, + ), + ); _controller.loadRequest( Uri.parse('https://demoshop.webtrekk.com/media/web2app/index.html')); diff --git a/lib/WebTrackingController.dart b/lib/WebTrackingController.dart index 66d9d70..942adac 100644 --- a/lib/WebTrackingController.dart +++ b/lib/WebTrackingController.dart @@ -7,17 +7,19 @@ class WebTrackingController { final WebViewController controller; final void Function(String data)? onMessage; final void Function()? onLoad; + final NavigationDelegate? navigationDelegate; WebTrackingController({ required this.controller, this.onMessage, this.onLoad, + this.navigationDelegate, }) { _setupChannels(); } // Injected scripts - final String _runOnce = """ + static const String _runOnce = """ var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width, height=device-height, initial-scale=0.85, maximum-scale=1.0, user-scalable=no'); @@ -30,24 +32,29 @@ class WebTrackingController { final injectEverIdScript = "window.webtrekkApplicationEverId = '$everId'; true;"; await controller.runJavaScript(_runOnce + injectEverIdScript); + onLoad?.call(); } catch (error, stack) { print('Error: $error'); PluginMappintelligence.trackExceptionWithNameAndMessage(error.runtimeType.toString(), stack.toString()); } - - if (onLoad != null) onLoad!(); } void _setupChannels() { - if(Platform.isIOS){ + if (Platform.isIOS) { PluginMappintelligence.trackWebviewConfiguration(); } - controller.setNavigationDelegate(NavigationDelegate(onPageFinished: (url) { - print('Page finished loading: $url'); - handleLoad(); - })); - + controller.setNavigationDelegate(NavigationDelegate( + onNavigationRequest: navigationDelegate?.onNavigationRequest, + onPageStarted: navigationDelegate?.onPageStarted, + onProgress: navigationDelegate?.onProgress, + onWebResourceError: navigationDelegate?.onWebResourceError, + onPageFinished: (String url) { + print('Page finished loading: $url'); + handleLoad().then((_) => navigationDelegate?.onPageFinished?.call(url)); + }, + )); + controller.addJavaScriptChannel( 'ReactNativeWebView', onMessageReceived: (message) { @@ -58,16 +65,18 @@ class WebTrackingController { final params = data['params']; print('Method: $method, Name: $name, Params: $params'); + if (method == null || name == null) return; + if (method == 'trackCustomPage') { - PluginMappintelligence.trackWebPage(name, params); + PluginMappintelligence.trackWebPage(name, params); } else if (method == 'trackCustomEvent') { - PluginMappintelligence.trackWebEvent(name,params); + PluginMappintelligence.trackWebEvent(name, params); } } catch (e) { print('Error parsing message from WebView: $e'); } - if (onMessage != null) onMessage!(message.message); + onMessage?.call(message.message); }, ); } diff --git a/lib/plugin_mappintelligence.dart b/lib/plugin_mappintelligence.dart index 6d57b31..bd9f669 100755 --- a/lib/plugin_mappintelligence.dart +++ b/lib/plugin_mappintelligence.dart @@ -275,7 +275,7 @@ class PluginMappintelligence { static Future _updateCustomParams() async { // !! IMPORTANT !! UPDATE THIS VERSION TO BE THE SAME AS 'version' in pubspec.yaml plugin file - final flutterPluginVersion = "5.0.9"; + final flutterPluginVersion = "5.0.10"; debugPrint("FLUTTER PLUGIN VERSION: $flutterPluginVersion"); final result = await _channel .invokeMethod("updateCustomParams", [flutterPluginVersion]); diff --git a/pubspec.yaml b/pubspec.yaml index ca9f9f6..3bad117 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,8 +2,8 @@ name: plugin_mappintelligence description: The MappIntelligence SDK allows you to track user activities, screen flow and media usage for an App. All data is send to the MappIntelligence tracking system for further analysis. # !! IMPORTANT !! WHEN UPDATES THIS VERSION, IT'S NEEDED TO UPDATE VERSION in -#`lib/plugin_mappintelligence` file in the method `updateCustomParams` -version: 5.0.9 +# [lib/plugin_mappintelligence] file in the method `updateCustomParams` +version: 5.0.10 homepage: https://github.com/mapp-digital/Mapp-Intelligence-Flutter-Tracking diff --git a/test/plugin_mappintelligence_test.dart b/test/plugin_mappintelligence_test.dart index e9ee201..2efd0af 100755 --- a/test/plugin_mappintelligence_test.dart +++ b/test/plugin_mappintelligence_test.dart @@ -1,23 +1,544 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_mappintelligence/object_tracking_classes.dart'; import 'package:plugin_mappintelligence/plugin_mappintelligence.dart'; void main() { - const MethodChannel channel = MethodChannel('plugin_mappintelligence'); - TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugin_mappintelligence'); + + // Captures every method call made through the channel. + final List log = []; + + // Default handler — returns sensible values so methods don't throw. + Future defaultHandler(MethodCall call) async { + log.add(call); + switch (call.method) { + case 'getEverId': + return 'ever-id-123'; + case 'setEverId': + return 'ok'; + case 'getIdsAndDomain': + return {'trackIds': ['123'], 'trackDomain': 'example.com'}; + case 'getCurrentConfig': + return {'key': 'value'}; + case 'resetConfig': + return 'reset'; + case 'sendAndCleanData': + return 'sent'; + case 'setTemporarySessionId': + return 'ok'; + case 'setUserMatchingEnabled': + return 'ok'; + case 'setEnableBackgroundSendout': + return 'ok'; + case 'updateCustomParams': + return 'ok'; + case 'disableAutoTracking': + case 'disableActivityTracking': + case 'disableFragmentTracking': + return 'ok'; + default: + return null; + } + } + setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, defaultHandler); }); tearDown(() { - channel.setMockMethodCallHandler(null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + MethodCall lastCall() => log.last; + + MethodCall callWithMethod(String method) => + log.firstWhere((c) => c.method == method); + + // --------------------------------------------------------------------------- + // platformVersion + // --------------------------------------------------------------------------- + + group('platformVersion', () { + test('invokes getPlatformVersion and returns result', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return '1.0.0'; + }); + final version = await PluginMappintelligence.platformVersion; + expect(version, '1.0.0'); + expect(lastCall().method, 'getPlatformVersion'); + }); + }); + + // --------------------------------------------------------------------------- + // initialize + // --------------------------------------------------------------------------- + + group('initialize', () { + test('invokes initialize with trackIds and trackDomain', () async { + await PluginMappintelligence.initialize(['123456789'], 'track.example.com'); + final call = lastCall(); + expect(call.method, 'initialize'); + expect(call.arguments['trackIds'], ['123456789']); + expect(call.arguments['trackDomain'], 'track.example.com'); + }); + + test('returns successfull prefix in result', () async { + final result = await PluginMappintelligence.initialize(['id'], 'domain'); + expect(result, startsWith('successfull')); + }); + }); + + // --------------------------------------------------------------------------- + // setLogLevel + // --------------------------------------------------------------------------- + + group('setLogLevel', () { + test('sends LogLevel index + 1', () async { + await PluginMappintelligence.setLogLevel(LogLevel.debug); + // debug is index 1, so argument should be 2 + expect(lastCall().method, 'setLogLevel'); + expect(lastCall().arguments, [LogLevel.debug.index + 1]); + }); + + test('sends correct index for each log level', () async { + for (final level in LogLevel.values) { + log.clear(); + await PluginMappintelligence.setLogLevel(level); + expect(lastCall().arguments, [level.index + 1]); + } + }); + }); + + // --------------------------------------------------------------------------- + // setBatchSupportEnabledWithSize + // --------------------------------------------------------------------------- + + group('setBatchSupportEnabledWithSize', () { + test('sends isEnabled and size', () async { + await PluginMappintelligence.setBatchSupportEnabledWithSize(true, 100); + expect(lastCall().method, 'setBatchSupportEnabledWithSize'); + expect(lastCall().arguments, [true, 100]); + }); + }); + + // --------------------------------------------------------------------------- + // setRequestInterval + // --------------------------------------------------------------------------- + + group('setRequestInterval', () { + test('sends interval size', () async { + await PluginMappintelligence.setRequestInterval(30); + expect(lastCall().method, 'setRequestInterval'); + expect(lastCall().arguments, [30]); + }); + }); + + // --------------------------------------------------------------------------- + // setRequestPerQueue + // --------------------------------------------------------------------------- + + group('setRequestPerQueue', () { + test('sends request number', () async { + await PluginMappintelligence.setRequestPerQueue(5); + expect(lastCall().method, 'setRequestPerQueue'); + expect(lastCall().arguments, [5]); + }); + }); + + // --------------------------------------------------------------------------- + // setSendAppVersionInEveryRequest + // --------------------------------------------------------------------------- + + group('setSendAppVersionInEveryRequest', () { + test('sends true', () async { + await PluginMappintelligence.setSendAppVersionInEveryRequest(true); + expect(lastCall().method, 'setSendAppVersionInEveryRequest'); + expect(lastCall().arguments, [true]); + }); + + test('sends false', () async { + await PluginMappintelligence.setSendAppVersionInEveryRequest(false); + expect(lastCall().arguments, [false]); + }); + }); + + // --------------------------------------------------------------------------- + // enableCrashTracking + // --------------------------------------------------------------------------- + + group('enableCrashTracking', () { + test('sends ExceptionType index', () async { + await PluginMappintelligence.enableCrashTracking(ExceptionType.uncaught); + expect(lastCall().method, 'enableCrashTracking'); + expect(lastCall().arguments, [ExceptionType.uncaught.index]); + }); + }); + + // --------------------------------------------------------------------------- + // optIn / optOut + // --------------------------------------------------------------------------- + + group('optIn', () { + test('invokes OptIn', () async { + await PluginMappintelligence.optIn(); + expect(lastCall().method, 'OptIn'); + }); + }); + + group('optOutAndSendCurrentData', () { + test('invokes optOutAndSendCurrentData with value', () async { + await PluginMappintelligence.optOutAndSendCurrentData(true); + expect(lastCall().method, 'optOutAndSendCurrentData'); + expect(lastCall().arguments, [true]); + }); + }); + + // --------------------------------------------------------------------------- + // reset + // --------------------------------------------------------------------------- + + group('reset', () { + test('invokes resetConfig', () async { + await PluginMappintelligence.reset(); + expect(lastCall().method, 'resetConfig'); + }); + }); + + // --------------------------------------------------------------------------- + // trackPage + // --------------------------------------------------------------------------- + + group('trackPage', () { + test('without params invokes trackPage with name', () async { + await PluginMappintelligence.trackPage('Home'); + expect(lastCall().method, 'trackPage'); + expect(lastCall().arguments, ['Home']); + }); + + test('with params invokes trackCustomPage with name and params map', () async { + await PluginMappintelligence.trackPage('Home', {'key': 'value'}); + expect(lastCall().method, 'trackCustomPage'); + expect(lastCall().arguments, ['Home', {'key': 'value'}]); + }); + }); + + // --------------------------------------------------------------------------- + // trackPageWithCustomData + // --------------------------------------------------------------------------- + + group('trackPageWithCustomData', () { + test('with customName invokes trackPageWithCustomNameAndPageViewEvent', () async { + await PluginMappintelligence.trackPageWithCustomData(null, 'MyPage'); + expect(lastCall().method, 'trackPageWithCustomNameAndPageViewEvent'); + expect(lastCall().arguments, ['MyPage']); + }); + + test('with pageViewEvent invokes trackPageWithCustomData with JSON', () async { + final event = PageViewEvent('ProductPage'); + await PluginMappintelligence.trackPageWithCustomData(event); + expect(lastCall().method, 'trackPageWithCustomData'); + final decoded = jsonDecode(lastCall().arguments[0] as String); + expect(decoded['name'], 'ProductPage'); + }); + + test('pageViewEvent JSON includes nested objects', () async { + final event = PageViewEvent('Cart'); + event.pageParameters = PageParameters()..searchTerm = 'shoes'; + event.ecommerceParameters = EcommerceParameters()..currency = 'EUR'; + await PluginMappintelligence.trackPageWithCustomData(event); + final decoded = jsonDecode(lastCall().arguments[0] as String); + expect(decoded['pageParameters']['searchTerm'], 'shoes'); + expect(decoded['ecommerceParameters']['currency'], 'EUR'); + }); + }); + + // --------------------------------------------------------------------------- + // trackExceptionWithNameAndMessage + // --------------------------------------------------------------------------- + + group('trackExceptionWithNameAndMessage', () { + test('sends name and message in map', () async { + await PluginMappintelligence.trackExceptionWithNameAndMessage( + 'NullPointerException', 'stack trace here'); + expect(lastCall().method, 'trackExceptionWithNameAndMessage'); + expect(lastCall().arguments['name'], 'NullPointerException'); + expect(lastCall().arguments['message'], 'stack trace here'); + }); }); - test('getPlatformVersion', () async { - expect(await PluginMappintelligence.platformVersion, '42'); + // --------------------------------------------------------------------------- + // trackAction + // --------------------------------------------------------------------------- + + group('trackAction', () { + test('invokes trackAction with JSON-encoded ActionEvent', () async { + final event = ActionEvent('ButtonClick'); + event.eventParameters = EventParameters() + ..parameters = {1: 'param_value'}; + await PluginMappintelligence.trackAction(event); + expect(lastCall().method, 'trackAction'); + final decoded = jsonDecode(lastCall().arguments[0] as String); + expect(decoded['name'], 'ButtonClick'); + expect(decoded['eventParameters']['parameters']['1'], 'param_value'); + }); + }); + + // --------------------------------------------------------------------------- + // trackUrl + // --------------------------------------------------------------------------- + + group('trackUrl', () { + test('without mediaCode invokes trackUrlWithoutMediaCode', () async { + await PluginMappintelligence.trackUrl('https://example.com', null); + expect(lastCall().method, 'trackUrlWithoutMediaCode'); + expect(lastCall().arguments, ['https://example.com']); + }); + + test('with mediaCode invokes trackUrl with url and code', () async { + await PluginMappintelligence.trackUrl('https://example.com', 'mc123'); + expect(lastCall().method, 'trackUrl'); + expect(lastCall().arguments, ['https://example.com', 'mc123']); + }); + }); + + // --------------------------------------------------------------------------- + // trackMedia + // --------------------------------------------------------------------------- + + group('trackMedia', () { + test('invokes trackMedia with JSON-encoded MediaEvent', () async { + final params = MediaParameters('video.mp4') + ..action = 'play' + ..duration = 120 + ..position = 0; + final event = MediaEvent('VideoPlayer', params); + await PluginMappintelligence.trackMedia(event); + expect(lastCall().method, 'trackMedia'); + final decoded = jsonDecode(lastCall().arguments[0] as String); + expect(decoded['name'], 'VideoPlayer'); + expect(decoded['mediaParameters']['action'], 'play'); + expect(decoded['mediaParameters']['duration'], 120); + }); + }); + + // --------------------------------------------------------------------------- + // trackWebview + // --------------------------------------------------------------------------- + + group('trackWebview', () { + test('with all coordinates sends x, y, width, height, url', () async { + await PluginMappintelligence.trackWebview(0, 0, 320, 480, 'https://example.com'); + expect(lastCall().method, 'trackWebview'); + expect(lastCall().arguments, [0.0, 0.0, 320.0, 480.0, 'https://example.com']); + }); + + test('with null coordinates sends only url', () async { + await PluginMappintelligence.trackWebview(null, null, null, null, 'https://example.com'); + expect(lastCall().method, 'trackWebview'); + expect(lastCall().arguments, ['https://example.com']); + }); + }); + + // --------------------------------------------------------------------------- + // getEverID / setEverId + // --------------------------------------------------------------------------- + + group('getEverID', () { + test('invokes getEverId and returns value', () async { + final id = await PluginMappintelligence.getEverID(); + expect(lastCall().method, 'getEverId'); + expect(id, 'ever-id-123'); + }); + + test('returns empty string when native returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return null; + }); + final id = await PluginMappintelligence.getEverID(); + expect(id, ''); + }); + }); + + group('setEverId', () { + test('invokes setEverId with the provided id', () async { + await PluginMappintelligence.setEverId('new-ever-id'); + expect(lastCall().method, 'setEverId'); + expect(lastCall().arguments, ['new-ever-id']); + }); + }); + + // --------------------------------------------------------------------------- + // setIdsAndDomain / getTrackIdsAndDomain + // --------------------------------------------------------------------------- + + group('setIdsAndDomain', () { + test('sends trackIds and trackDomain', () async { + await PluginMappintelligence.setIdsAndDomain(['id1', 'id2'], 'track.com'); + expect(lastCall().method, 'setIdsAndDomain'); + expect(lastCall().arguments['trackIds'], ['id1', 'id2']); + expect(lastCall().arguments['trackDomain'], 'track.com'); + }); + }); + + group('getTrackIdsAndDomain', () { + test('invokes getIdsAndDomain and returns map', () async { + final data = await PluginMappintelligence.getTrackIdsAndDomain(); + expect(lastCall().method, 'getIdsAndDomain'); + expect(data?['trackIds'], ['123']); + expect(data?['trackDomain'], 'example.com'); + }); + }); + + // --------------------------------------------------------------------------- + // setAnonymousTracking + // --------------------------------------------------------------------------- + + group('setAnonymousTracking', () { + test('sends anonymousTracking=true with non-empty params', () async { + await PluginMappintelligence.setAnonymousTracking(true, ['email']); + expect(lastCall().method, 'enableAnonymousTracking'); + expect(lastCall().arguments['anonymousTracking'], true); + expect(lastCall().arguments['params'], ['email']); + }); + + test('sends params as null when list is empty', () async { + await PluginMappintelligence.setAnonymousTracking(false, []); + expect(lastCall().arguments['params'], isNull); + }); + }); + + // --------------------------------------------------------------------------- + // setTemporarySessionId + // --------------------------------------------------------------------------- + + group('setTemporarySessionId', () { + test('sends session id in map', () async { + await PluginMappintelligence.setTemporarySessionId('session-abc'); + expect(lastCall().method, 'setTemporarySessionId'); + expect(lastCall().arguments['temporarySessionId'], 'session-abc'); + }); + }); + + // --------------------------------------------------------------------------- + // setUserMatchingEnabled / setEnableBackgroundSendout + // --------------------------------------------------------------------------- + + group('setUserMatchingEnabled', () { + test('sends enabled flag', () async { + await PluginMappintelligence.setUserMatchingEnabled(true); + expect(lastCall().method, 'setUserMatchingEnabled'); + expect(lastCall().arguments['enabled'], true); + }); + }); + + group('setEnableBackgroundSendout', () { + test('sends enabled flag', () async { + await PluginMappintelligence.setEnableBackgroundSendout(false); + expect(lastCall().method, 'setEnableBackgroundSendout'); + expect(lastCall().arguments['enabled'], false); + }); + }); + + // --------------------------------------------------------------------------- + // getCurrentConfig + // --------------------------------------------------------------------------- + + group('getCurrentConfig', () { + test('invokes getCurrentConfig and returns map', () async { + final config = await PluginMappintelligence.getCurrentConfig(); + expect(callWithMethod('getCurrentConfig').method, 'getCurrentConfig'); + expect(config['key'], 'value'); + }); + }); + + // --------------------------------------------------------------------------- + // sendAndCleanData + // --------------------------------------------------------------------------- + + group('sendAndCleanData', () { + test('invokes sendAndCleanData', () async { + await PluginMappintelligence.sendAndCleanData(); + expect(callWithMethod('sendAndCleanData').method, 'sendAndCleanData'); + }); + }); + + // --------------------------------------------------------------------------- + // reset + // --------------------------------------------------------------------------- + + group('reset', () { + test('invokes resetConfig', () async { + await PluginMappintelligence.reset(); + expect(lastCall().method, 'resetConfig'); + }); + }); + + // --------------------------------------------------------------------------- + // trackError + // --------------------------------------------------------------------------- + + group('trackError', () { + test('sends userInfo, domain and code', () async { + await PluginMappintelligence.trackError({'key': 'val'}, 'com.example', 42); + expect(lastCall().method, 'trackError'); + expect(lastCall().arguments['domain'], 'com.example'); + expect(lastCall().arguments['code'], 42); + expect(lastCall().arguments['userInfo'], {'key': 'val'}); + }); + }); + + // --------------------------------------------------------------------------- + // Version sync — pubspec.yaml must match the hardcoded version in source + // --------------------------------------------------------------------------- + + group('version sync', () { + test('flutterPluginVersion in _updateCustomParams matches pubspec.yaml', () async { + // Read pubspec.yaml from the package root (two levels up from test/) + final pubspecFile = File('pubspec.yaml'); + final pubspecContent = await pubspecFile.readAsString(); + + // Extract version: value without adding a yaml parser dependency + final match = RegExp(r'^version:\s*(\S+)', multiLine: true) + .firstMatch(pubspecContent); + expect(match, isNotNull, reason: 'version field not found in pubspec.yaml'); + final pubspecVersion = match!.group(1)!; + + // Trigger _updateCustomParams via build() and capture the channel call + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return 'ok'; + }); + + await PluginMappintelligence.build(); + + final updateCall = log.firstWhere((c) => c.method == 'updateCustomParams'); + final hardcodedVersion = (updateCall.arguments as List).first as String; + + expect( + hardcodedVersion, + pubspecVersion, + reason: 'flutterPluginVersion in plugin_mappintelligence.dart ($hardcodedVersion) ' + 'is out of sync with pubspec.yaml ($pubspecVersion). ' + 'Update the version string in _updateCustomParams().', + ); + }); }); } diff --git a/test/web_tracking_controller_test.dart b/test/web_tracking_controller_test.dart new file mode 100644 index 0000000..225bc27 --- /dev/null +++ b/test/web_tracking_controller_test.dart @@ -0,0 +1,390 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_mappintelligence/WebTrackingController.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +// --------------------------------------------------------------------------- +// Fake platform — captures NavigationDelegate callbacks so tests can trigger +// them directly without a real device. +// --------------------------------------------------------------------------- + +class FakeWebViewPlatform extends WebViewPlatform { + @override + PlatformWebViewController createPlatformWebViewController( + PlatformWebViewControllerCreationParams params, + ) => + FakeWebViewController(params); + + @override + PlatformWebViewWidget createPlatformWebViewWidget( + PlatformWebViewWidgetCreationParams params, + ) => + FakeWebViewWidget(params); + + @override + PlatformWebViewCookieManager createPlatformCookieManager( + PlatformWebViewCookieManagerCreationParams params, + ) => + FakeCookieManager(params); + + @override + PlatformNavigationDelegate createPlatformNavigationDelegate( + PlatformNavigationDelegateCreationParams params, + ) => + FakeNavigationDelegate(params); +} + +/// Stores callbacks registered by NavigationDelegate so tests can fire them. +class FakeNavigationDelegate extends PlatformNavigationDelegate { + FakeNavigationDelegate(super.params) : super.implementation(); + + PageEventCallback? _onPageStarted; + PageEventCallback? _onPageFinished; + ProgressCallback? _onProgress; + WebResourceErrorCallback? _onWebResourceError; + NavigationRequestCallback? _onNavigationRequest; + @override + Future setOnPageStarted(PageEventCallback cb) async => _onPageStarted = cb; + @override + Future setOnPageFinished(PageEventCallback cb) async => _onPageFinished = cb; + @override + Future setOnProgress(ProgressCallback cb) async => _onProgress = cb; + @override + Future setOnWebResourceError(WebResourceErrorCallback cb) async => _onWebResourceError = cb; + @override + Future setOnNavigationRequest(NavigationRequestCallback cb) async => _onNavigationRequest = cb; + @override + Future setOnUrlChange(UrlChangeCallback cb) async {} + @override + Future setOnHttpAuthRequest(HttpAuthRequestCallback cb) async {} + @override + Future setOnHttpError(HttpResponseErrorCallback cb) async {} + + // Simulation helpers + void simulatePageStarted(String url) => _onPageStarted?.call(url); + Future simulatePageFinished(String url) async => _onPageFinished?.call(url); + void simulateProgress(int progress) => _onProgress?.call(progress); + void simulateWebResourceError(WebResourceError error) => _onWebResourceError?.call(error); + Future simulateNavigationRequest(NavigationRequest req) async => + await _onNavigationRequest?.call(req) ?? NavigationDecision.navigate; +} + +class FakeWebViewController extends PlatformWebViewController { + FakeWebViewController(super.params) : super.implementation(); + + FakeNavigationDelegate? capturedDelegate; + final List jsLog = []; + final List channels = []; + + @override + Future setJavaScriptMode(JavaScriptMode mode) async {} + + @override + Future setBackgroundColor(Color color) async {} + + @override + Future setPlatformNavigationDelegate(PlatformNavigationDelegate handler) async { + capturedDelegate = handler as FakeNavigationDelegate; + } + + @override + Future runJavaScript(String js) async => jsLog.add(js); + + @override + Future runJavaScriptReturningResult(String js) async => ''; + + @override + Future addJavaScriptChannel(JavaScriptChannelParams params) async => + channels.add(params); + + @override + Future loadRequest(LoadRequestParams params) async {} + + @override + Future currentUrl() async => 'https://example.com'; +} + +class FakeWebViewWidget extends PlatformWebViewWidget { + FakeWebViewWidget(super.params) : super.implementation(); + + @override + Widget build(BuildContext context) => Container(); +} + +class FakeCookieManager extends PlatformWebViewCookieManager { + FakeCookieManager(super.params) : super.implementation(); +} + +// --------------------------------------------------------------------------- +// Helper: get the FakeWebViewController backing a WebViewController +// --------------------------------------------------------------------------- +FakeWebViewController _fakePlatformController(WebViewController controller) { + return controller.platform as FakeWebViewController; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const MethodChannel channel = MethodChannel('plugin_mappintelligence'); + late WebViewController controller; + late FakeWebViewController fakeController; + + setUp(() { + WebViewPlatform.instance = FakeWebViewPlatform(); + controller = WebViewController(); + fakeController = _fakePlatformController(controller); + + // Mock the native method channel used by PluginMappintelligence + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'getEverId': + return 'test-ever-id-123'; + case 'trackWebviewConfiguration': + case 'trackExceptionWithNameAndMessage': + return null; + default: + return null; + } + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + // ------------------------------------------------------------------------- + // 1. Basic setup + // ------------------------------------------------------------------------- + + test('constructs without error when no navigationDelegate is provided', () { + expect( + () => WebTrackingController(controller: controller), + returnsNormally, + ); + }); + + test('registers a NavigationDelegate on the controller', () { + WebTrackingController(controller: controller); + expect(fakeController.capturedDelegate, isNotNull); + }); + + test('registers ReactNativeWebView JavaScript channel', () { + WebTrackingController(controller: controller); + expect( + fakeController.channels.any((c) => c.name == 'ReactNativeWebView'), + isTrue, + ); + }); + + // ------------------------------------------------------------------------- + // 2. Regression: bug reproduction — client callbacks were silently dropped + // (demonstrates what the issue was before the fix) + // ------------------------------------------------------------------------- + + test('REGRESSION: without fix, a second setNavigationDelegate call would ' + 'override the first — verified by confirming only one delegate is active', () { + // Before the fix, clients had to call setNavigationDelegate themselves, + // which WebTrackingController then replaced. Now clients pass their + // callbacks via navigationDelegate parameter — only one delegate is set. + bool clientCalled = false; + + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onPageStarted: (_) => clientCalled = true, + ), + ); + + fakeController.capturedDelegate!.simulatePageStarted('https://example.com'); + expect(clientCalled, isTrue, + reason: 'Client onPageStarted must not be dropped'); + }); + + // ------------------------------------------------------------------------- + // 3. All five callbacks are forwarded + // ------------------------------------------------------------------------- + + test('onPageStarted client callback is forwarded', () { + String? capturedUrl; + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onPageStarted: (url) => capturedUrl = url, + ), + ); + + fakeController.capturedDelegate!.simulatePageStarted('https://example.com'); + expect(capturedUrl, 'https://example.com'); + }); + + test('onProgress client callback is forwarded', () { + int? capturedProgress; + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onProgress: (p) => capturedProgress = p, + ), + ); + + fakeController.capturedDelegate!.simulateProgress(75); + expect(capturedProgress, 75); + }); + + test('onWebResourceError client callback is forwarded', () { + WebResourceError? capturedError; + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onWebResourceError: (e) => capturedError = e, + ), + ); + + final error = WebResourceError( + errorCode: 404, + description: 'Not found', + errorType: WebResourceErrorType.fileNotFound, + ); + fakeController.capturedDelegate!.simulateWebResourceError(error); + expect(capturedError?.description, 'Not found'); + }); + + test('onNavigationRequest client callback is forwarded', () async { + bool clientCalled = false; + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onNavigationRequest: (req) { + clientCalled = true; + return NavigationDecision.navigate; + }, + ), + ); + + await fakeController.capturedDelegate!.simulateNavigationRequest( + NavigationRequest(url: 'https://example.com', isMainFrame: true), + ); + expect(clientCalled, isTrue); + }); + + // ------------------------------------------------------------------------- + // 4. onPageFinished ordering — client fires AFTER EverID injection + // ------------------------------------------------------------------------- + + test('onPageFinished: plugin injects EverID before client callback fires', () async { + final log = []; + + WebTrackingController( + controller: controller, + navigationDelegate: NavigationDelegate( + onPageFinished: (_) => log.add('client'), + ), + ); + + await fakeController.capturedDelegate!.simulatePageFinished('https://example.com'); + // Flush the full async chain: handleLoad (method channel) → runJavaScript → .then(client cb) + await Future.delayed(const Duration(milliseconds: 50)); + + expect(fakeController.jsLog.any((js) => js.contains('webtrekkApplicationEverId')), + isTrue, reason: 'EverID script must have been injected'); + expect(log, contains('client'), + reason: 'Client onPageFinished must fire after injection'); + }); + + test('onPageFinished: EverID value from native is injected into the page', () async { + WebTrackingController(controller: controller); + + await fakeController.capturedDelegate!.simulatePageFinished('https://example.com'); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + fakeController.jsLog.any((js) => js.contains("'test-ever-id-123'")), + isTrue, + ); + }); + + // ------------------------------------------------------------------------- + // 5. onLoad fires only on success, not after getEverID failure + // ------------------------------------------------------------------------- + + test('onLoad is called on successful page load', () async { + bool onLoadCalled = false; + final wt = WebTrackingController( + controller: controller, + onLoad: () => onLoadCalled = true, + ); + + await wt.handleLoad(); + expect(onLoadCalled, isTrue); + }); + + test('onLoad is NOT called when getEverID throws', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getEverId') throw PlatformException(code: 'ERROR'); + return null; + }); + + bool onLoadCalled = false; + final wt = WebTrackingController( + controller: controller, + onLoad: () => onLoadCalled = true, + ); + + await wt.handleLoad(); + expect(onLoadCalled, isFalse); + }); + + // ------------------------------------------------------------------------- + // 6. JavaScript channel message dispatch + // ------------------------------------------------------------------------- + + test('malformed JSON message does not throw', () { + WebTrackingController(controller: controller); + final channel = fakeController.channels + .firstWhere((c) => c.name == 'ReactNativeWebView'); + + expect( + () => channel.onMessageReceived(JavaScriptMessage(message: 'not-json')), + returnsNormally, + ); + }); + + test('message missing method/name fields does not dispatch tracking call', () { + final methodCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + methodCalls.add(call.method); + return null; + }); + + WebTrackingController(controller: controller); + final jsChannel = fakeController.channels + .firstWhere((c) => c.name == 'ReactNativeWebView'); + + jsChannel.onMessageReceived(JavaScriptMessage(message: '{"foo":"bar"}')); + expect(methodCalls, isNot(contains('trackWebPage'))); + expect(methodCalls, isNot(contains('trackWebEvent'))); + }); + + test('onMessage callback fires for every received message', () { + final received = []; + WebTrackingController( + controller: controller, + onMessage: (msg) => received.add(msg), + ); + + final jsChannel = fakeController.channels + .firstWhere((c) => c.name == 'ReactNativeWebView'); + + jsChannel.onMessageReceived(JavaScriptMessage(message: 'hello')); + expect(received, contains('hello')); + }); +}