Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Conversation

@cyanglaz
Copy link
Contributor

Adds SpellCheck Objective C code in engine. This is part of flutter/flutter#34688

The PR can be landed standalone, it does not affect or add features to the current Flutter.

This PR adds a FlutterSpellCheckPlugin to FlutterEngine. This plugin uses a method channel to communicate with dart. The plugin currently has one method: SpellCheck.initiateSpellCheck, which looks for the first misspelled word in the argument String and returns a result map:

{
   @"startIndex": 0,
   @"endIndex" : 5,
   @"suggestions": [@"suggestion1", @"suggestion2"..]
 }

Design doc: https://docs.google.com/document/d/10Lvkz802QpzAGFi3Q9WVTMVUV-SPJyG3_xs8XfxG59Q/edit?usp=sharing

Pre-launch Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I read and followed the Flutter Style Guide and the C++, Objective-C, Java style guides.
  • I listed at least one issue that this PR fixes in the description above.
  • I added new tests to check the change I am making or feature I am adding, or Hixie said the PR is test-exempt. See testing the engine for instructions on
    writing and running engine tests.
  • I updated/added relevant documentation (doc comments with ///).
  • I signed the CLA.
  • All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel on Discord.

_binaryMessenger.parent = nil;
[_binaryMessenger release];
[_isolateId release];
[self.flutterSpellCheckPlugin release];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't reference self in dealloc.

#import <UIKit/UIApplication.h>
#import <UIKit/UIKit.h>

#include "flutter/fml/logging.h"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea why we ever #include instead of #import in the darwin shell?

- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel {
self = [super init];
if (self) {
[self.methodChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, set the backing ivar instead of using self property getter in an initializer

// @"endIndex" : 5,
// @"suggestions": [@"suggestion1", @"suggestion2"..]
// }
- (NSDictionary*)kInitiateSpellCheck:(id)args {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't match the static NSString constant.

Suggested change
- (NSDictionary*)kInitiateSpellCheck:(id)args {
- (NSDictionary*)initiateSpellCheck:(id)args {

startingAt:0
wrap:NO
language:language];
if (misspelledRange.length <= 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's more idiomatic to check for location != NSNotFound but length != 0 is fine too. You don't need <=0 since length is a NSUInteger though.

}

- (void)dealloc {
[self.textChecker release];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[self.textChecker release];
[_textChecker release];

// @"suggestions": [@"suggestion1", @"suggestion2"..]
// }
- (NSDictionary*)kInitiateSpellCheck:(id)args {
NSString* language = args[0];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this check if this is in availableLanguages and either bail if not, or allow nil and fall back to the first availableLanguages preference? Doc says https://developer.apple.com/documentation/uikit/uitextchecker/1621033-availablelanguages?language=objc

An array of strings representing ISO 639-1 language codes or combined ISO 639-1 language codes and ISO 3166-1 regional codes (for example, en_US).

Copy link
Contributor Author

@cyanglaz cyanglaz Apr 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. we should check this.
I prefer bailing rather than assuming a language for the user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I can do some tests with an "unavailable" language and see what the return values are for rangeOfMisspelledWordInString. the result might just be as the same as bailing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value is (0, 0), so using a "unsupported" language is the same as "there is no spell checking error", which seems make sense to me.

The worry is that it makes harder for user to notice if they use a "unsupported" language. I think it is still necessary for us to check for it, and return an error in the method channel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update:
No suggestions now returns an empty array
Unsupported language now returns nil.

Copy link
Member

@jmagman jmagman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nits, LGTM

result([self initiateSpellCheck:args]);
FlutterError* error;
NSDictionary* spellCheckResult = [self initiateSpellCheck:args withError:&error];
if (error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more idiomatic in Objective-C to check if the return value is nil, and then process the error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A returned nil value is valid without an error in our case here: that is when spell-check didn't find a misspelled word.
So if we check nil first, it will be:

if (! spellCheckResult && error) {
...
} else {
...
}

That's essentially the same with an extra unnecessary check.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe initiateSpellCheck: should return something else to indicate a spell check didn't find a misspelled word?

Since NSNotFound is not defined by us, it would be error prone to return NSNotFound to dart and rely on that. Maybe we can use -1 as the startingIndex when no misspelled word is found? So:

{
  startIndex: -1
  endIndex: -1
  suggestions: []
}

This way, initiateSpellCheck: only returns nil when there's an error, which better matches Objective-C idiomatic. WDYT

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A returned nil value is valid without an error in our case here: that is when spell-check didn't find a misspelled word.

Can we make that case return an empty dictionary instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I will change it to empty array as we are changing this API to return all the spell errors.

capturedResult = result;
}];
XCTAssertTrue([capturedResult isKindOfClass:[FlutterError class]]);
XCTAssertEqual(((FlutterError*)capturedResult).code, @"unsupported_language");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
XCTAssertEqual(((FlutterError*)capturedResult).code, @"unsupported_language");
XCTAssertEqualObjects((FlutterError*)capturedResult).code, @"unsupported_language");

// @"endIndex" : 5,
// @"suggestions": [@"suggestion1", @"suggestion2"..]
// }
- (NSDictionary*)initiateSpellCheck:(id)args withError:(FlutterError**)error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- (NSDictionary*)initiateSpellCheck:(id)args withError:(FlutterError**)error {
- (NSDictionary<NSString *, id>*)initiateSpellCheck:(id)args withError:(FlutterError**)error {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this name is kind of confusing, it's not like it's kicking off a spell check to be processed later.

How about something like spellCheckSuggestionsForText:inLanguage:withError: where the arg indexing happens one layer up?

@chinmaygarde
Copy link
Member

@jmagman has open questions and suggestions. @cyanglaz Any progress on this PR?

@cyanglaz
Copy link
Contributor Author

cyanglaz commented May 5, 2022

@chinmaygarde Thank you for the reminder:) I switched my attention and started banging my head on flutter/flutter#103076 as it has a higher priority. I will circle back to this PR once I have some progress on the other issue.

Copy link
Contributor Author

@cyanglaz cyanglaz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmagman Updated to return all spell check errors in the String.
PTAL

result([self initiateSpellCheck:args]);
FlutterError* error;
NSDictionary* spellCheckResult = [self initiateSpellCheck:args withError:&error];
if (error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I will change it to empty array as we are changing this API to return all the spell errors.

// @"suggestions": [@"suggestion1", @"suggestion2"..]
// }
- (NSDictionary*)kInitiateSpellCheck:(id)args {
NSString* language = args[0];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update:
No suggestions now returns an empty array
Unsupported language now returns nil.

@cyanglaz cyanglaz force-pushed the ios_spell_check branch from 52e8f75 to 35ab1a7 Compare May 9, 2022 20:14
Comment on lines 590 to 591
self.flutterSpellCheckPlugin =
[[[FlutterSpellCheckPlugin alloc] initWithMethodChannel:_spellCheckChannel.get()] retain];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Failures for clang-tidy on /opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:592:1: error: Potential leak of an object of type 'FlutterSpellCheckPlugin *' [clang-analyzer-osx.cocoa.RetainCount,-warnings-as-errors]
}
^
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:528:7: note: Assuming the condition is false
  if ([_initialRoute length] > 0) {
      ^~~~~~~~~~~~~~~~~~~~~~~~~~
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:528:3: note: Taking false branch
  if ([_initialRoute length] > 0) {
  ^
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:591:8: note: Method returns an instance of FlutterSpellCheckPlugin with a +1 retain count
      [[[FlutterSpellCheckPlugin alloc] initWithMethodChannel:_spellCheckChannel.get()] retain];
       ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:591:7: note: Reference count incremented. The object now has a +2 retain count
      [[[FlutterSpellCheckPlugin alloc] initWithMethodChannel:_spellCheckChannel.get()] retain];
      ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm:592:1: note: Object leaked: allocated object of type 'FlutterSpellCheckPlugin *' is not referenced later in this execution path and has a retain count of +2
}

#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h"

#import <Foundation/Foundation.h>
#import <UIKit/UIApplication.h>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#import <UIKit/UIApplication.h>

This is already imported with #import <UIKit/UIKit.h>

self = [super init];
if (self) {
[_methodChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
[self handleMethodCall:call result:result];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this isn't a retain cycle in MRC since it doesn't increase the retain count. Does it need to check if self is nil? Brain hurts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! We should check if self is nil.

startingAt:(NSInteger)startingOffset
wrap:(BOOL)wrapFlag
language:(NSString*)language {
return self.startingIndexToRange[@(startingOffset)].rangeValue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NSRangeFromString is already a thing.

FML_DCHECK([UITextChecker.availableLanguages containsObject:language]);
NSRange misspelledRange =
[self.textChecker rangeOfMisspelledWordInString:text
range:NSMakeRange(0, text.length)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this API handle empty/nil strings?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It handles empty String, which I added a test for.
We won't get a nil here as I have added some assert above.

Comment on lines 142 to 144
NSArray* suggestions1 = @[ @"suggestion 1", @"suggestion 2" ];
NSArray* suggestions2 = @[ @"suggestion 3", @"suggestion 4" ];
[self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(0, 5)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like you're mostly testing out your mocks here. Are you sure it's thoroughly testing the corner cases of the range math, like the last part of the string being misspelled, that every letter is included in the ranges, empty strings?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like the last part of the string being misspelled
Example: "This is missspelled" where the last word is being tested.

that every letter is included in the ranges
Example "missspelled" where the entire string is one misspelled word.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offline discussion: we need one more test to check the while loop stops when nextStartingIndex is out of bounds.

nextOffset = [nextSpellSuggestion[@"location"] integerValue] +
[nextSpellSuggestion[@"length"] integerValue];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are NSUIntegers.

result[@"location"] = @(misspelledRange.location);
result[@"length"] = @(misspelledRange.length);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of splitting the range you can use NSValue, see valueWithRange:. Or better, create a new object with range and suggestions properties? Or a dictionary with the NSValue as a key and the suggestions. The string keys needed between two methods seems unnecessary.

@jmagman
Copy link
Member

jmagman commented May 16, 2022

Friendly ping @cyanglaz to check out the clang-tidy complaint.

NSString* method = call.method;
NSArray* args = call.arguments;
if ([method isEqualToString:kInitiateSpellCheck]) {
FML_DCHECK(args.count == 2);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assert both fields cannot be nil.
They can still be NSNull if dart passes null to this method channel. I added a bail below when either of them is NSNull

FML_DCHECK([UITextChecker.availableLanguages containsObject:language]);
NSRange misspelledRange =
[self.textChecker rangeOfMisspelledWordInString:text
range:NSMakeRange(0, text.length)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It handles empty String, which I added a test for.
We won't get a nil here as I have added some assert above.

Copy link
Contributor Author

@cyanglaz cyanglaz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmagman Updated, ptal

Comment on lines 142 to 144
NSArray* suggestions1 = @[ @"suggestion 1", @"suggestion 2" ];
NSArray* suggestions2 = @[ @"suggestion 3", @"suggestion 4" ];
[self mockUITextCheckerWithExpectedMisspelledWordRange:NSMakeRange(0, 5)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offline discussion: we need one more test to check the while loop stops when nextStartingIndex is out of bounds.

nextOffset =
nextSpellSuggestion.misspelledRange.location + nextSpellSuggestion.misspelledRange.length;
}
} while (nextSpellSuggestion != nil && nextOffset < text.length);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nextOffset < text.length :)

Comment on lines 136 to 137
// stop if nsnotfound

// if the startingIndex is last

// Test to make sure the while loop that checks all the misspelled word stops when the a
// `NSNotFound` is found.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably didn't mean to leave this in 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't mean to leave the first 2 lines in but i want to leave the "Test to make sure.." in. This serves as a little explanation to the test as what logic is the test testings.

suggestions:(NSArray<NSString*>*)suggestions {
self = [super init];
if (self) {
_suggestions = suggestions;
Copy link
Member

@jmagman jmagman May 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_suggestions = suggestions;
_suggestions = [suggestions copy];

result[@"location"] = @(_misspelledRange.location);
result[@"length"] = @(_misspelledRange.length);
result[@"suggestions"] = [_suggestions copy];
return result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Failures for clang-tidy on /opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm:
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm:159:3: error: Potential leak of an object of type 'id' [clang-analyzer-osx.cocoa.RetainCount,-warnings-as-errors]
return result;
^~~~~~~~~~~~~
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm:158:28: note: Method returns an Objective-C object with a +1 retain count
result[@"suggestions"] = [_suggestions copy];
^~~~~~~~~~~~~~~~~~~
/opt/s/w/ir/cache/builder/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm:159:3: note: Object leaked: allocated object of type 'id' is not referenced later in this execution path and has a retain count of +1
return result;
^~~~~~~~~~~~~

draft

spellcheck objc

review

throw error if language is not supported

run spell check to the end of the string

convert to use scoped_nsobject to match other plugins

some review fixes

review

clang-tidy, remove unnecessary comments
@cyanglaz cyanglaz added the waiting for tree to go green This PR is approved and tested, but waiting for the tree to be green to land. label May 19, 2022
@fluttergithubbot fluttergithubbot merged commit cba7bf3 into flutter:main May 19, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request May 20, 2022
engine-flutter-autoroll added a commit to engine-flutter-autoroll/flutter that referenced this pull request May 20, 2022
_spellCheckChannel.reset([[FlutterMethodChannel alloc]
initWithName:@"flutter/spellcheck"
binaryMessenger:self.binaryMessenger
codec:[FlutterJSONMethodCodec sharedInstance]]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive by: new codecs should avoid using the JSON codec.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What codec should be used?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlutterStandardMethodCodec

@cyanglaz cyanglaz deleted the ios_spell_check branch May 23, 2022 23:01
cyanglaz pushed a commit that referenced this pull request May 23, 2022
houhuayong pushed a commit to houhuayong/engine that referenced this pull request Jun 21, 2022
auto-submit bot pushed a commit that referenced this pull request Jul 11, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

platform-ios waiting for tree to go green This PR is approved and tested, but waiting for the tree to be green to land.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants