diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
index 164dd3c974c1d..17ea689f6eb22 100755
--- a/ci/licenses_golden/licenses_flutter
+++ b/ci/licenses_golden/licenses_flutter
@@ -783,7 +783,6 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/app/FlutterApplication.
FILE: ../../../flutter/shell/platform/android/io/flutter/app/FlutterFragmentActivity.java
FILE: ../../../flutter/shell/platform/android/io/flutter/app/FlutterPlayStoreSplitApplication.java
FILE: ../../../flutter/shell/platform/android/io/flutter/app/FlutterPluginRegistry.java
-FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/AndroidTouchProcessor.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/DrawableSplashScreen.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/ExclusiveAppComponent.java
@@ -799,6 +798,8 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/Flutt
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/FlutterSurfaceView.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/FlutterTextureView.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java
+FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/KeyChannelResponder.java
+FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/KeyboardManager.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/MotionEventTracker.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/RenderMode.java
FILE: ../../../flutter/shell/platform/android/io/flutter/embedding/android/SplashScreen.java
diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn
index 12eb239bb90b8..70f5e4430661a 100644
--- a/shell/platform/android/BUILD.gn
+++ b/shell/platform/android/BUILD.gn
@@ -128,7 +128,6 @@ android_java_sources = [
"io/flutter/app/FlutterFragmentActivity.java",
"io/flutter/app/FlutterPlayStoreSplitApplication.java",
"io/flutter/app/FlutterPluginRegistry.java",
- "io/flutter/embedding/android/AndroidKeyProcessor.java",
"io/flutter/embedding/android/AndroidTouchProcessor.java",
"io/flutter/embedding/android/DrawableSplashScreen.java",
"io/flutter/embedding/android/ExclusiveAppComponent.java",
@@ -144,6 +143,8 @@ android_java_sources = [
"io/flutter/embedding/android/FlutterSurfaceView.java",
"io/flutter/embedding/android/FlutterTextureView.java",
"io/flutter/embedding/android/FlutterView.java",
+ "io/flutter/embedding/android/KeyChannelResponder.java",
+ "io/flutter/embedding/android/KeyboardManager.java",
"io/flutter/embedding/android/MotionEventTracker.java",
"io/flutter/embedding/android/RenderMode.java",
"io/flutter/embedding/android/SplashScreen.java",
@@ -458,13 +459,14 @@ action("robolectric_tests") {
"test/io/flutter/FlutterTestSuite.java",
"test/io/flutter/SmokeTest.java",
"test/io/flutter/TestUtils.java",
- "test/io/flutter/embedding/android/AndroidKeyProcessorTest.java",
"test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java",
"test/io/flutter/embedding/android/FlutterActivityTest.java",
"test/io/flutter/embedding/android/FlutterAndroidComponentTest.java",
"test/io/flutter/embedding/android/FlutterFragmentActivityTest.java",
"test/io/flutter/embedding/android/FlutterFragmentTest.java",
"test/io/flutter/embedding/android/FlutterViewTest.java",
+ "test/io/flutter/embedding/android/KeyChannelResponderTest.java",
+ "test/io/flutter/embedding/android/KeyboardManagerTest.java",
"test/io/flutter/embedding/android/RobolectricFlutterActivity.java",
"test/io/flutter/embedding/engine/FlutterEngineCacheTest.java",
"test/io/flutter/embedding/engine/FlutterEngineConnectionRegistryTest.java",
diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java
deleted file mode 100644
index 00021b9b21343..0000000000000
--- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java
+++ /dev/null
@@ -1,275 +0,0 @@
-// Copyright 2013 The Flutter Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-package io.flutter.embedding.android;
-
-import android.view.KeyCharacterMap;
-import android.view.KeyEvent;
-import android.view.View;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import io.flutter.Log;
-import io.flutter.embedding.engine.systemchannels.KeyEventChannel;
-import io.flutter.plugin.editing.TextInputPlugin;
-import java.util.ArrayDeque;
-import java.util.Deque;
-import java.util.Iterator;
-
-/**
- * A class to process key events from Android, passing them to the framework as messages using
- * {@link KeyEventChannel}.
- *
- *
A class that sends Android key events to the framework, and re-dispatches those not handled by
- * the framework.
- *
- *
Flutter uses asynchronous event handling to avoid blocking the UI thread, but Android requires
- * that events are handled synchronously. So, when a key event is received by Flutter, it tells
- * Android synchronously that the key has been handled so that it won't propagate to other
- * components. Flutter then uses "delayed event synthesis", where it sends the event to the
- * framework, and if the framework responds that it has not handled the event, then this class
- * synthesizes a new event to send to Android, without handling it this time.
- */
-public class AndroidKeyProcessor {
- private static final String TAG = "AndroidKeyProcessor";
-
- @NonNull private final KeyEventChannel keyEventChannel;
- @NonNull private final TextInputPlugin textInputPlugin;
- private int combiningCharacter;
- @NonNull private EventResponder eventResponder;
-
- /**
- * Constructor for AndroidKeyProcessor.
- *
- *
The view is used as the destination to send the synthesized key to. This means that the the
- * next thing in the focus chain will get the event when the framework returns false from
- * onKeyDown/onKeyUp
- *
- *
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 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.
- * @param keyEventChannel the event channel to listen to for new key events.
- * @param textInputPlugin a plugin, which, if set, is given key events before the framework is,
- * and if it has a valid input connection and is accepting text, then it will handle the event
- * and the framework will not receive it.
- */
- public AndroidKeyProcessor(
- @NonNull View view,
- @NonNull KeyEventChannel keyEventChannel,
- @NonNull TextInputPlugin textInputPlugin) {
- this.keyEventChannel = keyEventChannel;
- this.textInputPlugin = textInputPlugin;
- textInputPlugin.setKeyEventProcessor(this);
- this.eventResponder = new EventResponder(view, textInputPlugin);
- this.keyEventChannel.setEventResponseHandler(eventResponder);
- }
-
- /**
- * Detaches the key processor from the Flutter engine.
- *
- *
The AndroidKeyProcessor instance should not be used after calling this.
- */
- public void destroy() {
- keyEventChannel.setEventResponseHandler(null);
- }
-
- /**
- * Called when a key event is received by the {@link FlutterView} or the {@link
- * InputConnectionAdaptor}.
- *
- * @param keyEvent the Android key event to respond to.
- * @return true if the key event should not be propagated to other Android components. Delayed
- * synthesis events will return false, so that other components may handle them.
- */
- public boolean onKeyEvent(@NonNull KeyEvent keyEvent) {
- int action = keyEvent.getAction();
- if (action != KeyEvent.ACTION_DOWN && action != KeyEvent.ACTION_UP) {
- // There is theoretically a KeyEvent.ACTION_MULTIPLE, but theoretically
- // that isn't sent by Android anymore, so this is just for protection in
- // case the theory is wrong.
- return false;
- }
- if (isPendingEvent(keyEvent)) {
- // If the keyEvent is in 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.removePendingEvent(keyEvent);
- return false;
- }
-
- Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar());
- KeyEventChannel.FlutterKeyEvent flutterEvent =
- new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter);
-
- eventResponder.addEvent(keyEvent);
- if (action == KeyEvent.ACTION_DOWN) {
- keyEventChannel.keyDown(flutterEvent);
- } else {
- keyEventChannel.keyUp(flutterEvent);
- }
- 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 isPendingEvent(@NonNull KeyEvent event) {
- return eventResponder.findPendingEvent(event) != null;
- }
-
- /**
- * 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
- * exists.
- *
- *
This method mutates {@link #combiningCharacter} over time to combine characters.
- *
- *
One of the following things happens in this method:
- *
- *
- *
If no previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint}
- * is not a combining character, then {@code newCharacterCodePoint} is returned.
- *
If no previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint}
- * is a combining character, then {@code newCharacterCodePoint} is saved as the {@link
- * #combiningCharacter} and null is returned.
- *
If a previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint} is
- * also a combining character, then the {@code newCharacterCodePoint} is combined with the
- * existing {@link #combiningCharacter} and null is returned.
- *
If a previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint} is
- * not a combining character, then the {@link #combiningCharacter} is applied to the regular
- * {@code newCharacterCodePoint} and the resulting complex character is returned. The {@link
- * #combiningCharacter} is cleared.
- *
- *
- *
The following reference explains the concept of a "combining character":
- * https://en.wikipedia.org/wiki/Combining_character
- */
- @Nullable
- private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoint) {
- if (newCharacterCodePoint == 0) {
- return null;
- }
-
- char complexCharacter = (char) newCharacterCodePoint;
- boolean isNewCodePointACombiningCharacter =
- (newCharacterCodePoint & KeyCharacterMap.COMBINING_ACCENT) != 0;
- if (isNewCodePointACombiningCharacter) {
- // If a combining character was entered before, combine this one with that one.
- int plainCodePoint = newCharacterCodePoint & KeyCharacterMap.COMBINING_ACCENT_MASK;
- if (combiningCharacter != 0) {
- combiningCharacter = KeyCharacterMap.getDeadChar(combiningCharacter, plainCodePoint);
- } else {
- combiningCharacter = plainCodePoint;
- }
- } else {
- // The new character is a regular character. Apply combiningCharacter to it, if
- // it exists.
- if (combiningCharacter != 0) {
- int combinedChar = KeyCharacterMap.getDeadChar(combiningCharacter, newCharacterCodePoint);
- if (combinedChar > 0) {
- complexCharacter = (char) combinedChar;
- }
- combiningCharacter = 0;
- }
- }
-
- return complexCharacter;
- }
-
- private static class EventResponder implements KeyEventChannel.EventResponseHandler {
- // The maximum number of pending events that are held before starting to
- // complain.
- private static final long MAX_PENDING_EVENTS = 1000;
- final Deque pendingEvents = new ArrayDeque();
- @NonNull private final View view;
- @NonNull private final TextInputPlugin textInputPlugin;
-
- public EventResponder(@NonNull View view, @NonNull TextInputPlugin textInputPlugin) {
- this.view = view;
- this.textInputPlugin = textInputPlugin;
- }
-
- /** Removes the first pending event from the cache of pending events. */
- private void removePendingEvent(KeyEvent event) {
- pendingEvents.remove(event);
- }
-
- private KeyEvent findPendingEvent(KeyEvent event) {
- Iterator iter = pendingEvents.iterator();
- while (iter.hasNext()) {
- KeyEvent item = iter.next();
- if (item == event) {
- return item;
- }
- }
- return null;
- }
-
- /**
- * Called whenever the framework responds that a given key event was handled by the framework.
- *
- * @param event the event to be marked as being handled by the framework. Must not be null.
- */
- @Override
- public void onKeyEventHandled(KeyEvent event) {
- removePendingEvent(event);
- }
-
- /**
- * Called whenever the framework responds that a given key event wasn't handled by the
- * framework.
- *
- * @param event the event to be marked as not being handled by the framework. Must not be null.
- */
- @Override
- public void onKeyEventNotHandled(KeyEvent event) {
- redispatchKeyEvent(findPendingEvent(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,
- "There are "
- + pendingEvents.size()
- + " keyboard events that have not yet received a response. Are responses being "
- + "sent?");
- }
- }
-
- /**
- * Dispatches the event to the activity associated with the context.
- *
- * @param event the event to be dispatched to the activity.
- */
- 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.getInputMethodManager().isAcceptingText()
- && textInputPlugin.getLastInputConnection() != null
- && textInputPlugin.getLastInputConnection().sendKeyEvent(event)) {
- // The event was handled, so we can remove it from the queue.
- removePendingEvent(event);
- return;
- }
-
- // Since the framework didn't handle it, dispatch the event again.
- if (view != null) {
- view.getRootView().dispatchKeyEvent(event);
- }
- }
- }
-}
diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java
index 70d5ad60065c1..5e56f9d188969 100644
--- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java
+++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java
@@ -104,7 +104,7 @@ public class FlutterView extends FrameLayout implements MouseCursorPlugin.MouseC
@Nullable private MouseCursorPlugin mouseCursorPlugin;
@Nullable private TextInputPlugin textInputPlugin;
@Nullable private LocalizationPlugin localizationPlugin;
- @Nullable private AndroidKeyProcessor androidKeyProcessor;
+ @Nullable private KeyboardManager keyboardManager;
@Nullable private AndroidTouchProcessor androidTouchProcessor;
@Nullable private AccessibilityBridge accessibilityBridge;
@@ -705,7 +705,7 @@ public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) {
return super.onCreateInputConnection(outAttrs);
}
- return textInputPlugin.createInputConnection(this, outAttrs);
+ return textInputPlugin.createInputConnection(this, keyboardManager, outAttrs);
}
/**
@@ -730,7 +730,7 @@ public boolean checkInputConnectionProxy(View view) {
* D-pad button. It is generally not invoked when a virtual software keyboard is used, though a
* software keyboard may choose to invoke this method in some situations.
*
- *
{@link KeyEvent}s are sent from Android to Flutter. {@link AndroidKeyProcessor} may do some
+ *
{@link KeyEvent}s are sent from Android to Flutter. {@link KeyboardManager} may do some
* additional work with the given {@link KeyEvent}, e.g., combine this {@code keyCode} with the
* previous {@code keyCode} to generate a unicode combined character.
*/
@@ -747,7 +747,7 @@ public boolean dispatchKeyEvent(KeyEvent event) {
// superclass. The key processor will typically handle all events except
// those where it has re-dispatched the event after receiving a reply from
// the framework that the framework did not handle it.
- return (isAttachedToFlutterEngine() && androidKeyProcessor.onKeyEvent(event))
+ return (isAttachedToFlutterEngine() && keyboardManager.handleEvent(event))
|| super.dispatchKeyEvent(event);
}
@@ -975,8 +975,14 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) {
this.flutterEngine.getTextInputChannel(),
this.flutterEngine.getPlatformViewsController());
localizationPlugin = this.flutterEngine.getLocalizationPlugin();
- androidKeyProcessor =
- new AndroidKeyProcessor(this, this.flutterEngine.getKeyEventChannel(), textInputPlugin);
+
+ keyboardManager =
+ new KeyboardManager(
+ this,
+ textInputPlugin,
+ new KeyChannelResponder[] {
+ new KeyChannelResponder(flutterEngine.getKeyEventChannel())
+ });
androidTouchProcessor =
new AndroidTouchProcessor(this.flutterEngine.getRenderer(), /*trackMotionEvents=*/ false);
accessibilityBridge =
@@ -1060,8 +1066,7 @@ public void detachFromFlutterEngine() {
// TODO(mattcarroll): once this is proven to work, move this line ot TextInputPlugin
textInputPlugin.getInputMethodManager().restartInput(this);
textInputPlugin.destroy();
-
- androidKeyProcessor.destroy();
+ keyboardManager.destroy();
if (mouseCursorPlugin != null) {
mouseCursorPlugin.destroy();
diff --git a/shell/platform/android/io/flutter/embedding/android/KeyChannelResponder.java b/shell/platform/android/io/flutter/embedding/android/KeyChannelResponder.java
new file mode 100644
index 0000000000000..d3bd28db79ea0
--- /dev/null
+++ b/shell/platform/android/io/flutter/embedding/android/KeyChannelResponder.java
@@ -0,0 +1,105 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.embedding.android;
+
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import androidx.annotation.NonNull;
+import io.flutter.embedding.engine.systemchannels.KeyEventChannel;
+
+/**
+ * A {@link Responder} of {@link KeyboardManager} that handles events by sending the raw information
+ * through the method channel.
+ *
+ *
This class corresponds to the RawKeyboard API in the framework.
+ */
+public class KeyChannelResponder implements KeyboardManager.Responder {
+ private static final String TAG = "KeyChannelResponder";
+
+ @NonNull private final KeyEventChannel keyEventChannel;
+ private int combiningCharacter;
+
+ public KeyChannelResponder(@NonNull KeyEventChannel keyEventChannel) {
+ this.keyEventChannel = keyEventChannel;
+ }
+
+ /**
+ * 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
+ * exists.
+ *
+ *
This method mutates {@link #combiningCharacter} over time to combine characters.
+ *
+ *
One of the following things happens in this method:
+ *
+ *
+ *
If no previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint}
+ * is not a combining character, then {@code newCharacterCodePoint} is returned.
+ *
If no previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint}
+ * is a combining character, then {@code newCharacterCodePoint} is saved as the {@link
+ * #combiningCharacter} and null is returned.
+ *
If a previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint} is
+ * also a combining character, then the {@code newCharacterCodePoint} is combined with the
+ * existing {@link #combiningCharacter} and null is returned.
+ *
If a previous {@link #combiningCharacter} exists and the {@code newCharacterCodePoint} is
+ * not a combining character, then the {@link #combiningCharacter} is applied to the regular
+ * {@code newCharacterCodePoint} and the resulting complex character is returned. The {@link
+ * #combiningCharacter} is cleared.
+ *
+ *
+ *
The following reference explains the concept of a "combining character":
+ * https://en.wikipedia.org/wiki/Combining_character
+ */
+ Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoint) {
+ char complexCharacter = (char) newCharacterCodePoint;
+ boolean isNewCodePointACombiningCharacter =
+ (newCharacterCodePoint & KeyCharacterMap.COMBINING_ACCENT) != 0;
+ if (isNewCodePointACombiningCharacter) {
+ // If a combining character was entered before, combine this one with that one.
+ int plainCodePoint = newCharacterCodePoint & KeyCharacterMap.COMBINING_ACCENT_MASK;
+ if (combiningCharacter != 0) {
+ combiningCharacter = KeyCharacterMap.getDeadChar(combiningCharacter, plainCodePoint);
+ } else {
+ combiningCharacter = plainCodePoint;
+ }
+ } else {
+ // The new character is a regular character. Apply combiningCharacter to it, if
+ // it exists.
+ if (combiningCharacter != 0) {
+ int combinedChar = KeyCharacterMap.getDeadChar(combiningCharacter, newCharacterCodePoint);
+ if (combinedChar > 0) {
+ complexCharacter = (char) combinedChar;
+ }
+ combiningCharacter = 0;
+ }
+ }
+
+ return complexCharacter;
+ }
+
+ @Override
+ public void handleEvent(
+ @NonNull KeyEvent keyEvent, @NonNull OnKeyEventHandledCallback onKeyEventHandledCallback) {
+ final int action = keyEvent.getAction();
+ if (action != KeyEvent.ACTION_DOWN && action != KeyEvent.ACTION_UP) {
+ // There is theoretically a KeyEvent.ACTION_MULTIPLE, but theoretically
+ // that isn't sent by Android anymore, so this is just for protection in
+ // case the theory is wrong.
+ onKeyEventHandledCallback.onKeyEventHandled(false);
+ return;
+ }
+
+ final Character complexCharacter =
+ applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar());
+ KeyEventChannel.FlutterKeyEvent flutterEvent =
+ new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter);
+
+ final boolean isKeyUp = action != KeyEvent.ACTION_DOWN;
+ keyEventChannel.sendFlutterKeyEvent(
+ flutterEvent,
+ isKeyUp,
+ (isEventHandled) -> onKeyEventHandledCallback.onKeyEventHandled(isEventHandled));
+ }
+}
diff --git a/shell/platform/android/io/flutter/embedding/android/KeyboardManager.java b/shell/platform/android/io/flutter/embedding/android/KeyboardManager.java
new file mode 100644
index 0000000000000..ccfcbc4ff5ca1
--- /dev/null
+++ b/shell/platform/android/io/flutter/embedding/android/KeyboardManager.java
@@ -0,0 +1,194 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.embedding.android;
+
+import android.view.KeyEvent;
+import android.view.View;
+import androidx.annotation.NonNull;
+import io.flutter.Log;
+import io.flutter.embedding.android.KeyboardManager.Responder.OnKeyEventHandledCallback;
+import io.flutter.plugin.editing.TextInputPlugin;
+import java.util.HashSet;
+
+/**
+ * A class to process {@link KeyEvent}s dispatched to a {@link FlutterView}, either from a hardware
+ * keyboard or an IME event.
+ *
+ *
A class that sends Android {@link KeyEvent} to the a list of {@link Responder}s, and
+ * re-dispatches those not handled by the primary responders.
+ *
+ *
Flutter uses asynchronous event handling to avoid blocking the UI thread, but Android requires
+ * that events are handled synchronously. So, when the Android system sends new @{link KeyEvent} to
+ * Flutter, Flutter responds synchronously that the key has been handled so that it won't propagate
+ * to other components. It then uses "delayed event synthesis", where it sends the event to the
+ * framework, and if the framework responds that it has not handled the event, then this class
+ * synthesizes a new event to send to Android, without handling it this time.
+ *
+ *
A new {@link KeyEvent} sent to a {@link KeyboardManager} can be propagated to 3 different
+ * types of responders (in the listed order):
+ *
+ *
+ *
{@link Responder}s: An immutable list of key responders in a {@link KeyboardManager} that
+ * each implements the {@link Responder} interface. A {@link Responder} is a key responder
+ * that's capable of handling {@link KeyEvent}s asynchronously.
+ *
When a new {@link KeyEvent} is received, {@link KeyboardManager} calls the {@link
+ * Responder#handleEvent(KeyEvent, OnKeyEventHandledCallback)} method on its {@link
+ * Responder}s. Each {@link Responder} must call the supplied {@link
+ * OnKeyEventHandledCallback} exactly once, when it has decided whether to handle the key
+ * event callback. More than one {@link Responder} is allowed to reply true and handle the
+ * same {@link KeyEvent}.
+ *
Typically a {@link KeyboardManager} uses a {@link KeyChannelResponder} as its only
+ * {@link Responder}.
+ *
{@link TextInputPlugin}: if every {@link Responder} has replied false to a {@link
+ * KeyEvent}, or if the {@link KeyboardManager} has zero {@link Responder}s, the {@link
+ * KeyEvent} will be sent to the currently focused editable text field in {@link
+ * TextInputPlugin}, if any.
+ *
"Redispatch": if there's no currently focused text field in {@link TextInputPlugin},
+ * or the text field does not handle the {@link KeyEvent} either, the {@link KeyEvent} will be
+ * sent back to the top of the activity's view hierachy, allowing it to be "redispatched",
+ * only this time the {@link KeyboardManager} will not try to handle the redispatched {@link
+ * KeyEvent}.
+ *
+ */
+public class KeyboardManager {
+ private static final String TAG = "KeyboardManager";
+
+ /**
+ * Constructor for {@link KeyboardManager} that takes a list of {@link Responder}s.
+ *
+ *
The view is used as the destination to send the synthesized key to. This means that the the
+ * next thing in the focus chain will get the event when the {@link Responder}s return false from
+ * onKeyDown/onKeyUp.
+ *
+ *
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 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.
+ * @param textInputPlugin a plugin, which, if set, is given key events before the framework is,
+ * and if it has a valid input connection and is accepting text, then it will handle the event
+ * and the framework will not receive it.
+ * @param responders the {@link Responder}s new {@link KeyEvent}s will be first dispatched to.
+ */
+ public KeyboardManager(
+ View view, @NonNull TextInputPlugin textInputPlugin, Responder[] responders) {
+ this.view = view;
+ this.textInputPlugin = textInputPlugin;
+ this.responders = responders;
+ }
+
+ /**
+ * The interface for responding to a {@link KeyEvent} asynchronously.
+ *
+ *
Implementers of this interface should be owned by a {@link KeyboardManager}, in order to
+ * receive key events.
+ *
+ *
After receiving a {@link KeyEvent}, the {@link Responder} must call the supplied {@link
+ * OnKeyEventHandledCallback} exactly once, to inform the {@link KeyboardManager} whether it
+ * wishes to handle the {@link KeyEvent}. The {@link KeyEvent} will not be propagated to the
+ * {@link TextInputPlugin} or be redispatched to the view hierachy if any key responders answered
+ * yes.
+ *
+ *
If a {@link Responder} fails to call the {@link OnKeyEventHandledCallback} callback, the
+ * {@link KeyEvent} will never be sent to the {@link TextInputPlugin}, and the {@link
+ * KeyboardManager} class can't detect such errors as there is no timeout.
+ */
+ interface Responder {
+ interface OnKeyEventHandledCallback {
+ void onKeyEventHandled(Boolean canHandleEvent);
+ }
+
+ /**
+ * Informs this {@link Responder} that a new {@link KeyEvent} needs processing.
+ *
+ * @param keyEvent the new {@link KeyEvent} this {@link Responder} may be interested in.
+ * @param onKeyEventHandledCallback the method to call when this {@link Responder} has decided
+ * whether to handle the {@link keyEvent}.
+ */
+ void handleEvent(
+ @NonNull KeyEvent keyEvent, @NonNull OnKeyEventHandledCallback onKeyEventHandledCallback);
+ }
+
+ private class PerEventCallbackBuilder {
+ private class Callback implements OnKeyEventHandledCallback {
+ boolean isCalled = false;
+
+ @Override
+ public void onKeyEventHandled(Boolean canHandleEvent) {
+ if (isCalled) {
+ throw new IllegalStateException(
+ "The onKeyEventHandledCallback should be called exactly once.");
+ }
+ isCalled = true;
+ unrepliedCount -= 1;
+ isEventHandled |= canHandleEvent;
+ if (unrepliedCount == 0 && !isEventHandled) {
+ onUnhandled(keyEvent);
+ }
+ }
+ }
+
+ PerEventCallbackBuilder(@NonNull KeyEvent keyEvent) {
+ this.keyEvent = keyEvent;
+ }
+
+ @NonNull final KeyEvent keyEvent;
+ int unrepliedCount = responders.length;
+ boolean isEventHandled = false;
+
+ public OnKeyEventHandledCallback buildCallback() {
+ return new Callback();
+ }
+ }
+
+ @NonNull protected final Responder[] responders;
+ @NonNull private final HashSet redispatchedEvents = new HashSet<>();
+ @NonNull private final TextInputPlugin textInputPlugin;
+ private final View view;
+
+ public boolean handleEvent(@NonNull KeyEvent keyEvent) {
+ final boolean isRedispatchedEvent = redispatchedEvents.remove(keyEvent);
+ if (isRedispatchedEvent) {
+ return false;
+ }
+
+ if (responders.length > 0) {
+ final PerEventCallbackBuilder callbackBuilder = new PerEventCallbackBuilder(keyEvent);
+ for (final Responder primaryResponder : responders) {
+ primaryResponder.handleEvent(keyEvent, callbackBuilder.buildCallback());
+ }
+ } else {
+ onUnhandled(keyEvent);
+ }
+
+ return true;
+ }
+
+ public void destroy() {
+ final int remainingRedispatchCount = redispatchedEvents.size();
+ if (remainingRedispatchCount > 0) {
+ Log.w(
+ TAG,
+ "A KeyboardManager was destroyed with "
+ + String.valueOf(remainingRedispatchCount)
+ + " unhandled redispatch event(s).");
+ }
+ }
+
+ private void onUnhandled(@NonNull KeyEvent keyEvent) {
+ if (textInputPlugin.handleKeyEvent(keyEvent) || view == null) {
+ return;
+ }
+
+ redispatchedEvents.add(keyEvent);
+ view.getRootView().dispatchKeyEvent(keyEvent);
+ if (redispatchedEvents.remove(keyEvent)) {
+ Log.w(TAG, "A redispatched key event was consumed before reaching KeyboardManager");
+ }
+ }
+}
diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java
index dbd6bf7f9c924..22345515774e6 100644
--- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java
+++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java
@@ -27,33 +27,16 @@
public class KeyEventChannel {
private static final String TAG = "KeyEventChannel";
- /**
- * Sets the event response handler to be used to receive key event response messages from the
- * framework on this channel.
- */
- public void setEventResponseHandler(EventResponseHandler handler) {
- this.eventResponseHandler = handler;
- }
-
- private EventResponseHandler eventResponseHandler;
-
/** A handler of incoming key handling messages. */
public interface EventResponseHandler {
/**
- * Called whenever the framework responds that a given key event was handled by the framework.
- *
- * @param event the event to be marked as being handled by the framework. Must not be null.
- */
- public void onKeyEventHandled(KeyEvent event);
-
- /**
- * Called whenever the framework responds that a given key event wasn't handled by the
- * framework.
+ * Called whenever the framework responds that a given key event was handled or not handled by
+ * the framework.
*
- * @param event the event to be marked as not being handled by the framework. Must not be null.
+ * @param isEventHandled whether the framework decides to handle the event.
*/
- public void onKeyEventNotHandled(KeyEvent event);
+ public void onFrameworkResponse(boolean isEventHandled);
}
/**
@@ -66,58 +49,19 @@ public KeyEventChannel(@NonNull BinaryMessenger binaryMessenger) {
new BasicMessageChannel<>(binaryMessenger, "flutter/keyevent", JSONMessageCodec.INSTANCE);
}
- /**
- * Creates a reply handler for the given key event.
- *
- * @param event the Android key event to create a reply for.
- */
- BasicMessageChannel.Reply