Skip to content

Commit

Permalink
chore: do not send repeated snapshots (#126)
Browse files Browse the repository at this point in the history
  • Loading branch information
marandaneto authored Dec 5, 2024
1 parent d8b1928 commit b98b54c
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 18 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
12 changes: 12 additions & 0 deletions lib/src/posthog_flutter_web_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ Future<dynamic> 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',
Expand Down
15 changes: 1 addition & 14 deletions lib/src/posthog_widget_widget.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,18 +73,7 @@ class PostHogWidgetState extends State<PostHogWidget> {
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);
}

Expand Down
40 changes: 36 additions & 4 deletions lib/src/replay/screenshot/screenshot_capturer.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
}

Expand Down Expand Up @@ -48,8 +52,8 @@ class ScreenshotCapturer {
ViewTreeSnapshotStatus statusView) {
if (shouldSendMetaEvent) {
statusView.sentMetaEvent = true;
_views[renderObject] = statusView;
}
_views[renderObject] = statusView;
}

Future<ImageInfo?> captureScreenshot() async {
Expand Down Expand Up @@ -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();
Expand All @@ -97,7 +127,8 @@ class ScreenshotCapturer {
globalPosition.dy.toInt(),
srcWidth.toInt(),
srcHeight.toInt(),
shouldSendMetaEvent);
shouldSendMetaEvent,
pngBytes);
_updateStatusView(shouldSendMetaEvent, renderObject, statusView);
return imageInfo;
}
Expand All @@ -110,7 +141,8 @@ class ScreenshotCapturer {
globalPosition.dy.toInt(),
srcWidth.toInt(),
srcHeight.toInt(),
shouldSendMetaEvent);
shouldSendMetaEvent,
pngBytes);
_updateStatusView(shouldSendMetaEvent, renderObject, statusView);
return imageInfo;
} catch (e) {
Expand Down
101 changes: 101 additions & 0 deletions lib/src/replay/vendor/equality.dart
Original file line number Diff line number Diff line change
@@ -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<E> {
const factory _Equality() = _DefaultEquality<E>;

/// 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<E> implements _Equality<E> {
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<E> implements _Equality<List<E>> {
final _Equality<E> _elementEquality;
const PHListEquality(
[_Equality<E> elementEquality = const _DefaultEquality<Never>()])
: _elementEquality = elementEquality;

@override
bool equals(List<E>? list1, List<E>? 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<E>? 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<E>;
}

0 comments on commit b98b54c

Please sign in to comment.