Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS copy native replay screenshot in-memory to native #2530

Merged
merged 9 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Enhancements

- Replay: improve performance of screenshot data to native recorder ([#2530](https://github.com/getsentry/sentry-dart/pull/2530))

## 8.12.0-beta.2

### Deprecations
Expand Down
16 changes: 11 additions & 5 deletions flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,23 @@ - (void)imageWithView:(UIView *_Nonnull)view
NSLog(@"SentryFlutterReplayScreenshotProvider received null "
@"result. "
@"Cannot capture a replay screenshot.");
} else if ([value
isKindOfClass:[FlutterStandardTypedData class]]) {
FlutterStandardTypedData *typedData =
(FlutterStandardTypedData *)value;
UIImage *image = [UIImage imageWithData:typedData.data];
} else if ([value isKindOfClass:[NSDictionary class]]) {
NSDictionary *dict = (NSDictionary *)value;
long address = ((NSNumber *)dict[@"address"]).longValue;
NSNumber *length = ((NSNumber *)dict[@"length"]);
NSData *data =
[NSData dataWithBytesNoCopy:(void *)address
length:length.unsignedLongValue
freeWhenDone:TRUE];
UIImage *image = [UIImage imageWithData:data];
onComplete(image);
return;
} else if ([value isKindOfClass:[FlutterError class]]) {
FlutterError *error = (FlutterError *)value;
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
@"error: %@. Cannot capture a replay screenshot.",
error.message);
return;
} else {
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
@"unexpected result. "
Expand Down
6 changes: 5 additions & 1 deletion flutter/lib/src/native/cocoa/sentry_native_cocoa.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '../../replay/replay_config.dart';
import '../../replay/replay_recorder.dart';
import '../../screenshot/recorder.dart';
import '../../screenshot/recorder_config.dart';
import '../native_memory.dart';
import '../sentry_native_channel.dart';
import 'binding.dart' as cocoa;

Expand Down Expand Up @@ -73,7 +74,10 @@ class SentryNativeCocoa extends SentryNativeChannel {
}
}).then(completer.complete, onError: completer.completeError);
});
return completer.future;
final uint8List = await completer.future;

// Malloc memory and copy the data. Native must free it.
return uint8List?.toNativeMemory().toJson();
default:
throw UnimplementedError('Method ${call.method} not implemented');
}
Expand Down
45 changes: 45 additions & 0 deletions flutter/lib/src/native/native_memory.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'dart:ffi';
import 'dart:typed_data';

import 'package:meta/meta.dart';
import 'package:ffi/ffi.dart' as pkg_ffi;

@internal
@immutable
class NativeMemory {
final Pointer<Uint8> pointer;
final int length;

const NativeMemory._(this.pointer, this.length);

factory NativeMemory.fromUint8List(Uint8List source) {
final length = source.length;
final ptr = pkg_ffi.malloc.allocate<Uint8>(length);
if (length > 0) {
ptr.asTypedList(length).setAll(0, source);
}
return NativeMemory._(ptr, length);
}

factory NativeMemory.fromJson(Map<dynamic, dynamic> json) {
final length = json['length'] as int;
final ptr = Pointer<Uint8>.fromAddress(json['address'] as int);
return NativeMemory._(ptr, length);
}

/// Frees the underlying native memory.
/// You must not use this object after freeing.
void free() => pkg_ffi.malloc.free(pointer);
vaind marked this conversation as resolved.
Show resolved Hide resolved

Uint8List asTypedList() => pointer.asTypedList(length);

Map<String, int> toJson() => {
'address': pointer.address,
'length': length,
};
}

