Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix race condition in key event handling on Android #22658

Merged
merged 3 commits into from
Dec 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@
import io.flutter.Log;
import io.flutter.embedding.engine.systemchannels.KeyEventChannel;
import io.flutter.plugin.editing.TextInputPlugin;
import java.util.AbstractMap.SimpleImmutableEntry;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map.Entry;

/**
* A class to process key events from Android, passing them to the framework as messages using
Expand All @@ -33,7 +31,6 @@
*/
public class AndroidKeyProcessor {
private static final String TAG = "AndroidKeyProcessor";
private static long eventIdSerial = 0;

@NonNull private final KeyEventChannel keyEventChannel;
@NonNull private final TextInputPlugin textInputPlugin;
Expand All @@ -50,8 +47,8 @@ public class AndroidKeyProcessor {
* <p>It is possible that that in the middle of the async round trip, the focus chain could
* change, and instead of the native widget that was "next" when the event was fired getting the
* event, it may be the next widget when the event is synthesized that gets it. In practice, this
* shouldn't be a huge problem, as this is an unlikely occurance to happen without user input, and
* it may actually be desired behavior, but it is possible.
* shouldn't be a huge problem, as this is an unlikely occurrence to happen without user input,
* and it may actually be desired behavior, but it is possible.
*
* @param view takes the activity to use for re-dispatching of events that were not handled by the
* framework.
Expand Down Expand Up @@ -96,23 +93,39 @@ public boolean onKeyEvent(@NonNull KeyEvent keyEvent) {
// case the theory is wrong.
return false;
}
if (eventResponder.dispatchingKeyEvent) {
// Don't handle it if it is from our own delayed event dispatch.
if (eventResponder.isHeadEvent(keyEvent)) {
// If the keyEvent is at the head of the queue of pending events we've seen,
// and has the same id, then we know that this is a re-dispatched keyEvent, and
// we shouldn't respond to it, but we should remove it from tracking now.
eventResponder.removeHeadEvent();
return false;
}

Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar());
KeyEventChannel.FlutterKeyEvent flutterEvent =
new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++);
new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter);

eventResponder.addEvent(keyEvent);
if (action == KeyEvent.ACTION_DOWN) {
keyEventChannel.keyDown(flutterEvent);
} else {
keyEventChannel.keyUp(flutterEvent);
}
eventResponder.addEvent(flutterEvent.eventId, keyEvent);
return true;
}

/**
* Returns whether or not the given event is currently being processed by this key processor. This
* is used to determine if a new key event sent to the {@link InputConnectionAdaptor} originates
* from a hardware key event, or a soft keyboard editing event.
*
* @param event the event to check for being the current event.
* @return
*/
public boolean isCurrentEvent(@NonNull KeyEvent event) {
return eventResponder.isHeadEvent(event);
}

