Syncing deviceUUID Between Flutter Mobile and JS SDKs in app Using a WebView

👍

Expected result of the “Syncing deviceUUID Between Flutter Mobile and JS SDKs in app Using a WebView” step:

A single customer profile is created in Maestra, combining actions from both the mobile SDK and the website using the JS SDK.

📘

If your app uses a WebView that loads a website with the JS tracker, installing the mobile app will result in two separate customer profiles in Maestra, each with a different deviceUUID: one from the JS SDK and one from the mobile SDK.
To prevent this and keep all data in a single customer profile, follow the steps below:

Add a method to retrieve the deviceUUID in your app.

// Timeout for retrieving the deviceUUID.
// The page won’t start loading until this timeout expires.
// On first initialization, retrieving the deviceUUID can take a few seconds,
//subsequent attempts usually take less than 250 ms.
static const fetchDeviceUuidTimeout = Duration(milliseconds: 4000);

// Retrieves the deviceUUID within the specified timeout.
// Note: fetchDeviceUuidTimeout must not be less than 250 ms,
// Otherwise, the deviceUUID may not be retrieved in time.
Future<String> _fetchDeviceUUIDWithTimeout() async {
  final completer = Completer<String>();

  if (fetchDeviceUuidTimeout.inMilliseconds < 250) {
    throw ArgumentError("Timeout must be at least 250 milliseconds.");
  }

  final timer = Timer(fetchDeviceUuidTimeout, () {
    if (!completer.isCompleted) {
      completer.completeError(
        TimeoutException("Timeout while fetching Device UUID."),
      );
    }
  });

  Mindbox.instance.getDeviceUUID((deviceUUID) {
    if (!completer.isCompleted) {
      if (deviceUUID.isNotEmpty) {
        completer.complete(deviceUUID);
      } else {
        completer.completeError(Exception("DeviceUUID is empty"));
      }
    }
  });

  return completer.future.whenComplete(() => timer.cancel());
}
🚧

On first initialization of the Mindbox SDK, the deviceUUID may be returned by the provider after a few seconds.

On subsequent initializations, the deviceUUID is retrieved within 100–200 ms.

📘

Using Third-Party Cookies

Starting with Android API 21 and iOS 12, third-party cookies are disabled by default.

When using third-party cookies, make sure to wait for the deviceUUID to be retrieved during the initial initialization.


In initState, add a method that waits for the deviceUUID retrieval and WebViewController initialization.

@override
void initState() {
  super.initState();

  _initializeDeviceUUIDAndWebView();

 // This call ensures that even if deviceUUID retrieval
 // times out during the initial load,
// synchronization will still occur on subsequent page loads
// or app launches.
  Mindbox.instance.getDeviceUUID((uuid) {
    deviceUUID = uuid;
  });

  //The rest of your code
}

// Attempts to retrieve the deviceUUID within the specified timeout (fetchDeviceUuidTimeout).
// The page starts loading either after the UUID is retrieved or when the timeout expires.
// If the UUID is not retrieved, synchronization will occur on the next app launch or page load.
Future<void> _initializeDeviceUUIDAndWebView() async {
  try {
    final uuid = await _fetchDeviceUUIDWithTimeout();
    deviceUUID = uuid;
    print('DeviceUUID initialized: $deviceUUID');
  } catch (e) {
    print('Failed to initialize DeviceUUID: $e');
  } finally {
    _initializeWebViewController();
    setState(() {
      _isWebViewInitialized = true;
    });
  }
}

If you don’t want to wait several seconds during the initial initialization, duplicate the deviceUUID retrieval in initState. In this case, synchronization will occur on the next page load.

  // Adding this method ensures that even if deviceUUID retrieval
  // times out during the initial page load,
 // synchronization will occur on subsequent page loads
 // or app launches.
    Mindbox.instance.getDeviceUUID((uuid) {
      deviceUUID = uuid;
    });

Add a WebViewController initialization method and a deviceUUID synchronization method.

