Skip to content

fix: masking semi-transparent widgets #2472

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

Merged
merged 5 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
});
```

### Fixes

- Masking semi-transparent widgets ([#2472](https://github.com/getsentry/sentry-dart/pull/2472))

## 8.11.0-beta.2

### Features
Expand Down
71 changes: 68 additions & 3 deletions flutter/lib/src/screenshot/recorder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart' as widgets;
import 'package:flutter/material.dart' as material;
import 'package:flutter/cupertino.dart' as cupertino;
import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
Expand Down Expand Up @@ -79,10 +81,13 @@

final filter = _widgetFilter;
if (filter != null) {
final colorScheme = context.findColorScheme();
filter.obscure(
context,
pixelRatio,
Rect.fromLTWH(0, 0, srcWidth * pixelRatio, srcHeight * pixelRatio),
context: context,
pixelRatio: pixelRatio,
colorScheme: colorScheme,
bounds: Rect.fromLTWH(
0, 0, srcWidth * pixelRatio, srcHeight * pixelRatio),
);
}

Expand Down Expand Up @@ -137,3 +142,63 @@
}
}
}

extension on widgets.BuildContext {
WidgetFilterColorScheme findColorScheme() {
WidgetFilterColorScheme? result;
visitAncestorElements((el) {
result = getElementColorScheme(el);
return result == null;
});

if (result == null) {
int limit = 20;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume 20 is an arbitrary number right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, completely

visitor(widgets.Element el) {

Check warning on line 156 in flutter/lib/src/screenshot/recorder.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/recorder.dart#L156

Added line #L156 was not covered by tests
// Don't take too much time trying to find the theme.
if (limit-- < 0) {

Check warning on line 158 in flutter/lib/src/screenshot/recorder.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/recorder.dart#L158

Added line #L158 was not covered by tests
return;
}

result ??= getElementColorScheme(el);

Check warning on line 162 in flutter/lib/src/screenshot/recorder.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/recorder.dart#L162

Added line #L162 was not covered by tests
if (result == null) {
el.visitChildren(visitor);

Check warning on line 164 in flutter/lib/src/screenshot/recorder.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/recorder.dart#L164

Added line #L164 was not covered by tests
}
}

visitChildElements(visitor);

Check warning on line 168 in flutter/lib/src/screenshot/recorder.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/recorder.dart#L168

Added line #L168 was not covered by tests
}

assert(material.Colors.white.isOpaque);
assert(material.Colors.black.isOpaque);
result ??= const WidgetFilterColorScheme(
background: material.Colors.white,
defaultMask: material.Colors.black,
defaultTextMask: material.Colors.black,
);

return result!;
}

WidgetFilterColorScheme? getElementColorScheme(widgets.Element el) {
final widget = el.widget;
if (widget is material.MaterialApp || widget is material.Scaffold) {
final colorScheme = material.Theme.of(el).colorScheme;
return WidgetFilterColorScheme(
background: colorScheme.surface.asOpaque(),
defaultMask: colorScheme.primary.asOpaque(),
defaultTextMask: colorScheme.primary.asOpaque(),
);
} else if (widget is cupertino.CupertinoApp) {
final colorScheme = cupertino.CupertinoTheme.of(el);
final textColor = colorScheme.textTheme.textStyle.foreground?.color ??
colorScheme.textTheme.textStyle.color ??
colorScheme.primaryColor;
return WidgetFilterColorScheme(
background: colorScheme.scaffoldBackgroundColor.asOpaque(),
defaultMask: colorScheme.primaryColor.asOpaque(),
defaultTextMask: textColor.asOpaque(),

Check warning on line 199 in flutter/lib/src/screenshot/recorder.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/recorder.dart#L192-L199

Added lines #L192 - L199 were not covered by tests
);
}
return null;
}
}
59 changes: 51 additions & 8 deletions flutter/lib/src/screenshot/widget_filter.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
Expand All @@ -11,7 +11,7 @@
final items = <WidgetFilterItem>[];
final SentryLogger logger;
final SentryMaskingConfig config;
static const _defaultColor = Color.fromARGB(255, 0, 0, 0);
late WidgetFilterColorScheme _scheme;
late double _pixelRatio;
late Rect _bounds;
final _warnedWidgets = <int>{};
Expand All @@ -22,9 +22,18 @@

WidgetFilter(this.config, this.logger);

void obscure(BuildContext context, double pixelRatio, Rect bounds) {
void obscure({
required BuildContext context,
required double pixelRatio,
required Rect bounds,
required WidgetFilterColorScheme colorScheme,
}) {
_pixelRatio = pixelRatio;
_bounds = bounds;
_scheme = colorScheme;
assert(colorScheme.background.isOpaque);
assert(colorScheme.defaultMask.isOpaque);
assert(colorScheme.defaultTextMask.isOpaque);
items.clear();
if (context is Element) {
_process(context);
Expand Down Expand Up @@ -81,7 +90,7 @@
stackTrace: stackTrace);
}
if (parent == null) {
return WidgetFilterItem(_defaultColor, _bounds);
return WidgetFilterItem(_scheme.defaultMask, _bounds);

Check warning on line 93 in flutter/lib/src/screenshot/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/widget_filter.dart#L93

Added line #L93 was not covered by tests
}
element = parent;
widget = element.widget;
Expand Down Expand Up @@ -126,11 +135,23 @@

Color? color;
if (widget is Text) {
color = (widget).style?.color;
color = widget.style?.color;
if (color == null && renderBox is RenderParagraph) {
color = renderBox.text.style?.color;
}
color ??= _scheme.defaultTextMask;

Check warning on line 142 in flutter/lib/src/screenshot/widget_filter.dart

View check run for this annotation

Codecov / codecov/patch

flutter/lib/src/screenshot/widget_filter.dart#L142

Added line #L142 was not covered by tests
} else if (widget is EditableText) {
color = (widget).style.color;
color = widget.style.color ?? _scheme.defaultTextMask;
} else if (widget is Image) {
color = (widget).color;
color = widget.color;
}

// We need to make the color non-transparent or the mask would
// also be partially transparent.
if (color == null) {
color = _scheme.defaultMask;
} else if (!color.isOpaque) {
color = Color.alphaBlend(color, _scheme.background);
}

// test-only code
Expand All @@ -142,7 +163,8 @@
return true;
}());

return WidgetFilterItem(color ?? _defaultColor, rect);
assert(color.isOpaque, 'Mask color must be opaque: $color');
return WidgetFilterItem(color, rect);
}

// We cut off some widgets early because they're not visible at all.
Expand Down Expand Up @@ -207,3 +229,24 @@
return result;
}
}

@internal
extension Opaqueness on Color {
@pragma('vm:prefer-inline')
bool get isOpaque => alpha == 0xff;

@pragma('vm:prefer-inline')
Color asOpaque() => isOpaque ? this : Color.fromARGB(0xff, red, green, blue);
}

@internal
class WidgetFilterColorScheme {
final Color defaultMask;
final Color defaultTextMask;
final Color background;

const WidgetFilterColorScheme(
{required this.defaultMask,
required this.defaultTextMask,
required this.background});
}
78 changes: 65 additions & 13 deletions flutter/test/screenshot/widget_filter_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_flutter/src/screenshot/widget_filter.dart';
Expand All @@ -14,6 +14,10 @@ void main() async {
const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000);
final rootBundle = TestAssetBundle();
final otherBundle = TestAssetBundle();
final colorScheme = WidgetFilterColorScheme(
defaultMask: Colors.white,
defaultTextMask: Colors.green,
background: Colors.red);

final createSut = ({bool redactImages = false, bool redactText = false}) {
final replayOptions = SentryPrivacyOptions();
Expand All @@ -30,29 +34,45 @@ void main() async {
testWidgets('redacts the correct number of elements', (tester) async {
final sut = createSut(redactText: true);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 5);
});

testWidgets('does not redact text when disabled', (tester) async {
final sut = createSut(redactText: false);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 0);
});

testWidgets('does not redact elements that are outside the screen',
(tester) async {
final sut = createSut(redactText: true);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 100, 100));
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: Rect.fromLTRB(0, 0, 100, 100),
colorScheme: colorScheme);
expect(sut.items.length, 1);
});

testWidgets('correctly determines sizes', (tester) async {
final sut = createSut(redactText: true);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 5);
expect(boundsRect(sut.items[0]), '624x48');
expect(boundsRect(sut.items[1]), '169x20');
Expand All @@ -66,7 +86,11 @@ void main() async {
testWidgets('redacts the correct number of elements', (tester) async {
final sut = createSut(redactImages: true);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 3);
});

Expand Down Expand Up @@ -106,22 +130,34 @@ void main() async {
testWidgets('does not redact text when disabled', (tester) async {
final sut = createSut(redactImages: false);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 0);
});

testWidgets('does not redact elements that are outside the screen',
(tester) async {
final sut = createSut(redactImages: true);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 500, 100));
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: Rect.fromLTRB(0, 0, 500, 100),
colorScheme: colorScheme);
expect(sut.items.length, 1);
});

testWidgets('correctly determines sizes', (tester) async {
final sut = createSut(redactImages: true);
final element = await pumpTestElement(tester);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 3);
expect(boundsRect(sut.items[0]), '1x1');
expect(boundsRect(sut.items[1]), '1x1');
Expand All @@ -134,7 +170,11 @@ void main() async {
final element = await pumpTestElement(tester, children: [
SentryMask(Padding(padding: EdgeInsets.all(100), child: Text('foo'))),
]);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 1);
expect(boundsRect(sut.items[0]), '344x248');
});
Expand All @@ -146,7 +186,11 @@ void main() async {
SentryUnmask(newImage()),
SentryUnmask(SentryMask(Text('foo'))),
]);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items, isEmpty);
});

Expand All @@ -155,11 +199,19 @@ void main() async {
final element = await pumpTestElement(tester, children: [
Padding(padding: EdgeInsets.all(100), child: Text('foo')),
]);
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 1);
expect(boundsRect(sut.items[0]), '144x48');
sut.throwInObscure = true;
sut.obscure(element, 1.0, defaultBounds);
sut.obscure(
context: element,
pixelRatio: 1.0,
bounds: defaultBounds,
colorScheme: colorScheme);
expect(sut.items.length, 1);
expect(boundsRect(sut.items[0]), '344x248');
});
Expand Down
Loading