From 619ebd67a9000c10ab2d12f1ff848dfd61e7d599 Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Tue, 20 Mar 2018 00:26:10 -0700 Subject: [PATCH] Support password fields for a11y (#15497) * Support password fields for a11y * rename to obscured * Roll engine to c3ab0c9143029f0267a05b99effbfbd280a4901b --- bin/internal/engine.version | 2 +- .../lib/src/rendering/custom_paint.dart | 3 ++ .../flutter/lib/src/rendering/editable.dart | 23 +++++++++++-- .../flutter/lib/src/rendering/proxy_box.dart | 15 +++++++++ .../flutter/lib/src/semantics/semantics.dart | 18 +++++++++++ packages/flutter/lib/src/widgets/basic.dart | 3 ++ .../lib/src/widgets/editable_text.dart | 6 ++-- .../test/widgets/custom_painter_test.dart | 1 + .../test/widgets/editable_text_test.dart | 32 +++++++++++++++++++ .../flutter/test/widgets/semantics_test.dart | 1 + 10 files changed, 99 insertions(+), 5 deletions(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 40520751ed1..2bae9f9dc86 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -1348ab5b63adc18148f161876a4b1cacd5ec0779 +c3ab0c9143029f0267a05b99effbfbd280a4901b diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index 5bd9f4215a3..1b6b6d04f3b 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -831,6 +831,9 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.inMutuallyExclusiveGroup != null) { config.isInMutuallyExclusiveGroup = properties.inMutuallyExclusiveGroup; } + if (properties.obscured != null) { + config.isObscured = properties.obscured; + } if (properties.header != null) { config.isHeader = properties.header; } diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 7cd6b63bb77..2e62705d471 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -132,12 +132,14 @@ class RenderEditable extends RenderBox { this.onSelectionChanged, this.onCaretChanged, this.ignorePointer: false, + bool obscureText: false, }) : assert(textAlign != null), assert(textDirection != null, 'RenderEditable created without a textDirection.'), assert(maxLines == null || maxLines > 0), assert(textScaleFactor != null), assert(offset != null), assert(ignorePointer != null), + assert(obscureText != null), _textPainter = new TextPainter( text: text, textAlign: textAlign, @@ -150,7 +152,8 @@ class RenderEditable extends RenderBox { _maxLines = maxLines, _selectionColor = selectionColor, _selection = selection, - _offset = offset { + _offset = offset, + _obscureText = obscureText { assert(_showCursor != null); assert(!_showCursor.value || cursorColor != null); _tap = new TapGestureRecognizer(debugOwner: this) @@ -160,6 +163,9 @@ class RenderEditable extends RenderBox { ..onLongPress = _handleLongPress; } + /// Character used to obscure text if [obscureText] is true. + static const String obscuringCharacter = '•'; + /// Called when the selection changes. SelectionChangedHandler onSelectionChanged; @@ -175,6 +181,16 @@ class RenderEditable extends RenderBox { /// The default value of this property is false. bool ignorePointer; + /// Whether to hide the text being edited (e.g., for passwords). + bool get obscureText => _obscureText; + bool _obscureText; + set obscureText(bool value) { + if (_obscureText == value) + return; + _obscureText = value; + markNeedsSemanticsUpdate(); + } + Rect _lastCaretRect; /// Marks the render object as needing to be laid out again and have its text @@ -351,7 +367,10 @@ class RenderEditable extends RenderBox { super.describeSemanticsConfiguration(config); config - ..value = text.toPlainText() + ..value = obscureText + ? obscuringCharacter * text.toPlainText().length + : text.toPlainText() + ..isObscured = obscureText ..textDirection = textDirection ..isFocused = hasFocus ..isTextField = true; diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index f5c499a3287..67ae9715497 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -3019,6 +3019,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox { bool textField, bool focused, bool inMutuallyExclusiveGroup, + bool obscured, String label, String value, String increasedValue, @@ -3053,6 +3054,7 @@ class RenderSemanticsAnnotations extends RenderProxyBox { _textField = textField, _focused = focused, _inMutuallyExclusiveGroup = inMutuallyExclusiveGroup, + _obscured = obscured, _label = label, _value = value, _increasedValue = increasedValue, @@ -3201,6 +3203,17 @@ class RenderSemanticsAnnotations extends RenderProxyBox { markNeedsSemanticsUpdate(); } + /// If non-null, sets the [SemanticsNode.isObscured] semantic to the given + /// value. + bool get obscured => _obscured; + bool _obscured; + set obscured(bool value) { + if (obscured == value) + return; + _obscured = value; + markNeedsSemanticsUpdate(); + } + /// If non-null, sets the [SemanticsNode.label] semantic to the given value. /// /// The reading direction is given by [textDirection]. @@ -3638,6 +3651,8 @@ class RenderSemanticsAnnotations extends RenderProxyBox { config.isFocused = focused; if (inMutuallyExclusiveGroup != null) config.isInMutuallyExclusiveGroup = inMutuallyExclusiveGroup; + if (obscured != null) + config.isObscured = obscured; if (label != null) config.label = label; if (value != null) diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 09fabd16491..e2ea34c1341 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -319,6 +319,7 @@ class SemanticsProperties extends DiagnosticableTree { this.textField, this.focused, this.inMutuallyExclusiveGroup, + this.obscured, this.label, this.value, this.increasedValue, @@ -398,6 +399,13 @@ class SemanticsProperties extends DiagnosticableTree { /// For example, a radio button is in a mutually exclusive group because only /// one radio button in that group can be marked as [checked]. final bool inMutuallyExclusiveGroup; + + /// If non-null, whether [value] should be obscured. + /// + /// This option is usually set in combination with [textField] to indicate + /// that the text field contains a password (or other sensitive information). + /// Doing so instructs screen readers to not read out the [value]. + final bool obscured; /// Provides a textual description of the widget. /// @@ -2405,6 +2413,16 @@ class SemanticsConfiguration { _setFlag(SemanticsFlag.isTextField, value); } + /// Whether the [value] should be obscured. + /// + /// This option is usually set in combination with [textField] to indicate + /// that the text field contains a password (or other sensitive information). + /// Doing so instructs screen readers to not read out the [value]. + bool get isObscured => _hasFlag(SemanticsFlag.isObscured); + set isObscured(bool value) { + _setFlag(SemanticsFlag.isObscured, value); + } + /// The currently selected text (or the position of the cursor) within [value] /// if this node represents a text field. TextSelection get textSelection => _textSelection; diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index ce383b47680..948bde4ce36 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4892,6 +4892,7 @@ class Semantics extends SingleChildRenderObjectWidget { bool textField, bool focused, bool inMutuallyExclusiveGroup, + bool obscured, String label, String value, String increasedValue, @@ -4929,6 +4930,7 @@ class Semantics extends SingleChildRenderObjectWidget { textField: textField, focused: focused, inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, + obscured: obscured, label: label, value: value, increasedValue: increasedValue, @@ -5007,6 +5009,7 @@ class Semantics extends SingleChildRenderObjectWidget { textField: properties.textField, focused: properties.focused, inMutuallyExclusiveGroup: properties.inMutuallyExclusiveGroup, + obscured: properties.obscured, label: properties.label, value: properties.value, increasedValue: properties.increasedValue, diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index fd820f35a36..b77e8379641 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -759,6 +759,7 @@ class _Editable extends LeafRenderObjectWidget { onSelectionChanged: onSelectionChanged, onCaretChanged: onCaretChanged, ignorePointer: rendererIgnoresPointer, + obscureText: obscureText, ); } @@ -778,7 +779,8 @@ class _Editable extends LeafRenderObjectWidget { ..offset = offset ..onSelectionChanged = onSelectionChanged ..onCaretChanged = onCaretChanged - ..ignorePointer = rendererIgnoresPointer; + ..ignorePointer = rendererIgnoresPointer + ..obscureText = obscureText; } TextSpan get _styledTextSpan { @@ -801,7 +803,7 @@ class _Editable extends LeafRenderObjectWidget { String text = value.text; if (obscureText) { - text = new String.fromCharCodes(new List.filled(text.length, 0x2022)); + text = RenderEditable.obscuringCharacter * text.length; final int o = obscureShowCharacterAtIndex; if (o != null && o >= 0 && o < text.length) text = text.replaceRange(o, o + 1, value.text.substring(o, o + 1)); diff --git a/packages/flutter/test/widgets/custom_painter_test.dart b/packages/flutter/test/widgets/custom_painter_test.dart index 5b114ab4886..4cea57df9ec 100644 --- a/packages/flutter/test/widgets/custom_painter_test.dart +++ b/packages/flutter/test/widgets/custom_painter_test.dart @@ -415,6 +415,7 @@ void _defineTests() { focused: true, inMutuallyExclusiveGroup: true, header: true, + obscured: true, ), ), ), diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 006ea1414f7..ea17a813e21 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -575,6 +575,38 @@ void main() { semantics.dispose(); }); + testWidgets('password fields have correct semantics', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + + controller.text = 'super-secret-password!!1'; + + await tester.pumpWidget(new MaterialApp( + home: new EditableText( + obscureText: true, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + )); + + final String expectedValue = '•' * controller.text.length; + + expect(semantics, hasSemantics(new TestSemantics( + children: [ + new TestSemantics( + flags: [SemanticsFlag.isTextField, SemanticsFlag.isObscured], + value: expectedValue, + textDirection: TextDirection.ltr, + nextNodeId: -1, + previousNodeId: -1, + ), + ], + ), ignoreTransform: true, ignoreRect: true, ignoreId: true)); + + semantics.dispose(); + }); + group('a11y copy/cut/paste', () { Future _buildApp(MockTextSelectionControls controls, WidgetTester tester) { return tester.pumpWidget(new MaterialApp( diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index e31159f53d0..cdaa8c55250 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -470,6 +470,7 @@ void main() { focused: true, inMutuallyExclusiveGroup: true, header: true, + obscured: true, ) );