/**
* Applies the given Unicode character in {@code newCharacterCodePoint} to a previously entered
* Unicode combining character and returns the combination of these characters if a combination
Expand Down Expand Up @@ -176,65 +189,63 @@ private static class EventResponder implements KeyEventChannel.EventResponseHand
// The maximum number of pending events that are held before starting to
// complain.
private static final long MAX_PENDING_EVENTS = 1000;
final Deque<Entry<Long, KeyEvent>> pendingEvents = new ArrayDeque<Entry<Long, KeyEvent>>();
final Deque<KeyEvent> pendingEvents = new ArrayDeque<KeyEvent>();
@NonNull private final View view;
@NonNull private final TextInputPlugin textInputPlugin;
boolean dispatchingKeyEvent = false;

public EventResponder(@NonNull View view, @NonNull TextInputPlugin textInputPlugin) {
this.view = view;
this.textInputPlugin = textInputPlugin;
}

/**
* Removes the pending event with the given id from the cache of pending events.
*
* @param id the id of the event to be removed.
*/
private KeyEvent removePendingEvent(long id) {
if (pendingEvents.getFirst().getKey() != id) {
/** Removes the first pending event from the cache of pending events. */
private KeyEvent removeHeadEvent() {
return pendingEvents.removeFirst();
}

private KeyEvent checkIsHeadEvent(KeyEvent event) {
if (pendingEvents.size() == 0) {
throw new AssertionError(
"Event response received when no events are in the queue. Received event " + event);
}
if (pendingEvents.getFirst() != event) {
throw new AssertionError(
"Event response received out of order. Should have seen event "
+ pendingEvents.getFirst().getKey()
+ pendingEvents.getFirst()
+ " first. Instead, received "
+ id);
+ event);
}
return pendingEvents.removeFirst().getValue();
return pendingEvents.getFirst();
}

private boolean isHeadEvent(KeyEvent event) {
return pendingEvents.size() > 0 && pendingEvents.getFirst() == event;
}

/**
* Called whenever the framework responds that a given key event was handled by the framework.
*
* @param id the event id of the event to be marked as being handled by the framework. Must not
* be null.
* @param event the event to be marked as being handled by the framework. Must not be null.
*/
@Override
public void onKeyEventHandled(long id) {
removePendingEvent(id);
public void onKeyEventHandled(KeyEvent event) {
removeHeadEvent();
}

/**
* Called whenever the framework responds that a given key event wasn't handled by the
* framework.
*
* @param id the event id of the event to be marked as not being handled by the framework. Must
* not be null.
* @param event the event to be marked as not being handled by the framework. Must not be null.
*/
@Override
public void onKeyEventNotHandled(long id) {
dispatchKeyEvent(removePendingEvent(id));
public void onKeyEventNotHandled(KeyEvent event) {
redispatchKeyEvent(checkIsHeadEvent(event));
}

/** Adds an Android key event with an id to the event responder to wait for a response. */
public void addEvent(long id, @NonNull KeyEvent event) {
if (pendingEvents.size() > 0 && pendingEvents.getFirst().getKey() >= id) {
throw new AssertionError(
"New events must have ids greater than the most recent pending event. New id "
+ id
+ " is less than or equal to the last event id of "
+ pendingEvents.getFirst().getKey());
}
pendingEvents.addLast(new SimpleImmutableEntry<Long, KeyEvent>(id, event));
/** Adds an Android key event to the event responder to wait for a response. */
public void addEvent(@NonNull KeyEvent event) {
pendingEvents.addLast(event);
if (pendingEvents.size() > MAX_PENDING_EVENTS) {
Log.e(
TAG,
Expand All @@ -250,27 +261,21 @@ public void addEvent(long id, @NonNull KeyEvent event) {
*
* @param event the event to be dispatched to the activity.
*/
public void dispatchKeyEvent(KeyEvent event) {
private void redispatchKeyEvent(KeyEvent event) {
// If the textInputPlugin is still valid and accepting text, then we'll try
// and send the key event to it, assuming that if the event can be sent,
// that it has been handled.
if (textInputPlugin.getLastInputConnection() != null
&& textInputPlugin.getInputMethodManager().isAcceptingText()) {
dispatchingKeyEvent = true;
boolean handled = textInputPlugin.getLastInputConnection().sendKeyEvent(event);
dispatchingKeyEvent = false;
if (handled) {
return;
}
if (textInputPlugin.getInputMethodManager().isAcceptingText()
&& textInputPlugin.getLastInputConnection() != null
&& textInputPlugin.getLastInputConnection().sendKeyEvent(event)) {
// The event was handled, so we can remove it from the queue.
removeHeadEvent();
return;
}

// Since the framework didn't handle it, dispatch the event again.
if (view != null) {
// Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and
// send it to the framework again.
dispatchingKeyEvent = true;
view.getRootView().dispatchKeyEvent(event);
Copy link
Contributor

Choose a reason for hiding this comment

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

In the case that the even is consumed by some other view before reaching our FlutterView (not sure if possible, maybe in add-to-app?), it will never be removed from the queue?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm. I was assuming that it wasn't possible: the event should be given to the view again, but I suppose it might be possible to construct an add2app scenario where a view was inserted before this view, and then have it handle the event before the view received it again.

I'm not sure how to prevent that. At the moment, it will assert after that happens, since the events will begin to arrive out of order. Perhaps that assert should just be a log message instead of an assertion?

One way to address this would be to just keep track of at most the last 1000 events, and not do any checking to make sure that they arrive in order. Then, even if this happens, there is a limited amount of memory it can waste. It's unlikely in normal operation that 1000 key events would be produced before the framework can respond to any of them. It would be slightly slower, since it would have to do a hash map lookup to find the event instead of assuming that it was at the head of the deque.

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 to directly deliver the event to the FlutterView since this is a re-dispatch?

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 wish, but no. If I don't deliver it to the root view, then the back button doesn't get processed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I see. The root view dispatches the event to the activity.

dispatchingKeyEvent = false;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -739,11 +739,6 @@ public boolean dispatchKeyEvent(KeyEvent event) {
} else if (event.getAction() == KeyEvent.ACTION_UP) {
// Stop tracking the event.
getKeyDispatcherState().handleUpEvent(event);
if (!event.isTracking() || event.isCanceled()) {
gspencergoog marked this conversation as resolved.
Show resolved Hide resolved
// Don't send the event to the key processor if it was canceled, or no
// longer being tracked.
return super.dispatchKeyEvent(event);
}
}
// If the key processor doesn't handle it, then send it on to the
// superclass. The key processor will typically handle all events except
Expand Down
Loading