Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
333f93a
Add sensitive content widget to default masking
buenaflor Jun 13, 2025
a240674
Add sensitive content widget to default masking
buenaflor Jun 13, 2025
6d1e35e
Merge branch 'main' into replay/add-sensitive-content
buenaflor Jun 13, 2025
74eb694
Update impl
buenaflor Jun 17, 2025
2e01b33
Merge branch 'main' into replay/add-sensitive-content
buenaflor Jul 11, 2025
132f050
Update rule
buenaflor Jul 11, 2025
591b9c3
Update doc
buenaflor Jul 11, 2025
5ce2839
Update
buenaflor Jul 11, 2025
dfce0e8
Update sentry_privacy_options.dart
buenaflor Jul 17, 2025
efa9362
Merge branch 'main' into replay/add-sensitive-content
buenaflor Jul 17, 2025
f76a31d
Update
buenaflor Jul 17, 2025
b928028
Merge branch 'main' into replay/add-sensitive-content
buenaflor Jul 24, 2025
f4c8e58
Update
buenaflor Jul 24, 2025
151a70d
Update impl
buenaflor Jul 24, 2025
c8596cd
Update
buenaflor Jul 28, 2025
dde1132
Update
buenaflor Jul 28, 2025
7c9f696
Update
buenaflor Jul 28, 2025
088cf62
Update
buenaflor Jul 28, 2025
4bbbded
Update
buenaflor Jul 28, 2025
20657ad
Update
buenaflor Jul 29, 2025
32e3fc4
Update
buenaflor Jul 29, 2025
0c59077
Merge branch 'main' into replay/add-sensitive-content
buenaflor Jul 29, 2025
686750f
Update CHANGELOG
buenaflor Jul 29, 2025
1f8bf63
Merge branch 'main' into replay/add-sensitive-content
buenaflor Aug 6, 2025
e38ab52
Merge branch 'main' into replay/add-sensitive-content
buenaflor Oct 7, 2025
33b5751
Merge branch 'main' into replay/add-sensitive-content
buenaflor Nov 11, 2025
87f3e2e
Merge branch 'main' into replay/add-sensitive-content
buenaflor Jan 8, 2026
e8fab32
refactor: enhance flutter version parsing and sensitive content detec…
buenaflor Jan 8, 2026
fbc6634
Add SensitiveContent widget to changelog
buenaflor Jan 8, 2026
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

### Features

- Add [SensitiveContent](https://main-api.flutter.dev/flutter/widgets/SensitiveContent-class.html) widget to default masking ([#2989](https://github.com/getsentry/sentry-dart/pull/2989))

## 9.9.2

### Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,60 @@ abstract class FlutterVersion {
static const String? dartVersion = bool.hasEnvironment('FLUTTER_DART_VERSION')
? String.fromEnvironment('FLUTTER_DART_VERSION')
: null;

/// Parses [version] into components for comparison.
///
/// Returns `null` if the version string is malformed.
///
/// Examples:
/// - "3.33.0" -> FlutterVersionComponents(3, 33)
/// - "4.0.0-pre.1" -> FlutterVersionComponents(4, 0)
/// - "3.24" -> FlutterVersionComponents(3, 24)
/// - "invalid" -> null
static FlutterVersionComponents? parseComponents(String version) {
final dot = version.indexOf('.');
if (dot == -1) return null;

final major = int.tryParse(version.substring(0, dot));
if (major == null) return null;

final nextDot = version.indexOf('.', dot + 1);
final minorEnd = nextDot == -1 ? version.length : nextDot;
final minor = int.tryParse(version.substring(dot + 1, minorEnd));
if (minor == null) return null;

return FlutterVersionComponents(major, minor);
}
}

