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

Conversation

@Renzo-Olivares
Copy link
Contributor

@Renzo-Olivares Renzo-Olivares commented Aug 18, 2021

Description

Design Doc

This change aims to collect the changes made to the editing value in the Android text input plugin, and send them to the framework through a platform channel.

Framework PR: flutter/flutter#88477

Related Issues

Partially fixes flutter/flutter#87972

Tests

  • Added tests for TextEditingDelta verifying the creation of various deltas.

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.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Thanks for building this, it really makes the design doc more real. I left a bunch of questions in the comments, but I think the biggest decisions to make are:

  • Are we sending the right data? Does it meet the needs to the developers that will use this, and are we able to send this data for all of Flutter's platforms?
  • Is this free of asynchronous race condition bugs? Maybe with this simple API, it's up to the consumer in the framework to avoid async problems.

@Override
public SpannableStringBuilder append(char text) {
Log.e("DELTAS", "insert #1 is called");
return append(String.valueOf(text));
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess this is the actual implementation of this append, you're just overriding it in order to be able to log here? And below.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh and you don't seem to need to override these to determine the type, you can just infer it inside of replace.

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 all these overrides where just to see if we actually hit these calls.

selectionEnd,
composingStart,
composingEnd);
textInputChannel.updateEditingStateWithDelta(
Copy link
Contributor

Choose a reason for hiding this comment

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

You may have explained this to me already somewhere, but why not send this as a part of updateEditingState?

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 think putting it in updateEditingState implies that we are going to be updating the TextEditingValue with it, but in fact they are two separate entities. One may want to subscribe to the TextEditingDelta without the TextEditingValue to create their own model.

Copy link
Contributor

Choose a reason for hiding this comment

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

True... Do you still have a POC somewhere of actually creating a simple rich text editor in the framework? Did you use the TextEditingValue coming from updateEditingState at all, or did you do everything with the deltas only?

I'm thinking that if you did need both, it may be very hard to correlate the calls to updateEditingState and updateEditingStateWithDelta with each other in the framework since they are called asynchronously.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In that POC I did everything with TextEditingValue, and just used the deltas as extra information to expand/contract ranges.

I agree a developer should only subscribe to one or the other. I think how it would work on this new model is that a developer either subscribes to updateEditingValue or updateEditingValueWithDelta. If using updateEditingValue then they are using the plain text model where the TextEditingValue is always replaced by the new one. If using updateEditingValueWithDelta they subscribe to deltas that they can apply to their TextEditingValue/or their custom equivalent. This delta should also include the new composing/selection region from the engine so a developer could also apply that to their model.

In regards to how framework updates will be consumed by the engine for now the framework would keep using setEditingState, completely replacing the TextEditingValue on the engine side.

Comment on lines 55 to 60
// Fields for delta.
private String oldText;
private String deltaText;
private String deltaType;
private int deltaStart;
private int deltaEnd;
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be cleaner to have a separate TextEditingDelta class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely!

"DELTAS",
"replace(" + start + ", " + end + ", " + tb + ", " + tbstart + ", " + tbend + ")");

final boolean isDeletionGreaterThanOne = end - (start + tbend) > 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

