diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index c4ef29db721..81db67b563b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1923,13 +1923,13 @@ class EditableTextState extends State with AutomaticKeepAliveClien hideToolbar(); _currentPromptRectRange = null; - if (_hasInputConnection) { - if (widget.obscureText && value.text.length == _value.text.length + 1) { - _obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks; - _obscureLatestCharIndex = _value.selection.baseOffset; - } - } + final bool revealObscuredInput = _hasInputConnection + && widget.obscureText + && WidgetsBinding.instance!.window.brieflyShowPassword + && value.text.length == _value.text.length + 1; + _obscureShowCharTicksPending = revealObscuredInput ? _kObscureShowLatestCharCursorTicks : 0; + _obscureLatestCharIndex = revealObscuredInput ? _value.selection.baseOffset : null; _formatAndSetValue(value, SelectionChangedCause.keyboard); } @@ -2621,7 +2621,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (_obscureShowCharTicksPending > 0) { setState(() { - _obscureShowCharTicksPending--; + _obscureShowCharTicksPending = WidgetsBinding.instance!.window.brieflyShowPassword + ? _obscureShowCharTicksPending - 1 + : 0; }); } } @@ -3245,11 +3247,13 @@ class EditableTextState extends State with AutomaticKeepAliveClien String text = _value.text; text = widget.obscuringCharacter * text.length; // Reveal the latest character in an obscured field only on mobile. - if (defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS || - defaultTargetPlatform == TargetPlatform.fuchsia) { - final int? o = - _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null; + const Set mobilePlatforms = { + TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia, + }; + final bool breiflyShowPassword = WidgetsBinding.instance!.window.brieflyShowPassword + && mobilePlatforms.contains(defaultTargetPlatform); + if (breiflyShowPassword) { + final int? o = _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null; 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/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 5e0840181e2..0150e8b0ce7 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -3611,6 +3611,72 @@ void main() { expect((findRenderEditable(tester).text! as TextSpan).text, expectedValue); }); + testWidgets('password briefly shows last character when entered on mobile', (WidgetTester tester) async { + final bool debugDeterministicCursor = EditableText.debugDeterministicCursor; + EditableText.debugDeterministicCursor = false; + addTearDown(() { + EditableText.debugDeterministicCursor = debugDeterministicCursor; + }); + + await tester.pumpWidget(MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + obscureText: true, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + )); + + await tester.enterText(find.byType(EditableText), 'AA'); + await tester.pump(); + await tester.enterText(find.byType(EditableText), 'AAA'); + await tester.pump(); + + expect((findRenderEditable(tester).text! as TextSpan).text, '••A'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect((findRenderEditable(tester).text! as TextSpan).text, '•••'); + }); + + testWidgets('password briefly does not show last character on Android if turned off', (WidgetTester tester) async { + final bool debugDeterministicCursor = EditableText.debugDeterministicCursor; + EditableText.debugDeterministicCursor = false; + addTearDown(() { + EditableText.debugDeterministicCursor = debugDeterministicCursor; + }); + + await tester.pumpWidget(MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + obscureText: true, + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + ), + )); + + await tester.enterText(find.byType(EditableText), 'AA'); + await tester.pump(); + await tester.enterText(find.byType(EditableText), 'AAA'); + await tester.pump(); + + tester.binding.window.brieflyShowPasswordTestValue = false; + addTearDown(() { + tester.binding.window.brieflyShowPasswordTestValue = true; + }); + expect((findRenderEditable(tester).text! as TextSpan).text, '••A'); + await tester.pump(const Duration(milliseconds: 500)); + expect((findRenderEditable(tester).text! as TextSpan).text, '•••'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + expect((findRenderEditable(tester).text! as TextSpan).text, '•••'); + }); + group('a11y copy/cut/paste', () { Future _buildApp(MockTextSelectionControls controls, WidgetTester tester) { return tester.pumpWidget(MaterialApp( diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart index 0ef04f61d97..e07363c94eb 100644 --- a/packages/flutter_test/lib/src/window.dart +++ b/packages/flutter_test/lib/src/window.dart @@ -271,6 +271,15 @@ class TestWindow implements ui.SingletonFlutterWindow { platformDispatcher.onTextScaleFactorChanged = callback; } + @override + bool get brieflyShowPassword => _brieflyShowPasswordTestValue ?? platformDispatcher.brieflyShowPassword; + bool? _brieflyShowPasswordTestValue; + /// Hides the real [brieflyShowPassword] and reports the given + /// `brieflyShowPasswordTestValue` instead. + set brieflyShowPasswordTestValue(bool brieflyShowPasswordTestValue) { // ignore: avoid_setters_without_getters + _brieflyShowPasswordTestValue = brieflyShowPasswordTestValue; + } + @override ui.FrameCallback? get onBeginFrame => platformDispatcher.onBeginFrame; @override diff --git a/packages/flutter_test/test/window_test.dart b/packages/flutter_test/test/window_test.dart index 92ed2e6c1bd..68352ac39ed 100644 --- a/packages/flutter_test/test/window_test.dart +++ b/packages/flutter_test/test/window_test.dart @@ -127,6 +127,18 @@ void main() { ); }); + testWidgets('TestWindow can fake brieflyShowPassword', (WidgetTester tester) async { + verifyThatTestWindowCanFakeProperty( + tester: tester, + realValue: ui.window.brieflyShowPassword, + fakeValue: !ui.window.brieflyShowPassword, + propertyRetriever: () => WidgetsBinding.instance!.window.brieflyShowPassword, + propertyFaker: (TestWidgetsFlutterBinding binding, bool fakeValue) { + binding.window.brieflyShowPasswordTestValue = fakeValue; + }, + ); + }); + testWidgets('TestWindow can fake default route name', (WidgetTester tester) async { verifyThatTestWindowCanFakeProperty( tester: tester,