/// Parsed Flutter version components for comparison.
class FlutterVersionComponents {
final int major;
final int minor;

const FlutterVersionComponents(this.major, this.minor);

/// Returns `true` if this version meets or exceeds the minimum requirement.
///
/// Example:
/// ```dart
/// final version = FlutterVersion.parseComponents('3.33.0');
/// if (version?.meetsMinimum(3, 33) ?? false) {
/// // Flutter 3.33+ feature available
/// }
/// ```
bool meetsMinimum(int minMajor, int minMinor) =>
major > minMajor || (major == minMajor && minor >= minMinor);

@override
bool operator ==(Object other) =>
other is FlutterVersionComponents &&
other.major == major &&
other.minor == minor;

@override
int get hashCode => Object.hash(major, minor);

@override
String toString() => 'FlutterVersionComponents($major, $minor)';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import 'package:sentry/src/event_processor/enricher/flutter_runtime.dart';
import 'package:test/test.dart';

void main() {
group('FlutterVersionComponents', () {
test('stores major and minor correctly', () {
const components = FlutterVersionComponents(3, 33);
expect(components.major, 3);
expect(components.minor, 33);
});

test('equality works correctly', () {
const a = FlutterVersionComponents(3, 33);
const b = FlutterVersionComponents(3, 33);
const c = FlutterVersionComponents(3, 32);

expect(a, equals(b));
expect(a, isNot(equals(c)));
});

test('hashCode is consistent with equality', () {
const a = FlutterVersionComponents(3, 33);
const b = FlutterVersionComponents(3, 33);

expect(a.hashCode, equals(b.hashCode));
});

test('toString returns readable format', () {
const components = FlutterVersionComponents(3, 33);
expect(components.toString(), 'FlutterVersionComponents(3, 33)');
});

group('meetsMinimum', () {
test('returns false when major is less than threshold', () {
expect(
const FlutterVersionComponents(2, 99).meetsMinimum(3, 0), isFalse);
expect(
const FlutterVersionComponents(2, 0).meetsMinimum(3, 33), isFalse);
});

test('returns false when major equals but minor is less than threshold',
() {
expect(
const FlutterVersionComponents(3, 32).meetsMinimum(3, 33), isFalse);
expect(
const FlutterVersionComponents(3, 0).meetsMinimum(3, 33), isFalse);
});

test('returns true when major equals and minor equals threshold', () {
expect(
const FlutterVersionComponents(3, 33).meetsMinimum(3, 33), isTrue);
expect(const FlutterVersionComponents(4, 0).meetsMinimum(4, 0), isTrue);
});

test('returns true when major equals and minor exceeds threshold', () {
expect(
const FlutterVersionComponents(3, 34).meetsMinimum(3, 33), isTrue);
expect(
const FlutterVersionComponents(3, 99).meetsMinimum(3, 33), isTrue);
});

test('returns true when major exceeds threshold', () {
expect(
const FlutterVersionComponents(4, 0).meetsMinimum(3, 33), isTrue);
expect(
const FlutterVersionComponents(5, 0).meetsMinimum(3, 99), isTrue);
expect(
const FlutterVersionComponents(10, 0).meetsMinimum(3, 33), isTrue);
});

test('works with various threshold values', () {
// Testing different thresholds
expect(const FlutterVersionComponents(2, 5).meetsMinimum(2, 5), isTrue);
expect(
const FlutterVersionComponents(2, 4).meetsMinimum(2, 5), isFalse);
expect(const FlutterVersionComponents(1, 0).meetsMinimum(1, 0), isTrue);
expect(
const FlutterVersionComponents(0, 0).meetsMinimum(0, 1), isFalse);
});
});
});

group('FlutterVersion.parseVersion', () {
test('parses standard version format (major.minor.patch)', () {
final result = FlutterVersion.parseComponents('3.33.0');
expect(result, isNotNull);
expect(result!.major, 3);
expect(result.minor, 33);
});

test('parses version with pre-release suffix', () {
final result = FlutterVersion.parseComponents('3.33.0-pre.123');
expect(result, isNotNull);
expect(result!.major, 3);
expect(result.minor, 33);
});

test('parses version with build metadata', () {
final result = FlutterVersion.parseComponents('3.33.0+hotfix.1');
expect(result, isNotNull);
expect(result!.major, 3);
expect(result.minor, 33);
});

test('parses major.minor only (no patch)', () {
final result = FlutterVersion.parseComponents('4.0');
expect(result, isNotNull);
expect(result!.major, 4);
expect(result.minor, 0);
});

test('parses version with large numbers', () {
final result = FlutterVersion.parseComponents('10.100.999');
expect(result, isNotNull);
expect(result!.major, 10);
expect(result.minor, 100);
});

test('returns null for single number (no dot)', () {
expect(FlutterVersion.parseComponents('3'), isNull);
});

test('returns null for non-numeric major', () {
expect(FlutterVersion.parseComponents('abc.33.0'), isNull);
});

test('returns null for non-numeric minor', () {
expect(FlutterVersion.parseComponents('3.abc.0'), isNull);
});

test('returns null for empty string', () {
expect(FlutterVersion.parseComponents(''), isNull);
});

test('returns null for dot only', () {
expect(FlutterVersion.parseComponents('.'), isNull);
});

test('returns null for leading dot', () {
expect(FlutterVersion.parseComponents('.33.0'), isNull);
});

test('returns null for minor with hyphen suffix without patch', () {
// "3.33-beta" -> "33-beta" is not a valid integer
expect(FlutterVersion.parseComponents('3.33-beta'), isNull);
});

test('parses version where minor ends at patch separator', () {
// "3.33.0-beta" -> major=3, minor=33
final result = FlutterVersion.parseComponents('3.33.0-beta');
expect(result, isNotNull);
expect(result!.major, 3);
expect(result.minor, 33);
});

test('handles version 0.0.0', () {
final result = FlutterVersion.parseComponents('0.0.0');
expect(result, isNotNull);
expect(result!.major, 0);
expect(result.minor, 0);
});

test('returns null for malformed version', () {
expect(FlutterVersion.parseComponents('invalid'), isNull);
});
});
}
77 changes: 77 additions & 0 deletions packages/flutter/lib/src/sentry_privacy_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import 'package:meta/meta.dart';
import '../sentry_flutter.dart';
import 'screenshot/masking_config.dart';
import 'screenshot/widget_filter.dart';
// ignore: implementation_imports
import 'package:sentry/src/event_processor/enricher/flutter_runtime.dart'
as flutter_runtime;

/// Configuration of the experimental privacy feature.
class SentryPrivacyOptions {
Expand Down Expand Up @@ -75,6 +78,11 @@ class SentryPrivacyOptions {
));
}

