Skip to content

Commit 3c54d06

Browse files
committed
iOS copy native replay screenshot in-memory to native
1 parent 0764150 commit 3c54d06

File tree

6 files changed

+162
-43
lines changed

6 files changed

+162
-43
lines changed

flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,31 @@ - (void)imageWithView:(UIView *_Nonnull)view
2626
invokeMethod:@"captureReplayScreenshot"
2727
arguments:@{@"replayId" : replayId ? replayId : [NSNull null]}
2828
result:^(id value) {
29-
if (value == nil) {
29+
if (value == nil || value == 0) {
3030
NSLog(@"SentryFlutterReplayScreenshotProvider received null "
3131
@"result. "
3232
@"Cannot capture a replay screenshot.");
33-
} else if ([value
34-
isKindOfClass:[FlutterStandardTypedData class]]) {
35-
FlutterStandardTypedData *typedData =
36-
(FlutterStandardTypedData *)value;
37-
UIImage *image = [UIImage imageWithData:typedData.data];
33+
} else if ([value isKindOfClass:[NSDictionary class]]) {
34+
NSDictionary *dict = (NSDictionary *)value;
35+
long address = ((NSNumber *)dict[@"address"]).longValue;
36+
unsigned long length =
37+
((NSNumber *)dict[@"length"]).unsignedLongValue;
38+
NSData *data = [NSData dataWithBytesNoCopy:(void *)address
39+
length:length
40+
freeWhenDone:TRUE];
41+
UIImage *image = [UIImage imageWithData:data];
3842
onComplete(image);
43+
return;
3944
} else if ([value isKindOfClass:[FlutterError class]]) {
4045
FlutterError *error = (FlutterError *)value;
4146
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
4247
@"error: %@. Cannot capture a replay screenshot.",
4348
error.message);
44-
} else {
45-
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
46-
@"unexpected result. "
47-
@"Cannot capture a replay screenshot.");
49+
return;
4850
}
51+
NSLog(@"SentryFlutterReplayScreenshotProvider received an "
52+
@"unexpected result. "
53+
@"Cannot capture a replay screenshot.");
4954
}];
5055
}
5156

flutter/lib/src/native/cocoa/sentry_native_cocoa.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../../replay/replay_config.dart';
1010
import '../../replay/replay_recorder.dart';
1111
import '../../screenshot/recorder.dart';
1212
import '../../screenshot/recorder_config.dart';
13+
import '../native_memory.dart';
1314
import '../sentry_native_channel.dart';
1415
import 'binding.dart' as cocoa;
1516

@@ -35,7 +36,7 @@ class SentryNativeCocoa extends SentryNativeChannel {
3536
_replayRecorder ??=
3637
ReplayScreenshotRecorder(ScreenshotRecorderConfig(), options);
3738

38-
final replayId = call.arguments['replayId'] == null
39+
final replayId = call.arguments ['replayId'] == null
3940
? null
4041
: SentryId.fromId(call.arguments['replayId'] as String);
4142
if (_replayId != replayId) {
@@ -73,7 +74,10 @@ class SentryNativeCocoa extends SentryNativeChannel {
7374
}
7475
}).then(completer.complete, onError: completer.completeError);
7576
});
76-
return completer.future;
77+
final uint8List = await completer.future;
78+
79+
//Malloc memory and copy the data. Native must free it.
80+
return uint8List?.toNativeMemory().toJson();
7781
default:
7882
throw UnimplementedError('Method ${call.method} not implemented');
7983
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import 'dart:ffi';
2+
import 'dart:typed_data';
3+
4+
import 'package:meta/meta.dart';
5+
import 'package:ffi/ffi.dart' as pkg_ffi;
6+
7+
@internal
8+
@immutable
9+
class NativeMemory {
10+
final Pointer<Uint8> pointer;
11+
final int length;
12+
13+
const NativeMemory._(this.pointer, this.length);
14+
15+
factory NativeMemory.fromUint8List(Uint8List source) {
16+
final length = source.length;
17+
final ptr = pkg_ffi.malloc.allocate<Uint8>(length);
18+
if (length > 0) {
19+
ptr.asTypedList(length).setAll(0, source);
20+
}
21+
return NativeMemory._(ptr, length);
22+
}
23+
24+
factory NativeMemory.fromJson(Map<dynamic, dynamic> json) {
25+
final length = json['length'] as int;
26+
final ptr = Pointer<Uint8>.fromAddress(json['address'] as int);
27+
return NativeMemory._(ptr, length);
28+
}
29+
30+
/// Frees the underlying native memory.
31+
/// You must not use this object after freeing.
32+
void free() {
33+
pkg_ffi.malloc.free(pointer);
34+
}
35+
36+
Uint8List asTypedList() => pointer.asTypedList(length);
37+
38+
Map<String, int> toJson() => {
39+
'address': pointer.address,
40+
'length': length,
41+
};
42+
}
43+
44+
@internal
45+
extension Uint8ListNativeMemory on Uint8List {
46+
NativeMemory toNativeMemory() => NativeMemory.fromUint8List(this);
47+
}