If we had a TextEditingDelta class (as I mentioned above), then maybe we could encapsulate most of this logic.

} else if (isReplaced) {
Log.e("DELTAS", "REPLACEMENT");
setDeltas(toString(), tb.subSequence(tbstart, tbend).toString(), "REPLACEMENT", start, end);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it ever possible that setDeltas won't be called here? It seems like bugs could arise from that, like sending old deltas data in updateEditingStateWithDelta.

Copy link
Contributor

Choose a reason for hiding this comment

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

And is it ever possible that some crazy change could happen that wouldn't be supported by a single TextEditingDelta? Maybe Android is massaging these types of changes into a single call to replace for you. Like what if you had two insertions at once?

"bbb" => "cbbbc"

Is there any way we can test something like that? I guess Android would just replace the entire "bbb" string rather than trying to do an insertion at the beginning and another at the end.

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 think that might be possible not to hit setDeltas we definitely need to make sure to cover all the cases here. Yeah in the case you mention android would replace the entire string with "cbbbc". I think in this case all we can say is what the platform gives us, and in Android's case it is that the entire word was replaced.


if (isEqual) {
Log.e("DELTAS", "EQUALITY");
setDeltas(toString(), "", "EQUALITY", -1, -1);
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be better to just not call updateEditingStateWithDelta at all in this case?

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, but it is called every time the editing state changes see https://github.com/flutter/engine/pull/28175/files#diff-6d16487c29b469c59861d81341897ed392331e365647f6c13c18459800cc854dR653 I think this is the place we want to put it to keep it in sync with the actual text editing changes.

The editing state could change for reasons not pertaining to the actual raw text chagning, such as a change in the selection/composing region, so I think we need this equality case for these cases.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah so if the user just moves the selection around with the arrow keys, they will get a bunch of EQUALITY deltas.

I wonder how exactly these calls would be used in the framework, similar to my other comment here, to help me see how these equality deltas would be used.

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 use case of this could be.

Developer subscribes to updateEditingValueWithDelta. They update the currentTextEditingValue by applying the delta to it.

Say we are typing "hello"
Engine sends "h" delta
Framework receives "h" delta applies it to currentTextEditingValue.
Engine sends "e" delta
Framework receives "e" delta and applies it to currentTextEditingValue.
.
.
.
.
Engine sends "o" delta
Framework receives "o" delta and applies it to currentTextEditingValue.
User changes selection
Engine sends equality delta to framework
Framework receives equality delta and does nothing (or updates their current selection/composing region with the new one).

Writing this makes me think that we should also be sending the new selection/composing region with this delta. So when a developer gets a delta from updateEditingValueWithDelta they also get the updated selection/composing region they can apply to the TextEditingValue/whatever structure they have representing a TextEditingValue. Any thoughts on this?

Log.e(TAG, "editing state should not be changed in a listener callback");
}

Log.e(
Copy link
Contributor

Choose a reason for hiding this comment

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

Weill replace be called when the engine receives a text change from the framework?

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 replace is called when the engine receives a text change. It replaces the entire value with the one from the framework.

Copy link
Contributor

Choose a reason for hiding this comment

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

Is it useful to setDeltas in that case? Could that delta ever end up being sent back to the framework when it shouldn't be?

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Looks much cleaner with the TextEditingDelta class, though I left one comment about maybe refactoring it a bit.

I also responded to some comments outside of this review above accidentally, FYI.

return deltaEnd;
}

private void setDeltas(
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be cleaner to create a new TextEditingDelta instance rather than always mutating one instance? That would definitely be my recommendation in Dart though I'm less confident about these patterns in Java.

Then reasonDeltas could be a constructor.

deltaType = type;
}

public void reasonDeltas(
Copy link
Contributor

Choose a reason for hiding this comment

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

When you get to writing tests, it will be nice to be able to test all of this logic directly without needing to spin up a TextInputChannel and everything.

@Renzo-Olivares Renzo-Olivares force-pushed the text_editing_deltas_android branch from 0447ae2 to 796a826 Compare August 29, 2021 02:55
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Check out my comments but I think this is otherwise ready to open to broader review.

mImm.restartInput(view);
mRestartInputPending = false;
} else {
mEditable.clearBatchDeltas();
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Consider commenting here and other places where clearBatchDeltas is called to explain why it's being called there. At a high level it makes sense to clear it when the framework is sending an update, but it seems really nuanced to know when exactly to clear. If it's easily explainable then a comment might be good.

Comment on lines 662 to 665
// Make sure last delta has the most up to date composing region.
// This can happen when the IME is restarted because the framework has changed the
// composing region. We do not have access to the new composing region until after
// the restart has completed.
Copy link
Contributor

Choose a reason for hiding this comment

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

Tricky.

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason to special case the composing region change caused by IME? Shouldn't the information already be captured by the deltas recorded by the ListenableEditingState class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For some reason when I capture it in ListenableEditingState the composing region is still (-1,-1). It is not until I arrive at ListenableEditingState that I'm able to grab the most up to date composing regions. I think this is somewhat consistent to how updateEditingState works, where we send the composing/selection regions we grab in didChangeEditingState.

I have observed that this is only the case when the composing region restarts. In other cases the selection/composing regions are up to date after the editable has been mutated with replace.

}

textInputChannel.updateEditingStateWithDeltas(inputTarget.id, batchTextEditingDeltas);
mEditable.clearBatchDeltas();
Copy link
Contributor

Choose a reason for hiding this comment

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

This clearBatchDeltas makes sense!


if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) {
// Autofilling the current client is the same as handling user input
// from the virtual keyboard. Setting the editable to newState and an
Copy link
Contributor

Choose a reason for hiding this comment

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

By "virtual keyboard" do you mean the usual Android on-screen keyboard? I think you should refer to it as the "soft keyboard" based on my understanding of Flutter's usual terminology, unless you've seen this way elsewhere. In the framework there is a PR referring to virtual keyboards as keyboards implemented in-app in Flutter itself (flutter/flutter#68988), so that made me notice this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Eh I guess we do have virtualId here... I guess the naming is up to you, whatever you think is best.

Copy link
Contributor

Choose a reason for hiding this comment

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

The virtualId here is the id of the virtual view structure used for autofill.

assertEquals(0, delta.getNewComposingStart());
assertEquals(5, delta.getNewComposingEnd());
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for thoroughly testing the TextEditingDelta constructor!

@Renzo-Olivares Renzo-Olivares changed the title [WIP] TextEditingDelta support for Android TextEditingDelta support for Android Aug 30, 2021
@Renzo-Olivares Renzo-Olivares marked this pull request as ready for review August 30, 2021 19:47
"TextInputClient.updateEditingStateWithTag", Arrays.asList(inputClientId, json));
}