// Initializes the WebView. Synchronization is performed in the onPageStarted callback.
Future<void> _initializeWebViewController() async {
  _controller = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..setNavigationDelegate(
      NavigationDelegate(
        onPageStarted: (String url) async {
          if (deviceUUID != null) {
            await _waitForJavaScriptReady(_controller);
            await _synchronizeDeviceUUID(_controller, deviceUUID!);
          }
        },
      ),
    )
    ..loadRequest(Uri.parse(url));
}

// Syncs the deviceUUID with the JS SDK.
Future<void> _synchronizeDeviceUUID(
  WebViewController controller,
  String uuid,
) async {
  await controller.runJavaScript('''
    document.cookie = "mindboxDeviceUUID=$uuid";
    window.localStorage.setItem('mindboxDeviceUUID', '$uuid');
  ''');

  Mindbox.instance.writeNativeLog(
    message: "Device UUID synchronized with deviceUUID: $uuid",
    logLevel: LogLevel.info,
  );
}

Add a method that waits for the JS context to become available when the page starts loading.

📘

On iOS, the JS context is not immediately available in the onPageStarted callback, and JavaScript cannot be executed until the context becomes available.


// Method for waiting for the JavaScript context to be ready
Future<void> _waitForJavaScriptReady(WebViewController controller) async {
  const int maxRetries = 10;
  int attempts = 0;
  const Duration retryInterval = Duration(milliseconds: 10);

  while (attempts < maxRetries) {
   // Adds a short delay before the first check
  // to give the JavaScript context time to initialize.
    await Future.delayed(retryInterval);

    try {
      final isReady = await controller.runJavaScriptReturningResult('''
        (function() {
          return typeof document.cookie !== "undefined" &&
                 typeof localStorage !== "undefined";
        })();
      ''');

      if (isReady == true) {
        print("JavaScript context is ready.");
        return;
      }

      print("JavaScript context not ready, retrying... [$attempts]");
    } catch (e) {
      print("Error during JavaScript readiness check: $e");
    }

    await Future.delayed(retryInterval);
    attempts++;
  }

  throw TimeoutException(
    "JavaScript context not ready after $maxRetries retries.",
  );
}

Example
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:mindbox/mindbox.dart';
import '../push_info_page/push_info_page.dart';
import 'package:webview_flutter/webview_flutter.dart';

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
static const String url = "https://your-site.com/";

// Timeout for retrieving the deviceUUID.
// The page will not start loading until this timeout expires.
// During the initial initialization, retrieval may take several seconds,
// while subsequent attempts usually take less than 250 ms.
static const fetchDeviceUuidTimeout = Duration(milliseconds: 4000);

late final WebViewController _controller;
String? deviceUUID;
bool _isWebViewInitialized = false;

@override
void initState() {
  super.initState();

  _initializeDeviceUUIDAndWebView();
  
// This call ensures that even if deviceUUID retrieval
// times out during the first page load,
// synchronization will occur on subsequent page loads
// or app launches.
  Mindbox.instance.getDeviceUUID((uuid) {
    deviceUUID = uuid;
  });
}

// Attempts to retrieve the device UUID within the specified timeout.
// The page starts loading either after the UUID is retrieved
// or when the timeout expires.
// If the UUID is not retrieved, synchronization will occur
// on the next app launch or page load.
Future<void> _initializeDeviceUUIDAndWebView() async {
  try {
    final uuid = await _fetchDeviceUUIDWithTimeout();
    deviceUUID = uuid;
    print('DeviceUUID initialized: $deviceUUID');
  } catch (e) {
    print('Failed to initialize DeviceUUID: $e');
  } finally {
    _initializeWebViewController();
    setState(() {
      _isWebViewInitialized = true;
    });
  }
}

// Initializes the WebView. Synchronization is performed in `onPageStarted`.
Future<void> _initializeWebViewController() async {
  _controller = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..setNavigationDelegate(
      NavigationDelegate(
        onPageStarted: (String url) async {
          if (deviceUUID != null) {
            await _waitForJavaScriptReady(_controller);
            await _synchronizeDeviceUUID(_controller, deviceUUID!);
          }
        },
      ),
    )
    ..loadRequest(Uri.parse(url));
}

