Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
dc93719
Add session management
camsim99 Jan 10, 2022
cd234ad
Handle receiving results
camsim99 Jan 10, 2022
f463846
Add framework logic, modify engine
camsim99 Jan 12, 2022
3dfae0a
Make engine build
camsim99 Jan 12, 2022
546692b
Add retrieving locale
camsim99 Jan 13, 2022
5f43ff5
Added comments for experimentation
camsim99 Jan 18, 2022
ee73219
Send string back temporarily, fix results representation
camsim99 Feb 2, 2022
624f68b
Formatting
camsim99 Feb 2, 2022
846b195
Merge branch 'main' of github.com:flutter/engine into issue_34688_dev
camsim99 Feb 28, 2022
b27704c
Merge remote-tracking branch 'upstream/main' into issue_34688_dev
camsim99 Mar 16, 2022
64f0ac7
Add new method channel
camsim99 Mar 18, 2022
0cc3776
Add platform setting for spell check
camsim99 Mar 18, 2022
33f2f40
Change method channel name
camsim99 Mar 21, 2022
eb3c731
Remove print statements
camsim99 Mar 22, 2022
1877242
Clean up
camsim99 Mar 23, 2022
5372aaa
Formatting
camsim99 Mar 24, 2022
d9adbdc
Clean PR
camsim99 Mar 28, 2022
34809db
Undo platform plugin changes
camsim99 Mar 28, 2022
10d154d
Clean up code
camsim99 Mar 30, 2022
00c228e
Begin adding tests
camsim99 Mar 30, 2022
e08171f
Formatting
camsim99 Mar 30, 2022
5ccc144
Correct sessions, remove unecessary line
camsim99 Mar 31, 2022
87ab60f
Add tests
camsim99 Mar 31, 2022
f4083d7
Formatting
camsim99 Mar 31, 2022
ce45427
Remove variable
camsim99 Mar 31, 2022
6972b0b
Begin fixing tests
camsim99 Apr 1, 2022
3eba4de
Address reviews
camsim99 Apr 7, 2022
963d9ae
Start fixing systesm setting
camsim99 Apr 8, 2022
6ea2ede
Continue addressing reviews
camsim99 Apr 8, 2022
32c7702
Continue addressing reviews
camsim99 Apr 8, 2022
e9ebd5b
Fix spell checkers check
camsim99 Apr 8, 2022
8298436
Begin fixing tests
camsim99 Apr 9, 2022
993b6c8
Reformat FlutterViewTest
camsim99 Apr 11, 2022
4179f42
Fixing tests
camsim99 Apr 11, 2022
f999488
Merge remote-tracking branch 'upstream/main' into issue_34688_dev
camsim99 Apr 11, 2022
ade72bc
Fix tests minus log errors
camsim99 Apr 11, 2022
c20b62c
Add context shadow to stub tsm request
camsim99 Apr 13, 2022
49583d4
Formatting
camsim99 Apr 13, 2022
49939aa
Fix np error
camsim99 Apr 14, 2022
0cf00f3
Add license
camsim99 Apr 14, 2022
48a4354
Update licenses file
camsim99 Apr 14, 2022
510f83a
Add nativeSpellCheckServiceDefined to other platform dispatchers
camsim99 Apr 15, 2022
d8e333b
Merge remote-tracking branch 'upstream/main' into issue_34688_dev
camsim99 Apr 15, 2022
61908d3
Remove setting
camsim99 Apr 15, 2022
29b095f
Fix typos
camsim99 Apr 15, 2022
ee28058
Update lib/ui/platform_dispatcher.dart
camsim99 Apr 18, 2022
4f0a074
Update lib/ui/window.dart
camsim99 Apr 18, 2022
6c4d437
Update shell/platform/android/io/flutter/embedding/android/FlutterVie…
camsim99 Apr 18, 2022
d73b915
Update shell/platform/android/io/flutter/embedding/engine/systemchann…
camsim99 Apr 18, 2022
66c24ed
Update shell/platform/android/io/flutter/embedding/engine/systemchann…
camsim99 Apr 18, 2022
04a2ce1
Update shell/platform/android/io/flutter/embedding/engine/systemchann…
camsim99 Apr 18, 2022
15d17f5
Update shell/platform/android/io/flutter/embedding/engine/systemchann…
camsim99 Apr 18, 2022
e6414b2
Update shell/platform/android/io/flutter/embedding/engine/systemchann…
camsim99 Apr 18, 2022
2882e3b
Update shell/platform/android/io/flutter/embedding/engine/systemchann…
camsim99 Apr 18, 2022
dfe4689
Update shell/platform/android/io/flutter/plugin/editing/SpellCheckPlu…
camsim99 Apr 18, 2022
cdc6b9c
Update shell/platform/android/io/flutter/plugin/editing/SpellCheckPlu…
camsim99 Apr 18, 2022
65b8850
Update shell/platform/android/io/flutter/plugin/editing/SpellCheckPlu…
camsim99 Apr 18, 2022
036b15b
Make review fixes
camsim99 Apr 18, 2022
188c2ad
Merge branch 'issue_34688_dev' of github.com:camsim99/engine into iss…
camsim99 Apr 18, 2022
8198d4e
Formatting
camsim99 Apr 18, 2022
a2b6c90
Fix spell check test
camsim99 Apr 18, 2022
2aac372
Address nits
camsim99 Apr 21, 2022
894f4e9
Send text to framework
camsim99 Apr 28, 2022
2a4ea66
Push for visibility
camsim99 May 4, 2022
c2a6550
Modify result response protocol
camsim99 May 6, 2022
6125e74
Merge branch 'main' into issue_34688_dev
camsim99 May 9, 2022
74d1477
Merge remote-tracking branch 'upstream/master' into issue_34688_dev
camsim99 May 10, 2022
cc8bf5e
Merge branch 'issue_34688_dev' of github.com:camsim99/engine into iss…
camsim99 May 10, 2022
5a175ec
Formatting and pull
camsim99 May 10, 2022
1c02784
Merge remote-tracking branch 'upstream/master' into issue_34688_dev
camsim99 May 12, 2022
3e74d61
Emulator fix
camsim99 May 13, 2022
975673c
Merge remote-tracking branch 'upstream/main' into issue_34688_dev
camsim99 May 13, 2022
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
Prev Previous commit
Next Next commit
Address reviews
  • Loading branch information