@internal
extension Uint8ListNativeMemory on Uint8List {
NativeMemory toNativeMemory() => NativeMemory.fromUint8List(this);
}
15 changes: 10 additions & 5 deletions flutter/test/mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,11 @@ class NativeChannelFixture {
handler;
static TestDefaultBinaryMessenger get _messenger =>
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late final codec = StandardMethodCodec();

NativeChannelFixture() {
TestWidgetsFlutterBinding.ensureInitialized();
channel = MethodChannel('test.channel', StandardMethodCodec(), _messenger);
channel = MethodChannel('test.channel', codec, _messenger);
handler = MockCallbacks().methodCallHandler;
when(handler('initNativeSdk', any)).thenAnswer((_) => Future.value());
when(handler('closeNativeSdk', any)).thenAnswer((_) => Future.value());
Expand All @@ -214,11 +215,15 @@ class NativeChannelFixture {
}

// Mock this call as if it was invoked by the native side.
Future<ByteData?> invokeFromNative(String method, [dynamic arguments]) async {
final call =
StandardMethodCodec().encodeMethodCall(MethodCall(method, arguments));
return _messenger.handlePlatformMessage(
Future<dynamic> invokeFromNative(String method, [dynamic arguments]) async {
final call = codec.encodeMethodCall(MethodCall(method, arguments));
final byteData = await _messenger.handlePlatformMessage(
channel.name, call, (ByteData? data) {});
if (byteData != null) {
return codec.decodeEnvelope(byteData);
} else {
return null;
}
}
}

Expand Down
44 changes: 44 additions & 0 deletions flutter/test/native_memory_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@TestOn('vm')
library flutter_test;

import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'native_memory_web_mock.dart'
if (dart.library.io) 'package:sentry_flutter/src/native/native_memory.dart';

void main() {
final testSrcList = Uint8List.fromList([1, 2, 3]);

test('empty list', () async {
final sut = NativeMemory.fromUint8List(Uint8List.fromList([]));
expect(sut.length, 0);
expect(sut.pointer.address, greaterThan(0));
expect(sut.asTypedList(), isEmpty);
sut.free();
});

test('non-empty list', () async {
final sut = NativeMemory.fromUint8List(testSrcList);
expect(sut.length, 3);
expect(sut.pointer.address, greaterThan(0));
expect(sut.asTypedList(), testSrcList);
sut.free();
});

test('json', () async {
final sut = NativeMemory.fromUint8List(testSrcList);
final json = sut.toJson();
expect(json['address'], greaterThan(0));
expect(json['length'], 3);
expect(json.entries, hasLength(2));

final sut2 = NativeMemory.fromJson(json);
expect(sut2.toJson(), json);
expect(sut2.asTypedList(), testSrcList);

expect(sut.pointer, sut2.pointer);
expect(sut.length, sut2.length);
sut2.free();
});
}
60 changes: 60 additions & 0 deletions flutter/test/native_memory_web_mock.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'dart:math';
import 'dart:typed_data';

// This is just a mock so `flutter test --platform chrome` works.
// See https://github.com/flutter/flutter/issues/160675
class NativeMemory {
final Pointer<Uint8> pointer;
final int length;

const NativeMemory._(this.pointer, this.length);

factory NativeMemory.fromUint8List(Uint8List source) {
return NativeMemory._(Pointer<Uint8>._store(source), source.length);
}

factory NativeMemory.fromJson(Map<dynamic, dynamic> json) {
return NativeMemory._(
Pointer<Uint8>._load(json['address'] as int), json['length'] as int);
}

void free() {}

Uint8List asTypedList() => _memory[pointer.address]!;

Map<String, int> toJson() => {
'address': pointer.address,
'length': length,
};
}

class Pointer<T> {
final int address;

const Pointer(this.address);

factory Pointer._store(Uint8List data) {
final address = Random().nextInt(999999);
_memory[address] = data;
return Pointer(address);
}

factory Pointer._load(int address) {
return Pointer(address);
}

/// Equality for Pointers only depends on their address.
@override
bool operator ==(Object other) {
if (other is! Pointer) return false;
return address == other.address;
}

/// The hash code for a Pointer only depends on its address.
@override
int get hashCode => address.hashCode;
}

class Uint8 {}

final _memory = <int, Uint8List>{};
67 changes: 41 additions & 26 deletions flutter/test/replay/replay_native_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_flutter/src/native/factory.dart';
import '../native_memory_web_mock.dart'
if (dart.library.io) 'package:sentry_flutter/src/native/native_memory.dart';
import 'package:sentry_flutter/src/native/sentry_native_binding.dart';

import '../mocks.dart';
Expand Down Expand Up @@ -76,29 +78,35 @@ void main() {
await sut.init(hub);
});

test('sets replay ID to context', () async {
// verify there was no scope configured before
verifyNever(hub.configureScope(any));

// emulate the native platform invoking the method
await native.invokeFromNative(
mockPlatform.isAndroid
? 'ReplayRecorder.start'
: 'captureReplayScreenshot',
replayConfig);
testWidgets('sets replayID to context', (tester) async {
await tester.runAsync(() async {
// verify there was no scope configured before
verifyNever(hub.configureScope(any));
when(hub.configureScope(captureAny)).thenReturn(null);

// verify the replay ID was set
final closure =
verify(hub.configureScope(captureAny)).captured.single;
final scope = Scope(options);
expect(scope.replayId, isNull);
await closure(scope);
expect(scope.replayId.toString(), replayConfig['replayId']);
// emulate the native platform invoking the method
final future = native.invokeFromNative(
mockPlatform.isAndroid
? 'ReplayRecorder.start'
: 'captureReplayScreenshot',
replayConfig);
await tester.pumpAndSettle(const Duration(seconds: 1));
await future;

// verify the replay ID was set
final closure =
verify(hub.configureScope(captureAny)).captured.single;
final scope = Scope(options);
expect(scope.replayId, isNull);
await closure(scope);
expect(scope.replayId.toString(), replayConfig['replayId']);
});
});

test('clears replay ID from context', () async {
// verify there was no scope configured before
verifyNever(hub.configureScope(any));
when(hub.configureScope(captureAny)).thenReturn(null);

// emulate the native platform invoking the method
await native.invokeFromNative('ReplayRecorder.stop');
Expand All @@ -116,6 +124,7 @@ void main() {
testWidgets('captures images', (tester) async {
await tester.runAsync(() async {
when(hub.configureScope(captureAny)).thenReturn(null);

await pumpTestElement(tester);
pumpAndSettle() => tester.pumpAndSettle(const Duration(seconds: 1));

Expand Down Expand Up @@ -198,17 +207,23 @@ void main() {
expect(capturedImages, equals(fsImages()));
expect(capturedImages.length, count);
} else if (mockPlatform.isIOS) {
var imagaData = native.invokeFromNative(
'captureReplayScreenshot', replayConfig);
await pumpAndSettle();
expect((await imagaData)?.lengthInBytes, greaterThan(3000));
Future<void> captureAndVerify() async {
final future = native.invokeFromNative(
'captureReplayScreenshot', replayConfig);
await pumpAndSettle();
final json = (await future) as Map<dynamic, dynamic>;

expect(json['length'], greaterThan(3000));
expect(json['address'], greaterThan(0));
NativeMemory.fromJson(json).free();
}

await captureAndVerify();

// Happens if the session-replay rate is 0.
// Check everything works if session-replay rate is 0,
// which causes replayId to be 0 as well.
replayConfig['replayId'] = null;
imagaData = native.invokeFromNative(
'captureReplayScreenshot', replayConfig);
await pumpAndSettle();
expect((await imagaData)?.lengthInBytes, greaterThan(3000));
await captureAndVerify();
} else {
fail('unsupported platform');
}
Expand Down
Loading