Skip to content

Commit

Permalink
fix: masking semi-transparent widgets (#2472)
Browse files Browse the repository at this point in the history
* fix: masking semi-transparent widgets

* chore: update changelog

* fix

* cleanup

* fix
  • Loading branch information
vaind authored Dec 10, 2024
1 parent 54e972d commit c3d30f4
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 24 deletions.
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 'dart:ui';

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 @@ class ScreenshotRecorder {

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 @@ class ScreenshotRecorder {
}
}
}

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

if (result == null) {
int limit = 20;
visitor(widgets.Element el) {
// Don't take too much time trying to find the theme.
if (limit-- < 0) {
return;
}

result ??= getElementColorScheme(el);
if (result == null) {
el.visitChildren(visitor);
}
}

visitChildElements(visitor);
}

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(),
);
}
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 @@ class WidgetFilter {
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 @@ class WidgetFilter {

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 @@ class WidgetFilter {
stackTrace: stackTrace);
}
if (parent == null) {
return WidgetFilterItem(_defaultColor, _bounds);
return WidgetFilterItem(_scheme.defaultMask, _bounds);
}
element = parent;
widget = element.widget;
Expand Down Expand Up @@ -126,11 +135,23 @@ class WidgetFilter {

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;
} 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 @@ class WidgetFilter {
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 @@ extension on Element {
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

0 comments on commit c3d30f4

Please sign in to comment.