camsim99 committed Apr 7, 2022
commit 3eba4de38a56776904a3819d3198c30a66e6663f
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import io.flutter.Log;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.plugin.common.JSONMethodCodec;
Expand All @@ -12,20 +11,49 @@
import org.json.JSONArray;
import org.json.JSONException;

/**
* {@link SpellCheckChannel} is a platform channel that is used by Flutter to initiate spell check
* in the Android engine and for the Android engine to send back the results.
*
* <p>If the {@link io.flutter.plugin.editing.SpellCheckPlugin} is used to handle spell check
* behavior, (such is the case by default) then there is new text to be spell checked, Flutter will
* send a message to the engine. In response, the {@link io.flutter.plugin.editing.SpellCheckPlugin}
* will make a call to Android's spell check service to fetch spell check results for the specified
* text.
*
* <p>Once the spell check results are received by the {@link
* io.flutter.plugin.editing.SpellCheckPlugin}, a message will be sent back to Flutter with the
* results. See diagram below for overview:
* -----------------------------------------------------------------------------------------------------------------------------
* | From | To | Message | Arguments |
* | ---------------------------------------------------------------------------------------------------------------------------|
* | Flutter | Android Engine | SpellCheck.iniateSpellCheck | {@code String} locale, {@code String} text |
* |----------------------------------------------------------------------------------------------------------------------------|
* | Android Engine | Flutter | SpellCheck.updateSpellCheckResults | {@code ArrayList} of results |
* -----------------------------------------------------------------------------------------------------------------------------
*
* <p>By default, {@link io.flutter.plugin.editing.SpellCheckPlugin} implements {@link
* io.flutter.plugin.common.MethodChannel.MethodCallHandler} to initiate spell check via the Android
* spell check service. Implement {@link SpellCheckMethodHandler} to respond to Flutter spell check
* messages.
*/
public class SpellCheckChannel {
private static final String TAG = "SpellCheckChannel";

@NonNull public final MethodChannel channel;
@Nullable private SpellCheckMethodHandler spellCheckMethodHandler;
Copy link

@blasten blasten Apr 4, 2022

Choose a reason for hiding this comment

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

just fyi. We use these annotations in many places, but they are only somewhat useful in public APIs in the case a consumer of these APIs is using Kotlin.


@NonNull @VisibleForTesting
@NonNull
final MethodChannel.MethodCallHandler parsingMethodHandler =
new MethodChannel.MethodCallHandler() {
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if (spellCheckMethodHandler == null) {
// If no explicit SpellCheckMethodHandler has been registered then we don't
// need to forward this call to an API. Return.
Log.v(
TAG,
"No SpellCheckeMethodHandler registered, call not forwarded to spell check API.");
return;
}
String method = call.method;
Expand All @@ -51,7 +79,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result
};

public SpellCheckChannel(@NonNull DartExecutor dartExecutor) {
this.channel = new MethodChannel(dartExecutor, "flutter/spellcheck", JSONMethodCodec.INSTANCE);
channel = new MethodChannel(dartExecutor, "flutter/spellcheck", JSONMethodCodec.INSTANCE);
channel.setMethodCallHandler(parsingMethodHandler);
}

Expand All @@ -76,6 +104,6 @@ public interface SpellCheckMethodHandler {
* SpellCheckChannel#setSpellCheckMethodHandler(SpellCheckMethodHandler)} once spell check
* results are received from the native spell check service.
*/
void initiateSpellCheck(String locale, String text);
void initiateSpellCheck(@NonNull String locale, @NonNull String text);
}
}
110 changes: 70 additions & 40 deletions shell/platform/android/io/flutter/plugin/editing/SpellCheckPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,50 @@
import android.view.textservice.TextServicesManager;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.systemchannels.SpellCheckChannel;
import io.flutter.plugin.localization.LocalizationPlugin;
import java.util.ArrayList;
import java.util.Locale;