const flutterVersion = flutter_runtime.FlutterVersion.version;
if (flutterVersion != null) {
_maybeAddSensitiveContentRule(rules, flutterVersion);
}

// In Debug mode, check if users explicitly mask (or unmask) widgets that
// look like they should be masked, e.g. Videos, WebViews, etc.
if (runtimeChecker.isDebugMode()) {
Expand Down Expand Up @@ -172,6 +180,75 @@ class SentryPrivacyOptions {
};
}

/// Minimum Flutter version required for SensitiveContent widget support.
const _sensitiveContentMinMajor = 3;
const _sensitiveContentMinMinor = 33;

/// Determines if the SensitiveContent masking rule should be added
/// based on parsed version components.
///
/// Returns `false` if [components] is `null` (malformed version string).
@visibleForTesting
bool shouldAddSensitiveContentRule(
flutter_runtime.FlutterVersionComponents? components) {
return components?.meetsMinimum(
_sensitiveContentMinMajor, _sensitiveContentMinMinor) ??
false;
}

/// Detects if a widget is a SensitiveContent widget at runtime.
///
/// Uses dynamic property access to check for the `sensitivity` property,
/// which is unique to the SensitiveContent widget. This approach works in
/// obfuscated builds where type names are mangled.
///
/// Returns `true` if the widget has a `sensitivity` property that is an
/// [Enum] (as expected from SensitiveContent widget).
@visibleForTesting
bool isSensitiveContentWidget(Widget widget) {
try {
final dynamic dynWidget = widget;
final sensitivity = dynWidget.sensitivity;
// The property must exist AND be an Enum to be considered SensitiveContent.
// This check is done at runtime (not via assert) to ensure consistent
// behavior in both debug and release modes.
if (sensitivity is! Enum) {
return false;
}
return true;
} catch (_) {
// Property not found – not a SensitiveContent widget.
return false;
}
}

/// Adds a masking rule for the [SensitiveContent] widget.
///
/// The rule masks any widget that exposes a `sensitivity` property which is an
/// [Enum]. This is how the [SensitiveContent] widget can be detected
/// without depending on its type directly (which would fail to compile on
/// older Flutter versions).
void _maybeAddSensitiveContentRule(
List<SentryMaskingRule> rules, String flutterVersion) {
final components =
flutter_runtime.FlutterVersion.parseComponents(flutterVersion);
if (!shouldAddSensitiveContentRule(components)) {
return;
}

SentryMaskingDecision maskSensitiveContent(Element element, Widget widget) {
return isSensitiveContentWidget(widget)
? SentryMaskingDecision.mask
: SentryMaskingDecision.continueProcessing;
}

rules.add(SentryMaskingCustomRule<Widget>(
callback: maskSensitiveContent,
name: 'SensitiveContent',
description: 'Mask SensitiveContent widget.',
));
}

SentryMaskingDecision _maskImagesExceptAssets(Element element, Image widget) {
final image = widget.image;
if (image is AssetBundleImageProvider) {
Expand Down
Loading
Loading