flutter/test/mocks.dart

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,11 @@ class NativeChannelFixture {
202202
handler;
203203
static TestDefaultBinaryMessenger get _messenger =>
204204
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
205+
late final codec = StandardMethodCodec();
205206

206207
NativeChannelFixture() {
207208
TestWidgetsFlutterBinding.ensureInitialized();
208-
channel = MethodChannel('test.channel', StandardMethodCodec(), _messenger);
209+
channel = MethodChannel('test.channel', codec, _messenger);
209210
handler = MockCallbacks().methodCallHandler;
210211
when(handler('initNativeSdk', any)).thenAnswer((_) => Future.value());
211212
when(handler('closeNativeSdk', any)).thenAnswer((_) => Future.value());
@@ -214,11 +215,15 @@ class NativeChannelFixture {
214215
}
215216

216217
// Mock this call as if it was invoked by the native side.
217-
Future<ByteData?> invokeFromNative(String method, [dynamic arguments]) async {
218-
final call =
219-
StandardMethodCodec().encodeMethodCall(MethodCall(method, arguments));
220-
return _messenger.handlePlatformMessage(
218+
Future<dynamic> invokeFromNative(String method, [dynamic arguments]) async {
219+
final call = codec.encodeMethodCall(MethodCall(method, arguments));
220+
final byteData = await _messenger.handlePlatformMessage(
221221
channel.name, call, (ByteData? data) {});
222+
if (byteData != null) {
223+
return codec.decodeEnvelope(byteData);
224+
} else {
225+
return null;
226+
}
222227
}
223228
}
224229

flutter/test/native_memory_test.dart

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
@TestOn('vm')
2+
library flutter_test;
3+
4+
import 'dart:ffi';
5+
import 'dart:typed_data';
6+
7+
import 'package:flutter_test/flutter_test.dart';
8+
import 'package:sentry_flutter/src/native/native_memory.dart';
9+
10+
void main() {
11+
final testSrcList = Uint8List.fromList([1, 2, 3]);
12+
13+
test('empty list', () async {
14+
final sut = NativeMemory.fromUint8List(Uint8List.fromList([]));
15+
expect(sut.length, 0);
16+
expect(sut.pointer, isNot(nullptr));
17+
expect(sut.asTypedList(), isEmpty);
18+
sut.free();
19+
});
20+
21+
test('non-empty list', () async {
22+
final sut = NativeMemory.fromUint8List(testSrcList);
23+
expect(sut.length, 3);
24+
expect(sut.pointer, isNot(nullptr));
25+
expect(sut.asTypedList(), testSrcList);
26+
sut.free();
27+
});
28+
29+
test('json', () async {
30+
final sut = NativeMemory.fromUint8List(testSrcList);
31+
final json = sut.toJson();
32+
expect(json['address'], greaterThan(0));
33+
expect(json['length'], 3);
34+
expect(json.entries, hasLength(2));
35+
36+
final sut2 = NativeMemory.fromJson(json);
37+
expect(sut2.toJson(), json);
38+
expect(sut2.asTypedList(), testSrcList);
39+
40+
expect(sut.pointer, sut2.pointer);
41+
expect(sut.length, sut2.length);
42+
sut2.free();
43+
});
44+
}

flutter/test/replay/replay_native_test.dart

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart';
1111
import 'package:mockito/mockito.dart';
1212
import 'package:sentry_flutter/sentry_flutter.dart';
1313
import 'package:sentry_flutter/src/native/factory.dart';
14+
import 'package:sentry_flutter/src/native/native_memory.dart';
1415
import 'package:sentry_flutter/src/native/sentry_native_binding.dart';
1516

1617
import '../mocks.dart';
@@ -76,29 +77,35 @@ void main() {
7677
await sut.init(hub);
7778
});
7879

79-
test('sets replay ID to context', () async {
80-
// verify there was no scope configured before
81-
verifyNever(hub.configureScope(any));
82-
83-
// emulate the native platform invoking the method
84-
await native.invokeFromNative(
85-
mockPlatform.isAndroid
86-
? 'ReplayRecorder.start'
87-
: 'captureReplayScreenshot',
88-
replayConfig);
80+
testWidgets('sets replayID to context', (tester) async {
81+
await tester.runAsync(() async {
82+
// verify there was no scope configured before
83+
verifyNever(hub.configureScope(any));
84+
when(hub.configureScope(captureAny)).thenReturn(null);
8985

90-
// verify the replay ID was set
91-
final closure =
92-
verify(hub.configureScope(captureAny)).captured.single;
93-
final scope = Scope(options);
94-
expect(scope.replayId, isNull);
95-
await closure(scope);
96-
expect(scope.replayId.toString(), replayConfig['replayId']);
86+
// emulate the native platform invoking the method
87+
final future = native.invokeFromNative(
88+
mockPlatform.isAndroid
89+
? 'ReplayRecorder.start'
90+
: 'captureReplayScreenshot',
91+
replayConfig);
92+
await tester.pumpAndSettle(const Duration(seconds: 1));
93+
await future;
94+
95+
// verify the replay ID was set
96+
final closure =
97+
verify(hub.configureScope(captureAny)).captured.single;
98+
final scope = Scope(options);
99+
expect(scope.replayId, isNull);
100+
await closure(scope);
101+
expect(scope.replayId.toString(), replayConfig['replayId']);
102+
});
97103
});
98104

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

103110
// emulate the native platform invoking the method
104111
await native.invokeFromNative('ReplayRecorder.stop');
@@ -116,6 +123,7 @@ void main() {
116123
testWidgets('captures images', (tester) async {
117124
await tester.runAsync(() async {
118125
when(hub.configureScope(captureAny)).thenReturn(null);
126+
119127
await pumpTestElement(tester);
120128
pumpAndSettle() => tester.pumpAndSettle(const Duration(seconds: 1));
121129

@@ -198,17 +206,23 @@ void main() {
198206
expect(capturedImages, equals(fsImages()));
199207
expect(capturedImages.length, count);
200208
} else if (mockPlatform.isIOS) {
201-
var imagaData = native.invokeFromNative(
202-
'captureReplayScreenshot', replayConfig);
203-
await pumpAndSettle();
204-
expect((await imagaData)?.lengthInBytes, greaterThan(3000));
209+
Future<void> captureAndVerify() async {
210+
final future = native.invokeFromNative(
211+
'captureReplayScreenshot', replayConfig);
212+
await pumpAndSettle();
213+
final json = (await future) as Map<dynamic, dynamic>;
214+
215+
expect(json['length'], greaterThan(3000));
216+
expect(json['address'], greaterThan(0));
217+
NativeMemory.fromJson(json).free();
218+
}
219+
220+
await captureAndVerify();
205221

206-
// Happens if the session-replay rate is 0.
222+
// Check everything works if session-replay rate is 0,
223+
// which causes replayId to be 0 as well.
207224
replayConfig['replayId'] = null;
208-
imagaData = native.invokeFromNative(
209-
'captureReplayScreenshot', replayConfig);
210-
await pumpAndSettle();
211-
expect((await imagaData)?.lengthInBytes, greaterThan(3000));
225+
await captureAndVerify();
212226
} else {
213227
fail('unsupported platform');
214228
}

0 commit comments

Comments
 (0)