/** Android implementation of the spell check plugin. */
public class SpellCheckPlugin implements SpellCheckerSession.SpellCheckerSessionListener {

@NonNull private final Context mContext;
@NonNull private final SpellCheckChannel mSpellCheckChannel;
@NonNull private final TextServicesManager tsm;
/**
* {@link SpellCheckPlugin} is the implementation of all functionality needed for spell check for
* text input.
*
* <p>The plugin handles requests for spell check sent by the {@link
* io.flutter.embedding.engine.systemchannels.SpellCheckChannel} via sending requests to the Android
* spell checker. It also receive the spell check results from the service and send them back to
* Flutter through the {@link io.flutter.embedding.engine.systemchannels.SpellCheckChannel}.
*/
public class SpellCheckPlugin
implements SpellCheckChannel.SpellCheckMethodHandler,
SpellCheckerSession.SpellCheckerSessionListener {

private final Context mContext;
private final SpellCheckChannel mSpellCheckChannel;
private final TextServicesManager mTextServicesManager;
private SpellCheckerSession mSpellCheckerSession;

// The maximum number of suggestions that the Android spell check service is allowed to provide
// per word.
// Same number that is used by default for Android's {@code TextView}'s.
private static final int MAX_SPELL_CHECK_SUGGESTIONS = 5;

public SpellCheckPlugin(@NonNull Context context, @NonNull SpellCheckChannel spellCheckChannel) {
mContext = context;
mSpellCheckChannel = spellCheckChannel;
tsm = (TextServicesManager) mContext.getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE);
mTextServicesManager =
(TextServicesManager) mContext.getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE);

mSpellCheckChannel.setSpellCheckMethodHandler(
new SpellCheckChannel.SpellCheckMethodHandler() {
@Override
public void initiateSpellCheck(String locale, String text) {
performSpellCheck(locale, text);
}
});
mSpellCheckChannel.setSpellCheckMethodHandler(this);
}

