Skip to content

Commit 1d139c1

Browse files
authored
Disable spell check for obscured text (flutter#186329)
Fixes flutter#185843 When `EditableText` has `obscureText: true`, spell check should not run or keep previous spell-check results. This makes the inferred spell-check configuration disabled for obscured text, recomputes that configuration when `obscureText` changes, refreshes spell check when obscured text becomes visible again, and ignores async spell-check responses that return after spell check has been disabled. Tests: - `../../bin/flutter test test/widgets/editable_text_test.dart --plain-name='Spell check'` - `../../bin/flutter analyze --no-pub lib/src/widgets/editable_text.dart test/widgets/editable_text_test.dart` - `git diff --check`
1 parent 5bcd096 commit 1d139c1

2 files changed

Lines changed: 277 additions & 9 deletions

File tree

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,6 +2051,11 @@ class EditableText extends StatefulWidget {
20512051
/// Specifies the [SpellCheckService] used to spell check text input and the
20522052
/// [TextStyle] used to style text with misspelled words.
20532053
///
2054+
/// Spell check is disabled for password input, including when [obscureText]
2055+
/// is true, [keyboardType] is [TextInputType.visiblePassword], or
2056+
/// [autofillHints] contains [AutofillHints.password] or
2057+
/// [AutofillHints.newPassword].
2058+
///
20542059
/// If the [SpellCheckService] is left null, spell check is disabled by
20552060
/// default unless the [DefaultSpellCheckService] is supported, in which case
20562061
/// it is used. It is currently supported only on Android and iOS.
@@ -3050,18 +3055,27 @@ class EditableTextState extends State<EditableText>
30503055
/// If spell check is enabled, this will try to infer a value for
30513056
/// the [SpellCheckService] if left unspecified.
30523057
static SpellCheckConfiguration _inferSpellCheckConfiguration(
3053-
SpellCheckConfiguration? configuration,
3054-
) {
3058+
SpellCheckConfiguration? configuration, {
3059+
required bool obscureText,
3060+
required TextInputType keyboardType,
3061+
required Iterable<String>? autofillHints,
3062+
}) {
30553063
final SpellCheckService? spellCheckService = configuration?.spellCheckService;
30563064
final bool spellCheckAutomaticallyDisabled =
3057-
configuration == null || configuration == const SpellCheckConfiguration.disabled();
3065+
_isPasswordInput(
3066+
obscureText: obscureText,
3067+
keyboardType: keyboardType,
3068+
autofillHints: autofillHints,
3069+
) ||
3070+
configuration == null ||
3071+
configuration == const SpellCheckConfiguration.disabled();
30583072
final bool spellCheckServiceIsConfigured =
30593073
spellCheckService != null ||
30603074
WidgetsBinding.instance.platformDispatcher.nativeSpellCheckServiceDefined;
30613075
if (spellCheckAutomaticallyDisabled || !spellCheckServiceIsConfigured) {
30623076
// Only enable spell check if a non-disabled configuration is provided
3063-
// and if that configuration does not specify a spell check service,
3064-
// a native spell checker must be supported.
3077+
// for non-password input and, if that configuration does not specify a
3078+
// spell check service, a native spell checker must be supported.
30653079
assert(() {
30663080
if (!spellCheckAutomaticallyDisabled && !spellCheckServiceIsConfigured) {
30673081
FlutterError.reportError(
@@ -3088,6 +3102,19 @@ class EditableTextState extends State<EditableText>
30883102
);
30893103
}
30903104

3105+
static bool _isPasswordInput({
3106+
required bool obscureText,
3107+
required TextInputType keyboardType,
3108+
required Iterable<String>? autofillHints,
3109+
}) {
3110+
return obscureText ||
3111+
keyboardType == TextInputType.visiblePassword ||
3112+
(autofillHints?.any(
3113+
(String hint) => hint == AutofillHints.password || hint == AutofillHints.newPassword,
3114+
) ??
3115+
false);
3116+
}
3117+
30913118
/// Returns the [ContextMenuButtonItem]s for the given [ToolbarOptions].
30923119
@Deprecated(
30933120
'Use `contextMenuBuilder` instead of `toolbarOptions`. '
@@ -3284,7 +3311,12 @@ class EditableTextState extends State<EditableText>
32843311
widget.controller.addListener(_didChangeTextEditingValue);
32853312
widget.focusNode.addListener(_handleFocusChanged);
32863313
_cursorVisibilityNotifier.value = widget.showCursor;
3287-
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
3314+
_spellCheckConfiguration = _inferSpellCheckConfiguration(
3315+
widget.spellCheckConfiguration,
3316+
obscureText: widget.obscureText,
3317+
keyboardType: widget.keyboardType,
3318+
autofillHints: widget.autofillHints,
3319+
);
32883320
_appLifecycleListener = AppLifecycleListener(onResume: _onResume);
32893321
_initProcessTextActions();
32903322
}
@@ -3493,6 +3525,28 @@ class EditableTextState extends State<EditableText>
34933525
}
34943526
}
34953527

3528+
if (oldWidget.spellCheckConfiguration != widget.spellCheckConfiguration ||
3529+
oldWidget.obscureText != widget.obscureText ||
3530+
oldWidget.keyboardType != widget.keyboardType ||
3531+
!listEquals<String>(
3532+
oldWidget.autofillHints?.toList(growable: false),
3533+
widget.autofillHints?.toList(growable: false),
3534+
)) {
3535+
_spellCheckConfiguration = _inferSpellCheckConfiguration(
3536+
widget.spellCheckConfiguration,
3537+
obscureText: widget.obscureText,
3538+
keyboardType: widget.keyboardType,
3539+
autofillHints: widget.autofillHints,
3540+
);
3541+
if (spellCheckEnabled) {
3542+
if (textEditingValue.text.isNotEmpty) {
3543+
_performSpellCheck(textEditingValue.text);
3544+
}
3545+
} else {
3546+
spellCheckResults = null;
3547+
}
3548+
}
3549+
34963550
if (widget.style != oldWidget.style) {
34973551
// The _textInputConnection will pick up the new style when it attaches in
34983552
// _openInputConnection.
@@ -4614,9 +4668,10 @@ class EditableTextState extends State<EditableText>
46144668
final List<SuggestionSpan>? suggestions = await _spellCheckConfiguration.spellCheckService!
46154669
.fetchSpellCheckSuggestions(localeForSpellChecking!, text);
46164670

4617-
if (suggestions == null || !mounted) {
4671+
if (suggestions == null || !mounted || !spellCheckEnabled) {
46184672
// The request to fetch spell check suggestions was canceled due to ongoing request,
4619-
// or the widget was unmounted.
4673+
// the widget was unmounted, or spell check was disabled before the
4674+
// request completed.
46204675
return;
46214676
}
46224677

packages/flutter/test/widgets/editable_text_test.dart

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15975,6 +15975,204 @@ void main() {
1597515975
);
1597615976
});
1597715977

15978+
testWidgets('Spell check disabled when obscureText is true', (WidgetTester tester) async {
15979+
final fakeSpellCheckService = FakeSpellCheckService();
15980+
controller.text = 'A';
15981+
15982+
await tester.pumpWidget(
15983+
TestWidgetsApp(
15984+
home: EditableText(
15985+
controller: controller,
15986+
focusNode: focusNode,
15987+
obscureText: true,
15988+
style: const TextStyle(),
15989+
cursorColor: const Color(0xFF0000FF),
15990+
backgroundCursorColor: const Color(0xFF808080),
15991+
cursorOpacityAnimates: true,
15992+
autofillHints: null,
15993+
spellCheckConfiguration: SpellCheckConfiguration(
15994+
spellCheckService: fakeSpellCheckService,
15995+
misspelledTextStyle: const TextStyle(decoration: TextDecoration.underline),
15996+
),
15997+
),
15998+
),
15999+
);
16000+
16001+
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
16002+
expect(state.spellCheckEnabled, isFalse);
16003+
expect(state.spellCheckConfiguration, equals(const SpellCheckConfiguration.disabled()));
16004+
});
16005+
16006+
testWidgets('Spell check disabled for visible password input type', (
16007+
WidgetTester tester,
16008+
) async {
16009+
final fakeSpellCheckService = FakeSpellCheckService();
16010+
controller.text = 'A';
16011+
16012+
await tester.pumpWidget(
16013+
TestWidgetsApp(
16014+
home: EditableText(
16015+
controller: controller,
16016+
focusNode: focusNode,
16017+
keyboardType: TextInputType.visiblePassword,
16018+
style: const TextStyle(),
16019+
cursorColor: const Color(0xFF0000FF),
16020+
backgroundCursorColor: const Color(0xFF808080),
16021+
cursorOpacityAnimates: true,
16022+
autofillHints: null,
16023+
spellCheckConfiguration: SpellCheckConfiguration(
16024+
spellCheckService: fakeSpellCheckService,
16025+
misspelledTextStyle: const TextStyle(decoration: TextDecoration.underline),
16026+
),
16027+
),
16028+
),
16029+
);
16030+
16031+
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
16032+
expect(state.spellCheckEnabled, isFalse);
16033+
expect(state.spellCheckConfiguration, equals(const SpellCheckConfiguration.disabled()));
16034+
});
16035+
16036+
testWidgets('Spell check disabled for password autofill hints', (WidgetTester tester) async {
16037+
final fakeSpellCheckService = FakeSpellCheckService();
16038+
controller.text = 'A';
16039+
16040+
await tester.pumpWidget(
16041+
TestWidgetsApp(
16042+
home: EditableText(
16043+
controller: controller,
16044+
focusNode: focusNode,
16045+
style: const TextStyle(),
16046+
cursorColor: const Color(0xFF0000FF),
16047+
backgroundCursorColor: const Color(0xFF808080),
16048+
cursorOpacityAnimates: true,
16049+
autofillHints: const <String>[AutofillHints.password],
16050+
spellCheckConfiguration: SpellCheckConfiguration(
16051+
spellCheckService: fakeSpellCheckService,
16052+
misspelledTextStyle: const TextStyle(decoration: TextDecoration.underline),
16053+
),
16054+
),
16055+
),
16056+
);
16057+
16058+
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
16059+
expect(state.spellCheckEnabled, isFalse);
16060+
expect(state.spellCheckConfiguration, equals(const SpellCheckConfiguration.disabled()));
16061+
});
16062+
16063+
testWidgets('Spell check updates when non-password obscureText changes', (
16064+
WidgetTester tester,
16065+
) async {
16066+
const suggestionSpans = <SuggestionSpan>[
16067+
SuggestionSpan(TextRange(start: 0, end: 1), <String>['a']),
16068+
];
16069+
final fakeSpellCheckService = FakeSpellCheckService(
16070+
suggestionSpansByText: const <String, List<SuggestionSpan>?>{'A': suggestionSpans},
16071+
);
16072+
controller.text = 'A';
16073+
var obscureText = false;
16074+
late StateSetter setState;
16075+
16076+
await tester.pumpWidget(
16077+
TestWidgetsApp(
16078+
home: StatefulBuilder(
16079+
builder: (BuildContext context, StateSetter localSetState) {
16080+
setState = localSetState;
16081+
return EditableText(
16082+
controller: controller,
16083+
focusNode: focusNode,
16084+
obscureText: obscureText,
16085+
style: const TextStyle(),
16086+
cursorColor: const Color(0xFF0000FF),
16087+
backgroundCursorColor: const Color(0xFF808080),
16088+
cursorOpacityAnimates: true,
16089+
autofillHints: null,
16090+
spellCheckConfiguration: SpellCheckConfiguration(
16091+
spellCheckService: fakeSpellCheckService,
16092+
misspelledTextStyle: const TextStyle(decoration: TextDecoration.underline),
16093+
),
16094+
);
16095+
},
16096+
),
16097+
),
16098+
);
16099+
16100+
void setObscureText(bool value) {
16101+
setState(() {
16102+
obscureText = value;
16103+
});
16104+
}
16105+
16106+
EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
16107+
expect(state.spellCheckEnabled, isTrue);
16108+
state.spellCheckResults = const SpellCheckResults('A', suggestionSpans);
16109+
16110+
setObscureText(true);
16111+
await tester.pump();
16112+
16113+
state = tester.state<EditableTextState>(find.byType(EditableText));
16114+
expect(state.spellCheckEnabled, isFalse);
16115+
expect(state.spellCheckConfiguration, equals(const SpellCheckConfiguration.disabled()));
16116+
expect(state.spellCheckResults, isNull);
16117+
expect(fakeSpellCheckService.fetchSpellCheckSuggestionsCallCount, 0);
16118+
16119+
setObscureText(false);
16120+
await tester.pump();
16121+
16122+
state = tester.state<EditableTextState>(find.byType(EditableText));
16123+
expect(state.spellCheckEnabled, isTrue);
16124+
expect(fakeSpellCheckService.fetchSpellCheckSuggestionsCallCount, 1);
16125+
expect(fakeSpellCheckService.lastSpellCheckText, 'A');
16126+
expect(state.spellCheckResults, const SpellCheckResults('A', suggestionSpans));
16127+
});
16128+
16129+
testWidgets('Spell check stays disabled for visible password when obscureText changes', (
16130+
WidgetTester tester,
16131+
) async {
16132+
final fakeSpellCheckService = FakeSpellCheckService();
16133+
controller.text = 'A';
16134+
var obscureText = true;
16135+
late StateSetter setState;
16136+
16137+
await tester.pumpWidget(
16138+
TestWidgetsApp(
16139+
home: StatefulBuilder(
16140+
builder: (BuildContext context, StateSetter localSetState) {
16141+
setState = localSetState;
16142+
return EditableText(
16143+
controller: controller,
16144+
focusNode: focusNode,
16145+
keyboardType: TextInputType.visiblePassword,
16146+
obscureText: obscureText,
16147+
style: const TextStyle(),
16148+
cursorColor: const Color(0xFF0000FF),
16149+
backgroundCursorColor: const Color(0xFF808080),
16150+
cursorOpacityAnimates: true,
16151+
autofillHints: null,
16152+
spellCheckConfiguration: SpellCheckConfiguration(
16153+
spellCheckService: fakeSpellCheckService,
16154+
misspelledTextStyle: const TextStyle(decoration: TextDecoration.underline),
16155+
),
16156+
);
16157+
},
16158+
),
16159+
),
16160+
);
16161+
16162+
EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
16163+
expect(state.spellCheckEnabled, isFalse);
16164+
16165+
setState(() {
16166+
obscureText = false;
16167+
});
16168+
await tester.pump();
16169+
16170+
state = tester.state<EditableTextState>(find.byType(EditableText));
16171+
expect(state.spellCheckEnabled, isFalse);
16172+
expect(state.spellCheckConfiguration, equals(const SpellCheckConfiguration.disabled()));
16173+
expect(fakeSpellCheckService.fetchSpellCheckSuggestionsCallCount, 0);
16174+
});
16175+
1597816176
testWidgets(
1597916177
'Spell check disabled when spell check configuration specified but no default spell check service available',
1598016178
(WidgetTester tester) async {
@@ -19019,7 +19217,22 @@ class _TestScrollController extends ScrollController {
1901919217
bool get attached => hasListeners;
1902019218
}
1902119219

19022-
class FakeSpellCheckService extends DefaultSpellCheckService {}
19220+
class FakeSpellCheckService extends DefaultSpellCheckService {
19221+
FakeSpellCheckService({this.suggestionSpansByText = const <String, List<SuggestionSpan>?>{}});
19222+
19223+
final Map<String, List<SuggestionSpan>?> suggestionSpansByText;
19224+
int fetchSpellCheckSuggestionsCallCount = 0;
19225+
String? lastSpellCheckText;
19226+
19227+
@override
19228+
Future<List<SuggestionSpan>?> fetchSpellCheckSuggestions(Locale locale, String text) async {
19229+
fetchSpellCheckSuggestionsCallCount += 1;
19230+
lastSpellCheckText = text;
19231+
return suggestionSpansByText.containsKey(text)
19232+
? suggestionSpansByText[text]
19233+
: const <SuggestionSpan>[];
19234+
}
19235+
}
1902319236

1902419237
class FakeFlutterView extends TestFlutterView {
1902519238
FakeFlutterView(TestFlutterView view, {required this.viewId})

0 commit comments

Comments
 (0)