// Retrieves the deviceUUID within the specified timeout.
Future<String> _fetchDeviceUUIDWithTimeout() async {
  final completer = Completer<String>();

  if (fetchDeviceUuidTimeout.inMilliseconds < 250) {
    throw ArgumentError("Timeout must be at least 250 milliseconds.");
  }

  final timer = Timer(fetchDeviceUuidTimeout, () {
    if (!completer.isCompleted) {
      completer.completeError(
        TimeoutException("Timeout while fetching Device UUID."),
      );
    }
  });

  Mindbox.instance.getDeviceUUID((deviceUUID) {
    if (!completer.isCompleted) {
      if (deviceUUID.isNotEmpty) {
        completer.complete(deviceUUID);
      } else {
        completer.completeError(Exception("DeviceUUID is empty"));
      }
    }
  });

  return completer.future.whenComplete(() => timer.cancel());
}

void _handlePushNotification(String link, dynamic payload) {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (context) => PushInfoPage(link: link, payload: payload),
    ),
  );
}

// Debug method: logs cookies, localStorage, and the mobile UUID.
Future<void> _showData(WebViewController controller) async {
  try {
    final cookies =
        await controller.runJavaScriptReturningResult("document.cookie");
    print("Cookies: $cookies");
    print("Mobile device UUID is: $deviceUUID");

    final localStorageUUID = await controller.runJavaScriptReturningResult('''
      window.localStorage.getItem('mindboxDeviceUUID');
    ''');

    print("JS tracker device UUID: $localStorageUUID");
  } catch (e) {
    print("Failed to fetch cookies: $e");
  }
}

// Syncs the deviceUUID with the JS SDK.
Future<void> _synchronizeDeviceUUID(
  WebViewController controller,
  String uuid,
) async {
  await controller.runJavaScript('''
    document.cookie = "mindboxDeviceUUID=$uuid";
    window.localStorage.setItem('mindboxDeviceUUID', '$uuid');
  ''');

  Mindbox.instance.writeNativeLog(
    message: "Device UUID synchronized with deviceUUID: $uuid",
    logLevel: LogLevel.info,
  );
}

// Clears cookies.
Future<void> _clearAllCookies() async {
  final cookieManager = WebViewCookieManager();
  await cookieManager.clearCookies();
}

// Waits until the JavaScript context is available.
Future<void> _waitForJavaScriptReady(WebViewController controller) async {
  const int maxRetries = 10;
  int attempts = 0;
  const Duration retryInterval = Duration(milliseconds: 10);

  while (attempts < maxRetries) {
    await Future.delayed(retryInterval);

    try {
      final isReady = await controller.runJavaScriptReturningResult('''
        (function() {
          return typeof document.cookie !== "undefined" &&
                 typeof localStorage !== "undefined";
        })();
      ''');

      if (isReady == true) {
        print("JavaScript context is ready.");
        return;
      }

      print("JavaScript context not ready, retrying... [$attempts]");
    } catch (e) {
      print("Error during JavaScript readiness check: $e");
    }

    await Future.delayed(retryInterval);
    attempts++;
  }

  throw TimeoutException(
    "JavaScript context not ready after $maxRetries retries.",
  );
}

@override
Widget build(BuildContext context) {
  if (!_isWebViewInitialized) {
    return Scaffold(
      appBar: AppBar(title: const Text('WebView Example')),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CircularProgressIndicator(),
          ],
        ),
      ),
    );
  }

  return Scaffold(
    appBar: AppBar(
      title: const Text('WebView Example'),
      actions: [
        IconButton(
          icon: const Icon(Icons.cookie),
          onPressed: () => _showData(_controller),
        ),
      ],
    ),
    body: WebViewWidget(controller: _controller),
  );
}

@override
void dispose() {
  _controller.clearCache();
  super.dispose();
}
}