/**
* Unregisters this {@code SpellCheckPlugin} as the {@code
* SpellCheckChannel.SpellCheckMethodHandler}, for the {@link
* io.flutter.embedding.engine.systemchannels.SpellCheckChannel}, and closes the most recently
* opened {@code SpellCheckerSessions}.
*
* <p>Do not invoke any methods on a {@code SpellCheckPlugin} after invoking this method.
*/
public void destroy() {
mSpellCheckChannel.setSpellCheckMethodHandler(null);

Expand All @@ -45,62 +64,73 @@ public void destroy() {
}
}

@Override
public void initiateSpellCheck(@NonNull String locale, @NonNull String text) {
performSpellCheck(locale, text);
Copy link

Choose a reason for hiding this comment

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

Handle case when there's a pending initiateSpellCheck. Maybe, call result.error or result.success with an empty list. The general idea is that while the framework is waiting on a spellcheck request, a new request is issued, so the older one needs to be dropped.

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 approach of dropping older requests has a similar fault to the approach I pursued previously of saving the result and text awaiting a response to that result in the SpellCheckChannel class, which is that we cannot cancel requests to the Android spell checker once they have been submitted.

We already knew that, but our assumption was that if the SpellCheckPlugin has detected a result awaiting a response when another request to initiate spell check has come in, then that request will not reach the onGetSentenceSuggestions(...) callback, meaning that the Android spell checker consumed the request. The consequence of assuming such is that sometimes the plugin will attempt to respond to a new request with old spell check results. Depending on how much the text has been modified between the time those old results come in and the time that the new request to spell check was submitted, those old results could lead to some hard-to-handle errors on the framework side.

Given all of that, I decided to have the plugin only handle one request at a time by dropping new requests when an old one is still pending. This approach may lead to results lagging slightly behind, but (i) the spell check results in any given response will correspond to the text in that response, (ii) the spell check results will be returned in chronological order, and (iii) any impacts of these results lagging behind can be handled by the logic on the framework side. This is all because with this approach, we do not have to battle the unpredictable behavior of the Android spell checker. I believe also that in further iterations, other speedup techniques can be more easily explored given these guarantees.

Copy link
Contributor

Choose a reason for hiding this comment

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

Does this approach seem to work with decent performance in practice? Maybe this is the most solid approach in the real world even though it's not the fastest theoretically.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, is there ever a risk of the spell checker never responding and locking up or is that another thing that we shouldn't worry about in practice?

Copy link
Contributor Author

@camsim99 camsim99 May 10, 2022

Choose a reason for hiding this comment

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

Does this approach seem to work with decent performance in practice? Maybe this is the most solid approach in the real world even though it's not the fastest theoretically.

Yes, exactly. From playing around with it (paired with making the corrections on the framework side if the process of returning results lag behind), I don't see any difference. If you are open to playing around with it, too, that'd be great!

Also, is there ever a risk of the spell checker never responding and locking up or is that another thing that we shouldn't worry about in practice?

I think we can rely on Android for this. Android seems to have some handling for issues like that (see here).

}

