Skip to content

Commit d585ea8

Browse files
committed
feat: asset images don't need to be obscured
1 parent 1526023 commit d585ea8

File tree

6 files changed

+123
-40
lines changed

6 files changed

+123
-40
lines changed

flutter/example/lib/main.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,8 @@ Future<void> showDialogWithTextAndImage(BuildContext context) async {
10311031
await DefaultAssetBundle.of(context).loadString('assets/lorem-ipsum.txt');
10321032

10331033
if (!context.mounted) return;
1034+
final imageBytes =
1035+
await DefaultAssetBundle.of(context).load('assets/sentry-wordmark.png');
10341036
await showDialog<void>(
10351037
context: context,
10361038
// gets tracked if using SentryNavigatorObserver
@@ -1044,7 +1046,15 @@ Future<void> showDialogWithTextAndImage(BuildContext context) async {
10441046
child: Column(
10451047
mainAxisSize: MainAxisSize.min,
10461048
children: [
1049+
// Use various ways an image is included in the app.
1050+
// Local asset images are not obscured in replay recording.
10471051
Image.asset('assets/sentry-wordmark.png'),
1052+
Image.asset('assets/sentry-wordmark.png', bundle: rootBundle),
1053+
Image.asset('assets/sentry-wordmark.png',
1054+
bundle: DefaultAssetBundle.of(context)),
1055+
Image.network(
1056+
'https://www.gstatic.com/recaptcha/api2/logo_48.png'),
1057+
Image.memory(imageBytes.buffer.asUint8List()),
10481058
Text(text),
10491059
],
10501060
),

flutter/lib/sentry_flutter.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export 'src/sentry_flutter.dart';
1010
export 'src/sentry_flutter_options.dart';
1111
export 'src/sentry_replay_options.dart';
1212
export 'src/flutter_sentry_attachment.dart';
13-
export 'src/sentry_asset_bundle.dart';
13+
export 'src/sentry_asset_bundle.dart' show SentryAssetBundle;
1414
export 'src/integrations/on_error_integration.dart';
1515
export 'src/screenshot/sentry_screenshot_widget.dart';
1616
export 'src/screenshot/sentry_screenshot_quality.dart';

flutter/lib/src/replay/widget_filter.dart

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import 'package:flutter/services.dart';
12
import 'package:flutter/widgets.dart';
23
import 'package:meta/meta.dart';
3-
import 'package:sentry/sentry.dart';
44

55
import '../../sentry_flutter.dart';
6+
import '../sentry_asset_bundle.dart';
67

78
@internal
89
class WidgetFilter {
@@ -14,11 +15,14 @@ class WidgetFilter {
1415
late double _pixelRatio;
1516
late Rect _bounds;
1617
final _warnedWidgets = <int>{};
18+
final AssetBundle _rootAssetBundle;
1719

1820
WidgetFilter(
1921
{required this.redactText,
2022
required this.redactImages,
21-
required this.logger});
23+
required this.logger,
24+
@visibleForTesting AssetBundle? rootAssetBundle})
25+
: _rootAssetBundle = rootAssetBundle ?? rootBundle;
2226

2327
void obscure(BuildContext context, double pixelRatio, Rect bounds) {
2428
_pixelRatio = pixelRatio;
@@ -57,6 +61,14 @@ class WidgetFilter {
5761
} else if (redactText && widget is EditableText) {
5862
color = widget.style.color;
5963
} else if (redactImages && widget is Image) {
64+
if (widget.image is AssetBundleImageProvider) {
65+
final image = widget.image as AssetBundleImageProvider;
66+
if (isBuiltInAssetImage(image)) {
67+
logger(SentryLevel.debug,
68+
"WidgetFilter skipping asset: $widget ($image).");
69+
return false;
70+
}
71+
}
6072
color = widget.color;
6173
} else {
6274
// No other type is currently obscured.
@@ -115,6 +127,22 @@ class WidgetFilter {
115127
return true;
116128
}
117129

130+
@visibleForTesting
131+
@pragma('vm:prefer-inline')
132+
bool isBuiltInAssetImage(AssetBundleImageProvider image) {
133+
late final AssetBundle? bundle;
134+
if (image is AssetImage) {
135+
bundle = image.bundle;
136+
} else if (image is ExactAssetImage) {
137+
bundle = image.bundle;
138+
} else {
139+
return false;
140+
}
141+
return (bundle == null ||
142+
bundle == _rootAssetBundle ||
143+
(bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle));
144+
}
145+
118146
@pragma('vm:prefer-inline')
119147
void _cantObscure(Widget widget, String message) {
120148
if (!_warnedWidgets.contains(widget.hashCode)) {

flutter/lib/src/sentry_asset_bundle.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:ui';
77

88
import 'package:flutter/services.dart';
99
import 'package:flutter/material.dart';
10+
import 'package:meta/meta.dart';
1011
import 'package:sentry/sentry.dart';
1112

1213
typedef _StringParser<T> = Future<T> Function(String value);
@@ -375,3 +376,9 @@ class SentryAssetBundle implements AssetBundle {
375376
as Future<T>;
376377
}
377378
}
379+
380+
@internal
381+
extension SentryAssetBundleInternal on SentryAssetBundle {
382+
/// Returns the wrapped [AssetBundle].
383+
AssetBundle get bundle => _bundle;
384+
}

flutter/test/replay/test_widget.dart

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import 'dart:typed_data';
2-
31
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
43
import 'package:flutter_test/flutter_test.dart';
54
import 'package:sentry_flutter/sentry_flutter.dart';
65

7-
Future<Element> pumpTestElement(WidgetTester tester) async {
6+
Future<Element> pumpTestElement(WidgetTester tester,
7+
{List<Widget>? children}) async {
88
await tester.pumpWidget(
99
MaterialApp(
1010
home: SentryWidget(
@@ -14,25 +14,26 @@ Future<Element> pumpTestElement(WidgetTester tester) async {
1414
child: Opacity(
1515
opacity: 0.5,
1616
child: Column(
17-
children: <Widget>[
18-
newImage(),
19-
const Padding(
20-
padding: EdgeInsets.all(15),
21-
child: Center(child: Text('Centered text')),
22-
),
23-
ElevatedButton(
24-
onPressed: () {},
25-
child: Text('Button title'),
26-
),
27-
newImage(),
28-
// Invisible widgets won't be obscured.
29-
Visibility(visible: false, child: Text('Invisible text')),
30-
Visibility(visible: false, child: newImage()),
31-
Opacity(opacity: 0, child: Text('Invisible text')),
32-
Opacity(opacity: 0, child: newImage()),
33-
Offstage(offstage: true, child: Text('Offstage text')),
34-
Offstage(offstage: true, child: newImage()),
35-
],
17+
children: children ??
18+
<Widget>[
19+
newImage(),
20+
const Padding(
21+
padding: EdgeInsets.all(15),
22+
child: Center(child: Text('Centered text')),
23+
),
24+
ElevatedButton(
25+
onPressed: () {},
26+
child: Text('Button title'),
27+
),
28+
newImage(),
29+
// Invisible widgets won't be obscured.
30+
Visibility(visible: false, child: Text('Invisible text')),
31+
Visibility(visible: false, child: newImage()),
32+
Opacity(opacity: 0, child: Text('Invisible text')),
33+
Opacity(opacity: 0, child: newImage()),
34+
Offstage(offstage: true, child: Text('Offstage text')),
35+
Offstage(offstage: true, child: newImage()),
36+
],
3637
),
3738
),
3839
),
@@ -43,17 +44,15 @@ Future<Element> pumpTestElement(WidgetTester tester) async {
4344
return TestWidgetsFlutterBinding.instance.rootElement!;
4445
}
4546

46-
Image newImage() => Image.memory(
47-
Uint8List.fromList([
48-
66, 77, 142, 0, 0, 0, 0, 0, 0, 0, 138, 0, 0, 0, 124, 0, 0, 0, 1, 0,
49-
0, 0, 255, 255, 255, 255, 1, 0, 32, 0, 3, 0, 0, 0, 4, 0, 0, 0, 19,
50-
11, 0, 0, 19, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0,
51-
255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 66, 71, 82, 115, 0, 0, 0, 0,
52-
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
53-
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
54-
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 135, 135, 135, 255,
55-
// This comment prevents dartfmt reformatting this to single-item lines.
56-
]),
57-
width: 1,
58-
height: 1,
59-
);
47+
final testImageData = Uint8List.fromList([
48+
66, 77, 142, 0, 0, 0, 0, 0, 0, 0, 138, 0, 0, 0, 124, 0, 0, 0, 1, 0,
49+
0, 0, 255, 255, 255, 255, 1, 0, 32, 0, 3, 0, 0, 0, 4, 0, 0, 0, 19,
50+
11, 0, 0, 19, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0,
51+
255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 66, 71, 82, 115, 0, 0, 0, 0,
52+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
53+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
54+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 135, 135, 135, 255,
55+
// This comment prevents dartfmt reformatting this to single-item lines.
56+
]);
57+
58+
Image newImage() => Image.memory(testImageData, width: 1, height: 1);

flutter/test/replay/widget_filter_test.dart

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
1-
import 'package:flutter/material.dart';
1+
import 'package:flutter/rendering.dart';
2+
import 'package:flutter/services.dart';
3+
import 'package:flutter/widgets.dart';
24
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:sentry_flutter/sentry_flutter.dart';
36
import 'package:sentry_flutter/src/replay/widget_filter.dart';
47

58
import 'test_widget.dart';
69

710
void main() async {
811
TestWidgetsFlutterBinding.ensureInitialized();
912
const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000);
13+
final rootBundle = TestAssetBundle();
14+
final otherBundle = TestAssetBundle();
1015

1116
final createSut =
1217
({bool redactImages = false, bool redactText = false}) => WidgetFilter(
1318
logger: (level, message, {exception, logger, stackTrace}) {},
1419
redactImages: redactImages,
1520
redactText: redactText,
21+
rootAssetBundle: rootBundle,
1622
);
1723

1824
group('redact text', () {
@@ -47,6 +53,32 @@ void main() async {
4753
expect(sut.items.length, 2);
4854
});
4955

56+
// Note: we cannot currently test actual asset images without either:
57+
// - introducing assets to the package because those wouldn't get tree-shaken in final user apps (https://github.com/flutter/flutter/issues/64106)
58+
// - using a mock asset bundle implementation, because the image widget loads AssetManifest.bin first and we don't have a way to mock that (https://github.com/flutter/flutter/issues/126860)
59+
// Therefore we only check the function that actually decides whether the image is a built-in asset image.
60+
for (var newAssetImage in [AssetImage.new, ExactAssetImage.new]) {
61+
testWidgets(
62+
'recognizes ${newAssetImage('').runtimeType} from the root bundle',
63+
(tester) async {
64+
final sut = createSut(redactImages: true);
65+
66+
expect(sut.isBuiltInAssetImage(newAssetImage('')), isTrue);
67+
expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: rootBundle)),
68+
isTrue);
69+
expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: otherBundle)),
70+
isFalse);
71+
expect(
72+
sut.isBuiltInAssetImage(newAssetImage('',
73+
bundle: SentryAssetBundle(bundle: rootBundle))),
74+
isTrue);
75+
expect(
76+
sut.isBuiltInAssetImage(newAssetImage('',
77+
bundle: SentryAssetBundle(bundle: otherBundle))),
78+
isFalse);
79+
});
80+
}
81+
5082
testWidgets('does not redact text when disabled', (tester) async {
5183
final sut = createSut(redactImages: false);
5284
final element = await pumpTestElement(tester);
@@ -63,3 +95,10 @@ void main() async {
6395
});
6496
});
6597
}
98+
99+
class TestAssetBundle extends CachingAssetBundle {
100+
@override
101+
Future<ByteData> load(String key) async {
102+
return ByteData(0);
103+
}
104+
}

0 commit comments

Comments
 (0)