public void updateEditingStateWithDeltasWithTag(
Copy link
Contributor

Choose a reason for hiding this comment

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

This method is for autofill only (which generally just replaces all the current content of the text field). So it probably shouldn't be using deltas. Also it's unlikely that users would want to autofill a rich text field.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, responded to this topic in another comment.

// This can happen when the IME is restarted because the framework has changed the
// composing region. We do not have access to the new composing region until after
// the restart has completed.
if (mEditable.getBatchTextEditingDeltas().size() - 1 >= 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: collection.size() - 1 >= 0 => !collection.isEmpty() (not sure if there's an isNotEmpty() method).

Also the else if can just be else I think?

mEditable.popTextEditingDeltaFromList();
mEditable.addTextEditingDeltaToList(delta);
} else if (mEditable.getBatchTextEditingDeltas().size() == 0) {
// Make sure we always have at least one delta when triggering a framework update, in
Copy link
Contributor

Choose a reason for hiding this comment

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

What would be a case that there's no deltas recorded but the engine has a different text editing value and needs to update the framework?

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'm not sure I follow this example. If the engine has changed its text editing value then it has most likely gone through the replace method and generated a delta.

Comment on lines 662 to 665
// Make sure last delta has the most up to date composing region.
// This can happen when the IME is restarted because the framework has changed the
// composing region. We do not have access to the new composing region until after
// the restart has completed.
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason to special case the composing region change caused by IME? Shouldn't the information already be captured by the deltas recorded by the ListenableEditingState class?

final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>();
for (int i = 0; i < values.size(); i++) {
int virtualId = values.keyAt(i);
if (configuration.enableDeltaModel) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably don't want to enable delta model for autofill?

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 thought about this, I had included it just because I was trying to get every feature that works with the TextField on the engine side working with this delta model. I agree rich text editors will probably not be using autofill, do you think it is alright to not include this then? I was thinking this might be good to have for the future but I guess are plans for this are not concrete.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the thing autofill does is slightly different from any of the delta types specified: it replaces the entirety of the document, instead of the content in [0, currentLength). They're slightly different since we're dealing with async communications.

For example if the user autofills a password field and at the same time the framework changes the text to be longer, then we want the obscured field has the right characters in it, instead of just replacing the original range.

}

public void popTextEditingDeltaFromList() {
mBatchTextEditingDeltas.remove(mBatchTextEditingDeltas.size() - 1);
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: do java lists have "pop()" or something similar?

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 did not find an equivalent in the docs.


private static final String TAG = "TextEditingDelta";

public TextEditingDelta(
Copy link
Contributor

Choose a reason for hiding this comment

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

I was expecting that we can just extract the tb{start, end}, start, end and tb from the replace implementation and send them verbatim to the framework, and the framework will have to implement the same replace method to apply it to a TextEditingValue. Would that not work?

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Aug 31, 2021

Choose a reason for hiding this comment

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

That wouldn't work in some cases. Say the case where we are typing "world".

Current state: "worl|"
soft keyboard input: "d"
replace method call: replace(0,4, world, 0, 5)

or for a deletion

Current state: "world|"
soft keyboard input: backspace
replace method call: replace(0,5, worl, 0, 4)

If typing at the end of the composing region the text in the composing region is replaced with the entire new composing text.

If we move the cursor inside the word currently being composed like below we get this call.
current state: "wo|ld"
soft keyboard input: "r"
replace method call: replace(2,2, r, 0,1)

Here we are actually given the insertion point and the character inserted, where when it is at the end of the composing region we must do some reasoning.

On iOS we have something slightly different as well.

Current state: "world|"
soft keyboard input: backspace
replaceRangeWithLocal (4,5) with empty string ranges (0,0)

another thing iOS does is when typing with a CJK keyboard it will exhibit behavior similar to Android composing region where the entire composing region is replaced on input.

Because these values can vary a bit by platform for the same operation I think it is important to unify them into one format. I'm unsure if a replacement of "world" with "worl" would have the same result as replacing "d" in "world" with an empty string regarding if ranges of "world" where bolded/styled. Would these ranges still have the information needed to contract/expand as expected?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah makes sense, thank you for the clarification! Do you think we'll be able to extract that info directly from the InputConnection interface (instead of replace)? It doesn't have to be implemented that way right now if it is (it's probably going to be a little complicated to implement since InputConnectionAdaptor and TextInputPlugin currently don't directly talk to each other). I'm just wondering if we can avoid the manual diffing process here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately from my research of the API I don't think so. Even the InputConnectionAdaptor does not provide these granular differences between editing states.

InputConnectionAdaptor.commitText and InputConnectionAdaptor.setComposingText both call their super class BaseInputConnection which calls replaceText() and routes that to SpannableStringBuilder.replace(). Only in some cases will the IME actually provide granular changes like in InputConnectionAdaptor.deleteSurroundingText which does call SpannableStringBuilder.delete() that is in the end also routed to replace(). In most cases for Android since the composing region is always active we are always replacing the entire composing region.

Maybe I missed something but this is my conclusion after my research.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see. So setComposingText and commit* can delete/insert/replace, but they all only operate on a specific range.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd prefer a bit more documentation in this class. If this were the entrypoint for someone learning about this feature, the documentation here is a bit lacking to properly introduce what this does and find the rest of the code.

};
}

public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: maybe we should just have a "pop" method that returns the deltas and clear the batched deltas.

Copy link
Contributor

Choose a reason for hiding this comment

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

Every time you retrieve the deltas you should typically delete them from ListenableEditingState too I think?

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 that's correct this makes sense then.

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: get implies idempotency. A different verb maybe?

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree that I wouldn't expect the .clear() with the "get" name. Maybe... "flush"? Maybe not...

Also, should clearBatchDeltas be clearBatchTextEditingDeltas? Since this method name refers to TextEditingDeltas.


if (autofill.uniqueIdentifier.equals(currentAutofill.uniqueIdentifier)) {
// Autofilling the current client is the same as handling user input
// from the virtual keyboard. Setting the editable to newState and an
Copy link
Contributor

Choose a reason for hiding this comment

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

The virtualId here is the id of the virtual view structure used for autofill.


private static final String TAG = "TextEditingDelta";

public TextEditingDelta(
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah makes sense, thank you for the clarification! Do you think we'll be able to extract that info directly from the InputConnection interface (instead of replace)? It doesn't have to be implemented that way right now if it is (it's probably going to be a little complicated to implement since InputConnectionAdaptor and TextInputPlugin currently don't directly talk to each other). I'm just wondering if we can avoid the manual diffing process here.

};
}

public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Every time you retrieve the deltas you should typically delete them from ListenableEditingState too I think?

new TextEditingDelta(
toString(),
"",
"TextEditingDeltaType.equality",
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I think "equality" is a confusing name. "cursorUpdate"? Or "selectionOnly"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Equality could also represent a composing region update. Maybe regionUpdate or selectionOrComposingRegionUpdate, nonTextUpdate?

final HashMap<String, TextInputChannel.TextEditState> editingValues = new HashMap<>();
for (int i = 0; i < values.size(); i++) {
int virtualId = values.keyAt(i);
if (configuration.enableDeltaModel) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the thing autofill does is slightly different from any of the delta types specified: it replaces the entirety of the document, instead of the content in [0, currentLength). They're slightly different since we're dealing with async communications.

For example if the user autofills a password field and at the same time the framework changes the text to be longer, then we want the obscured field has the right characters in it, instead of just replacing the original range.

setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd);
}

public void setNewComposingStart(int newStart) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you still need to expose these methods? Would it be possible to make this class immutable?

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 think this is possible now.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

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

The approach LGTM.

newComposingStart = composingStart;
newComposingEnd = composingEnd;
final boolean isDeletionGreaterThanOne = end - (start + tbend) > 1;
final boolean isCalledFromDelete = tb == "" && tbstart == 0 && tbstart == tbend;
Copy link
Contributor

Choose a reason for hiding this comment

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

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 that's where I got that from.


private static final String TAG = "TextEditingDelta";

public TextEditingDelta(
Copy link
Contributor

Choose a reason for hiding this comment

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

I see. So setComposingText and commit* can delete/insert/replace, but they all only operate on a specific range.

@Renzo-Olivares Renzo-Olivares force-pushed the text_editing_deltas_android branch from ed9bb5e to 29bcc62 Compare September 7, 2021 06:16
};
}

public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: get implies idempotency. A different verb maybe?

super.setSpan(what, start, end, flags);
// Setting a span does not involve mutating the text value in the editing state. Here we create
// a non text update delta with any updated selection and composing regions.
mBatchTextEditingDeltas.add(
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it possible for setSpan to be invoked from within the replace method? If it is then when that happens we'll see an extra nonTextUpdate for the change?

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 is possible but it shouldn't affect the final result since they are nonTextUpdates. In the end the final TextEditingDelta in the batch will have the most up to date selection/composing regions. In theory if setSpan is called in replace we will want to capture that anyways.

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Sep 7, 2021

Choose a reason for hiding this comment

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

The batch may look something like this if super.replace() calls setSpan.
[nonTextUpdate, nonTextUpdate, nonTextUpdate, delta]
In this case the last delta will still have the most up to date composing/selection regions. We capture the delta after super.replace() has been called so the actual delta will always be the last one applied.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ideally there should be no overlap between consecutive deltas and there should be no redundant deltas. Should be fine for now.

setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd);
}

public CharSequence getOldText() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you need these getters? The text input plugin only need to be able to build deltas and serialize them to JSON right? Are these for writing tests?

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 there are now purely for tests.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Maybe write a comment or comments stating that these are for testing only.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know java too well but do you need expose them as public if the test is in the same package?

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 I think we do. I just tried to switch them to private and it complained.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Looks a lot more straightforward now that a lot of delta logic has been moved to the framework 👍

};
}