/** Calls on the Android spell check API to spell check specified text. */
public void performSpellCheck(String locale, String text) {
String[] localeCodes = locale.split("-");
Locale parsedLocale;

if (localeCodes.length == 3) {
parsedLocale = new Locale(localeCodes[0], localeCodes[1], localeCodes[2]);
} else if (localeCodes.length == 2) {
parsedLocale = new Locale(localeCodes[0], localeCodes[1]);
} else {
parsedLocale = new Locale(localeCodes[0]);
}
Locale localeFromString = LocalizationPlugin.localeFromString(locale);

if (mSpellCheckerSession != null) {
mSpellCheckerSession.close();
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this related to the bug we were looking at where the spell checker would give inconsistent results? So it seems like we start a new session every time the text changes. I think that's right by briefly looking at the android spell check docs. It seems like they want you to keep a session alive if you want to spell check multiple subsets of a larger piece of text? (Probably not a concern of Flutter's right now)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this is related to that bug. I do agree that this agrees with the flow in this diagram if you are interacting with the Android spell check service directly.

When it comes to larger pieces of text, we can definitely play around with this! Although, by the time this becomes a concern for Flutter, it would probably be more preferable to no longer be spell checking the entirety of the inputted text each time anyways.

}
mSpellCheckerSession = tsm.newSpellCheckerSession(null, parsedLocale, this, true);
mSpellCheckerSession =
Copy link
Contributor

Choose a reason for hiding this comment

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

out of curiosity can we reuse the same session for different performSpellCheck calls? The configuration parameters used to create a new session don't seem to be tied to a particular spell check attempt (for instance it doesn't take text)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can, but I found that the spell check results lag behind when I didn't create a new session each time. It also seems like this is the intended way to interact with the spell checker as per this guide.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong Apr 27, 2022

Choose a reason for hiding this comment

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

The guide doesn't seem to imply that each spell check needs a new session.
The android spellchecker seems to only reset the session (resetSession()) when the service is turned on/off, and on locale change:

https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/SpellChecker.java;l=235;drc=408a5565ee6d61fa3dc642de349cedf607d08d94;bpv=0;bpt=1

Copy link
Contributor

Choose a reason for hiding this comment

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

NBD if it's cheap to reset the session. But I'm curious what do you mean by "results lag behind"? The listener may get the response to a previous request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

NBD if it's cheap to reset the session. But I'm curious what do you mean by "results lag behind"? The listener may get the response to a previous request?

I reached out to someone more knowledgeable on the spell checker to find out how expensive it is, and they said it could be expensive, and that it would be ideal to reuse the same session (but this may only be the case until some bugs are fixed; they were unsure).

I had experimented with @justinmc in the past with reusing the session versus reseting it each time, and we noticed that the spell check results didn’t update for a particular word until two additional characters were added, but upon re-test, this appears to have been an issue on the framework side that I have since fixed. So, it would be ideal to reuse the session for each EditableText instance.

out of curiosity can we reuse the same session for different performSpellCheck calls? The configuration parameters used to create a new session don't seem to be tied to a particular spell check attempt (for instance it doesn't take text)

In terms of this proposal, the only problem with reusing the same session is that the SpellCheckerSessionListener is tied to that session, meaning we could not group the text with the spell check results that this listener receives by way of creating different listeners for different requests (see here). Any input on a work around for that?

If we end up having to reset the session each time in order to group the text with the spell check results that correspond to it, we may be looking at a solution that involves reusing the same session up until a certain cutoff of text length where we know the framework would need access to the text to correct for the increase in time it takes to spell check that length text and longer (or something of the like).

Copy link

Choose a reason for hiding this comment

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

the only problem with reusing the same session is that the SpellCheckerSessionListener is tied to that session, meaning we could not group the text with the spell check results that this listener receives by way of creating different listeners for different requests (see here). Any input on a work around for that?

If you are reusing the session, and since you said that calling getSentenceSuggestions multiple times before onGetSentenceSuggestions has been called, results in a single callback with the results for the last call, you could:

  1. Construct newSpellCheckerSession once.
  2. Define a setter in the listener that sets the latest MethodChannel.Result for the latest call to getSentenceSuggestions.
  3. The setter for the latest MethodChannel.Result, must resolve the current MethodChannel.Result by either returning an error, or succeeding with no results. It doesn't matter which one you use because the framework will need to handle either case.
  4. This simplifies the protocol quite a bit. e.g. You don't need to pass "text" to the response, and you don't need a different method to send the response.

