From b98b54c2d062e2483d7155ccac68ddd70e16f394 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:06:23 +0100 Subject: [PATCH] chore: do not send repeated snapshots (#126) --- CHANGELOG.md | 2 + lib/src/posthog_flutter_web_handler.dart | 12 +++ lib/src/posthog_widget_widget.dart | 15 +-- .../screenshot/screenshot_capturer.dart | 40 ++++++- lib/src/replay/vendor/equality.dart | 101 ++++++++++++++++++ 5 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 lib/src/replay/vendor/equality.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f48438..22d9609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- chore: do not send repeated snapshots ([#126](https://github.com/PostHog/posthog-flutter/pull/126)) + ## 4.7.0 - chore: flutter session replay (Android and iOS) ([#123](https://github.com/PostHog/posthog-flutter/pull/123)) diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index a4fcd59..bef4065 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -102,6 +102,18 @@ Future handleWebMethodCall(MethodCall call, JsObject context) async { // not supported on Web // analytics.callMethod('close'); break; + case 'sendMetaEvent': + // not supported on Web + // Flutter Web uses the JS SDK for Session replay + break; + case 'sendFullSnapshot': + // not supported on Web + // Flutter Web uses the JS SDK for Session replay + break; + case 'isSessionReplayActive': + // not supported on Web + // Flutter Web uses the JS SDK for Session replay + break; default: throw PlatformException( code: 'Unimplemented', diff --git a/lib/src/posthog_widget_widget.dart b/lib/src/posthog_widget_widget.dart index e37ca6b..68c5feb 100644 --- a/lib/src/posthog_widget_widget.dart +++ b/lib/src/posthog_widget_widget.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:ui' as ui; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; @@ -75,18 +73,7 @@ class PostHogWidgetState extends State { screen: Posthog().currentScreen); } - // TODO: package:image/image.dart to convert to jpeg instead - final ByteData? byteData = - await imageInfo.image.toByteData(format: ui.ImageByteFormat.png); - if (byteData == null) { - printIfDebug('Error: Failed to convert image to byte data.'); - return; - } - - Uint8List pngBytes = byteData.buffer.asUint8List(); - imageInfo.image.dispose(); - - await _nativeCommunicator?.sendFullSnapshot(pngBytes, + await _nativeCommunicator?.sendFullSnapshot(imageInfo.imageBytes, id: imageInfo.id, x: imageInfo.x, y: imageInfo.y); } diff --git a/lib/src/replay/screenshot/screenshot_capturer.dart b/lib/src/replay/screenshot/screenshot_capturer.dart index d91e206..80fadda 100644 --- a/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/lib/src/replay/screenshot/screenshot_capturer.dart @@ -1,9 +1,11 @@ import 'dart:math'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/rendering.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/src/replay/mask/image_mask_painter.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; +import 'package:posthog_flutter/src/replay/vendor/equality.dart'; import 'package:posthog_flutter/src/util/logging.dart'; class ImageInfo { @@ -14,13 +16,15 @@ class ImageInfo { final int width; final int height; final bool shouldSendMetaEvent; + final Uint8List imageBytes; ImageInfo(this.image, this.id, this.x, this.y, this.width, this.height, - this.shouldSendMetaEvent); + this.shouldSendMetaEvent, this.imageBytes); } class ViewTreeSnapshotStatus { bool sentMetaEvent = false; + Uint8List? imageBytes; ViewTreeSnapshotStatus(this.sentMetaEvent); } @@ -48,8 +52,8 @@ class ScreenshotCapturer { ViewTreeSnapshotStatus statusView) { if (shouldSendMetaEvent) { statusView.sentMetaEvent = true; - _views[renderObject] = statusView; } + _views[renderObject] = statusView; } Future captureScreenshot() async { @@ -83,6 +87,32 @@ class ScreenshotCapturer { final replayConfig = _config.sessionReplayConfig; + // using png because its compressed, the native SDKs will decompress it + // and transform to jpeg if needed (soon webp) + // https://github.com/brendan-duncan/image does not have webp encoding + final ByteData? byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + printIfDebug('Error: Failed to convert image to byte data.'); + image.dispose(); + return null; + } + + Uint8List pngBytes = byteData.buffer.asUint8List(); + image.dispose(); + + if (pngBytes.isEmpty) { + printIfDebug('Error: Failed to convert image byte data to Uint8List.'); + return null; + } + + if (const PHListEquality().equals(pngBytes, statusView.imageBytes)) { + printIfDebug('Snapshot is the same as the last one.'); + return null; + } + + statusView.imageBytes = pngBytes; + if (replayConfig.maskAllTexts || replayConfig.maskAllImages) { final screenElementsRects = await PostHogMaskController.instance.getCurrentScreenRects(); @@ -97,7 +127,8 @@ class ScreenshotCapturer { globalPosition.dy.toInt(), srcWidth.toInt(), srcHeight.toInt(), - shouldSendMetaEvent); + shouldSendMetaEvent, + pngBytes); _updateStatusView(shouldSendMetaEvent, renderObject, statusView); return imageInfo; } @@ -110,7 +141,8 @@ class ScreenshotCapturer { globalPosition.dy.toInt(), srcWidth.toInt(), srcHeight.toInt(), - shouldSendMetaEvent); + shouldSendMetaEvent, + pngBytes); _updateStatusView(shouldSendMetaEvent, renderObject, statusView); return imageInfo; } catch (e) { diff --git a/lib/src/replay/vendor/equality.dart b/lib/src/replay/vendor/equality.dart new file mode 100644 index 0000000..6ce27f6 --- /dev/null +++ b/lib/src/replay/vendor/equality.dart @@ -0,0 +1,101 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Adapted from https://github.com/dart-lang/core/blob/7f9f597e64fa52faebd3c0a2214f61a7b081174d/pkgs/collection/lib/src/equality.dart#L164 + +// ignore_for_file: library_private_types_in_public_api + +const int _hashMask = 0x7fffffff; + +/// A generic equality relation on objects. +abstract class _Equality { + const factory _Equality() = _DefaultEquality; + + /// Compare two elements for being equal. + /// + /// This should be a proper equality relation. + bool equals(E e1, E e2); + + /// Get a hashcode of an element. + /// + /// The hashcode should be compatible with [equals], so that if + /// `equals(a, b)` then `hash(a) == hash(b)`. + int hash(E e); + + /// Test whether an object is a valid argument to [equals] and [hash]. + /// + /// Some implementations may be restricted to only work on specific types + /// of objects. + bool isValidKey(Object? o); +} + +/// Equality of objects that compares only the natural equality of the objects. +/// +/// This equality uses the objects' own [Object.==] and [Object.hashCode] for +/// the equality. +/// +/// Note that [equals] and [hash] take `Object`s rather than `E`s. This allows +/// `E` to be inferred as `Null` in const contexts where `E` wouldn't be a +/// compile-time constant, while still allowing the class to be used at runtime. +class _DefaultEquality implements _Equality { + const _DefaultEquality(); + @override + bool equals(Object? e1, Object? e2) => e1 == e2; + @override + int hash(Object? e) => e.hashCode; + @override + bool isValidKey(Object? o) => true; +} + +/// Equality on lists. +/// +/// Two lists are equal if they have the same length and their elements +/// at each index are equal. +/// +/// This is effectively the same as [IterableEquality] except that it +/// accesses elements by index instead of through iteration. +/// +/// The [equals] and [hash] methods accepts `null` values, +/// even if the [isValidKey] returns `false` for `null`. +/// The [hash] of `null` is `null.hashCode`. +class PHListEquality implements _Equality> { + final _Equality _elementEquality; + const PHListEquality( + [_Equality elementEquality = const _DefaultEquality()]) + : _elementEquality = elementEquality; + + @override + bool equals(List? list1, List? list2) { + if (identical(list1, list2)) return true; + if (list1 == null || list2 == null) return false; + var length = list1.length; + if (length != list2.length) return false; + for (var i = 0; i < length; i++) { + if (!_elementEquality.equals(list1[i], list2[i])) return false; + } + return true; + } + + @override + int hash(List? list) { + if (list == null) return null.hashCode; + // Jenkins's one-at-a-time hash function. + // This code is almost identical to the one in IterableEquality, except + // that it uses indexing instead of iterating to get the elements. + var hash = 0; + for (var i = 0; i < list.length; i++) { + var c = _elementEquality.hash(list[i]); + hash = (hash + c) & _hashMask; + hash = (hash + (hash << 10)) & _hashMask; + hash ^= hash >> 6; + } + hash = (hash + (hash << 3)) & _hashMask; + hash ^= hash >> 11; + hash = (hash + (hash << 15)) & _hashMask; + return hash; + } + + @override + bool isValidKey(Object? o) => o is List; +}