public ArrayList<TextEditingDelta> getBatchTextEditingDeltas() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree that I wouldn't expect the .clear() with the "get" name. Maybe... "flush"? Maybe not...

Also, should clearBatchDeltas be clearBatchTextEditingDeltas? Since this method name refers to TextEditingDeltas.

Log.e(TAG, "editing state should not be changed in a listener callback");
}

Log.v(TAG, "replace(" + start + ", " + end + ", " + tb + ", " + tbstart + ", " + tbend + ")");
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you intend to remove this Log?

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 I just cleaned up the logs.

setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd);
}

public CharSequence getOldText() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Maybe write a comment or comments stating that these are for testing only.


private void setDeltas(CharSequence oldTxt, CharSequence newTxt, int newStart, int newExtent) {
oldText = oldTxt;
deltaText = newTxt;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why "Txt" instead of "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.

I did this avoid shadowing, but I can also just use this.

// latest TextEditState from the framework.
@VisibleForTesting
void setTextInputEditingState(View view, TextInputChannel.TextEditState state) {
Log.v(
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Remove this Log?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in another comment.

newComposingEnd);

assertEquals(oldText, delta.getOldText());
assertEquals("hello", delta.getDeltaText());
Copy link
Contributor

Choose a reason for hiding this comment

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

Why the string literal instead of textAfterChange?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

false,
true,
true,
true, // Enable delta model.
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: What do you think of adding a test where this is false and asserting that there are no deltas created and no calls to updateEditingStateWithDeltas?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added tests

@Renzo-Olivares
Copy link
Contributor Author

Ready for re-review.

The main change is that the main logic for deltas has moved to the framework. There was a lot of common logic between the Android and iOS engine PR's and this helps centralize the logic.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

LGTM 👍

Caveat: My comment in the framework PR about the json format for batched deltas. Is we decide to change that, this PR will need a small change too.

false,
true,
true,
false,
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: You have a comment "Enable delta model." on all the other tests where this is true, maybe add it here too.

for (TextEditingDelta delta : batchDeltas) {
deltas.put(delta.toJSON());
}
state.put("batchDeltas", deltas);
Copy link
Contributor

Choose a reason for hiding this comment

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

I made a comment in the framework about how we send these batched deltas: flutter/flutter#88477 (comment)

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 all good now, we always send an array of batchDeltas.

CharSequence oldEditable,
int start,
int end,
CharSequence tb,
Copy link
Contributor

Choose a reason for hiding this comment

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

write this out instead of an abbreviation


public TextEditingDelta(
CharSequence oldEditable,
int start,
Copy link
Contributor

Choose a reason for hiding this comment

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

specify what kind of start/end. Since you have lots of similarly named vars, it is important to disambiguate whenever possible

// Setting a span does not involve mutating the text value in the editing state. Here we create
// a non text update delta with any updated selection and composing regions.
mBatchTextEditingDeltas.add(
new TextEditingDelta(
Copy link
Contributor

Choose a reason for hiding this comment

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

We should make a "nonTextUpdate" delta be its own constructor. It is not always obvious to pass "", -1, -1. A separate constructor for a separate case would prevent any accidental misuse or misconfiguration of this particular type of delta.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This makes sense, I do this already in the iOS PR.

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a comment

Choose a reason for hiding this comment

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

LGTM module comments from other reviewers.

super.setSpan(what, start, end, flags);
// Setting a span does not involve mutating the text value in the editing state. Here we create
// a non text update delta with any updated selection and composing regions.
mBatchTextEditingDeltas.add(
Copy link
Contributor

Choose a reason for hiding this comment

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

Ideally there should be no overlap between consecutive deltas and there should be no redundant deltas. Should be fine for now.

setDeltas(oldText, deltaText, deltaType, deltaStart, deltaEnd);
}

public CharSequence getOldText() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know java too well but do you need expose them as public if the test is in the same package?

import org.json.JSONObject;

/// A representation of the change that occured to an editing state, along with the resulting
/// composing and selection regions.
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we explicitly differentiate between text content changes and selection/composing region index only changes? However it is handled, it should be documented.

Copy link
Contributor

Choose a reason for hiding this comment

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

Never mind, that is under the umbrella of TextEditingDeltaNonTextUpdate

Copy link
Contributor Author

@Renzo-Olivares Renzo-Olivares Sep 14, 2021

Choose a reason for hiding this comment

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

On the framework we do, but on the engine there is no differentiation. This differentiation is documented on the framework side, should it also have something here?

Edit: didn't see your comment before this, disregard.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flutter's text input plugin should report deltas instead of full text blocks

4 participants