Since MethodChannel.Result is the result object for the original request, it's a single request/response from the framework. The framework already knows the text, so it doesn't need additional bookeeping.

I hope that makes sense.

mTextServicesManager.newSpellCheckerSession(
null,
localeFromString,
this,
/** referToSpellCheckerLanguageSettings= */
true);

TextInfo[] textInfos = new TextInfo[] {new TextInfo(text)};
mSpellCheckerSession.getSentenceSuggestions(textInfos, 3);
mSpellCheckerSession.getSentenceSuggestions(textInfos, MAX_SPELL_CHECK_SUGGESTIONS);
}

/**
* Callback for Android spell check API that decomposes results and send results through the
* {@link SpellCheckChannel}.
*
* <p>Spell check results will be encoded as a string representing the span of that result, with
* the format [start_index.end_index.suggestion_1,suggestion_2,suggestion_3] where there may be up
* to 5 suggestions.
*/
@Override
public void onGetSentenceSuggestions(SentenceSuggestionsInfo[] results) {
ArrayList<String> spellCheckerSuggestionSpans = new ArrayList<String>();

for (int i = 0; i < results[0].getSuggestionsCount(); i++) {
SuggestionsInfo suggestionsInfo = results[0].getSuggestionsInfoAt(i);
int suggestionsCount = suggestionsInfo.getSuggestionsCount();
if (results.length > 0) {
SentenceSuggestionsInfo spellCheckResults = results[0];

if (suggestionsCount > 0) {
String spellCheckerSuggestionSpan = "";
int start = results[0].getOffsetAt(i);
int length = results[0].getLengthAt(i);
for (int i = 0; i < spellCheckResults.getSuggestionsCount(); i++) {

spellCheckerSuggestionSpan += (String.valueOf(start) + ".");
spellCheckerSuggestionSpan += (String.valueOf(start + (length - 1)) + ".");
SuggestionsInfo suggestionsInfo = spellCheckResults.getSuggestionsInfoAt(i);
int suggestionsCount = suggestionsInfo.getSuggestionsCount();

for (int j = 0; j < suggestionsCount; j++) {
spellCheckerSuggestionSpan += (suggestionsInfo.getSuggestionAt(j) + ",");
}
if (suggestionsCount > 0) {
String spellCheckerSuggestionSpan = "";
int start = spellCheckResults.getOffsetAt(i);
int length = spellCheckResults.getLengthAt(i);

spellCheckerSuggestionSpan += (String.valueOf(start) + ".");
spellCheckerSuggestionSpan += (String.valueOf(start + (length - 1)) + ".");

spellCheckerSuggestionSpans.add(
spellCheckerSuggestionSpan.substring(0, spellCheckerSuggestionSpan.length() - 1));
for (int j = 0; j < suggestionsCount; j++) {
spellCheckerSuggestionSpan += (suggestionsInfo.getSuggestionAt(j) + ",");
Copy link

Choose a reason for hiding this comment

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

could the suggestion include any of the reserved characters? e.g. ,, and .? If they include those, then the code that parses the result might get very confused.

Copy link

Choose a reason for hiding this comment

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

I saw the test case, using . to separate the offsets is ok.

One possible solution would be to save the length of the word before we write the word. Since it's variable length encoding, we have no choice. Another possible solution, (simpler than encoding the length) is to use escape characters. e.g. new line. I don't think new line will be included in the suggestions.

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 believe punctuation would only be in results for grammar check, but I think it's worth fixing this now as I assume we will want to support grammar correction in the future (I plan to file a bug for that). So, at least for now, using new lines should be safe!

}

spellCheckerSuggestionSpans.add(
spellCheckerSuggestionSpan.substring(0, spellCheckerSuggestionSpan.length() - 1));
Copy link

Choose a reason for hiding this comment

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

is spellCheckerSuggestionSpan.substring(0, spellCheckerSuggestionSpan.length() - 1) just spellCheckerSuggestionSpan?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, I'm chopping off the last separator of the suggested replacements list.

}
}
}

mSpellCheckChannel.updateSpellCheckResults(spellCheckerSuggestionSpans);
Copy link
Contributor

@justinmc justinmc Apr 20, 2022

Choose a reason for hiding this comment

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

Similar to my previous comment, if the framework calls initiateSpellCheck multiple times before receiving updateSpellCheckResults, what guarantees does it have about the order of incoming updateSpellCheckResults calls?

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've seen the results consistently come back in order, and like I said in the previous comment, in cases where it takes a bit longer to get spell check results back, the older calls get consumed, so it seems like the spell check service handles this already.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is pretty theoretical but do you think this is a valid problem?

  1. The framework calls initiateSpellCheck with string "recieve".
  2. The engine receives this and calls updateSpellCheckResults.
  3. The user has quickly pasted something new, so framework calls initiateSpellCheck with string "thier".
  4. The engine is called back on updateSpellCheckResults with the suggestions for "recieve" and sends them to the framework.
  5. The framework receives the suggestions for "recieve". The last call it made to initiateSpellCheck was for string "thier", and that's what's in the text field, so it incorrectly applies the suggestions for "recieve" to "thier".

In reality, I think this would only be a problem for a few milliseconds because the framework would then receive the correct suggestions for "thier". These kinds of problems are rare in reality but hard to eliminate because everything is asynchronous. We have similar problems with updateTextEditingState itself, and we currently don't do anything about them, so maybe this is fine here too.

One thing we could do, though, is to send the text (or a version or a hash) along with the suggestions so we could make sure we apply them to the correct text. I don't know if that is worth it here.

@LongCatIsLooong I know you've worked with these asynchronous problems before, any thoughts here? I'm probably being too nitpicky but I want a second opinion to tell me not to worry about this.

Copy link

Choose a reason for hiding this comment

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

In reality, I think this would only be a problem for a few milliseconds because the framework would then receive the correct suggestions for "thier".

I was suggesting to send the string from the request back in the response, so the framework can handle that case. I think it would be great to address this issue.

Copy link
Contributor

Choose a reason for hiding this comment

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

The spell checking process probably should not be initiated before the text input formatters are applied, the onChanged callback is called, and the TextEditingController listeners are notified in the framework (as we allow developers to change the text in these steps), to minimize the chance of this kind of races happening. But if the initiateSpellCheck API is exposed to the developer then I think we should provide a way to allow them to tell if the result is still relevant.

I'm ok with including the original string from the request but that could increase the payload size by a lot if the developer sends over the entire Bible for spellchecking. From the framework's perspective ideally the spell check service should take a String and return a Future<ListOfSuggestions>, with this kind of one-to-one correspondence the text field can determine whether the suggestion results are still relevant, and discards the results if the text has already changed.

Copy link
Contributor Author

@camsim99 camsim99 Apr 22, 2022

Choose a reason for hiding this comment

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

I'm ok with including the original string from the request but that could increase the payload size by a lot if the developer sends over the entire Bible for spellchecking. From the framework's perspective ideally the spell check service should take a String and return a Future<ListOfSuggestions>, with this kind of one-to-one correspondence the text field can determine whether the suggestion results are still relevant, and discards the results if the text has already changed.

I discussed a similar approach with @blasten yesterday and I agree that this is the best way to proceed. Even if I do use the relevant text to ensure the results are still relevant, I don't have to send them to the engine and back, as I already found a way to recall the TextEditingValue that the spell check results are related to (by passing them here).

Down the line, I think exploring techniques for speeding up the entire process like only spell checking sentences that have changed versus the entirety of the inputted text could help, as well, as I suspect that the case @justinmc described would occur more frequently as the length of the text being spell checked increases. I definitely think that case should be addressed before any framework PRs land, but I believe it shouldn't block this PR since potential fixes would be in the framework.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong Apr 27, 2022

Choose a reason for hiding this comment

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

Ah thanks for the clarification! Looking at the framework-side implementation, it seems to be expecting the spellchecking service to guarantee the requests are replied to in FIFO order, but the framework-side API actually doesn't provide the same FIFO guarantee its implementation relies on, to the API consumer (because the signature of the function is (Locale, TextEditingValue) -> Future<List<SpellCheckerSuggestionSpan>>).

For Android and iOS builtin spellchecking services it's easy to satisfy the FIFO requirement but I think this can be a bit too "strong" of a requirement in some other occasions. If someone wants to implement a spellchecking service that, say, fetches results from a web server, it could be a hassle to guarantee the FIFO ordering (and in the end Flutter developers that use the API don't even benefit from that).

Copy link
Contributor Author

@camsim99 camsim99 May 9, 2022

Choose a reason for hiding this comment

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

I do agree it could be hassle, but I do think it could be largely addressed on the framework side. Due to the fact that the framework may attempt to build the TextSpan tree with spell check results that are not up to date, I added some logic there that could do some corrections on those results to build the TextSpanTree as accurate as it can guess. This may address that problem to a large degree, but of course, I acknowledge that it would be best if we could guarantee the FIFO ordering.

}

@Override
@SuppressWarnings("deprecation")
public void onGetSuggestions(SuggestionsInfo[] results) {
// Deprecated callback for Android spell check API; will not use.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ public void performSpellCheckSendsRequestToAndroidSpellCheckService() {
.thenReturn(fakeTextServicesManager);
SpellCheckPlugin spellCheckPlugin = new SpellCheckPlugin(fakeContext, fakeSpellCheckChannel);
SpellCheckerSession fakeSpellCheckerSession = mock(SpellCheckerSession.class);
when(fakeTextServicesManager.newSpellCheckerSession(
null, new Locale("en", "US"), spellCheckPlugin, true))
Locale english_US = new Locale("en", "US");
when(fakeTextServicesManager.newSpellCheckerSession(null, english_US, spellCheckPlugin, true))
.thenReturn(fakeSpellCheckerSession);
int maxSuggestions = 5;

ArgumentCaptor<TextInfo[]> textInfosCaptor = ArgumentCaptor.forClass(TextInfo[].class);
ArgumentCaptor<Integer> maxSuggestionsCaptor = ArgumentCaptor.forClass(Integer.class);
Expand All @@ -105,7 +106,27 @@ null, new Locale("en", "US"), spellCheckPlugin, true))
verify(fakeSpellCheckerSession)
.getSentenceSuggestions(textInfosCaptor.capture(), maxSuggestionsCaptor.capture());
assertEquals("Hello, wrold!", textInfosCaptor.getValue()[0].getText());
assertEquals(Integer.valueOf(3), maxSuggestionsCaptor.getValue());
assertEquals(Integer.valueOf(maxSuggestions), maxSuggestionsCaptor.getValue());
}

@Test
public void performSpellCheckCreatesNewSpellCheckerSession() {
Context fakeContext = mock(Context.class);
SpellCheckChannel fakeSpellCheckChannel = mock(SpellCheckChannel.class);
TextServicesManager fakeTextServicesManager = mock(TextServicesManager.class);
when(fakeContext.getSystemService(Context.TEXT_SERVICES_MANAGER_SERVICE))
.thenReturn(fakeTextServicesManager);
SpellCheckPlugin spellCheckPlugin = new SpellCheckPlugin(fakeContext, fakeSpellCheckChannel);
SpellCheckerSession fakeSpellCheckerSession = mock(SpellCheckerSession.class);
Locale english_US = new Locale("en", "US");
when(fakeTextServicesManager.newSpellCheckerSession(null, english_US, spellCheckPlugin, true))
.thenReturn(fakeSpellCheckerSession);

spellCheckPlugin.performSpellCheck("en-US", "Hello, worl!");
spellCheckPlugin.performSpellCheck("en-US", "Hello, world!");

verify(fakeTextServicesManager, times(2))
.newSpellCheckerSession(null, english_US, spellCheckPlugin, true);
}

@Test
Expand Down