diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index eed010506b3b9..3fe51be12a214 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -417,6 +417,7 @@ action("robolectric_tests") { sources = [ "test/io/flutter/FlutterTestSuite.java", "test/io/flutter/SmokeTest.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", @@ -435,6 +436,7 @@ action("robolectric_tests") { "test/io/flutter/embedding/engine/loader/ApplicationInfoLoaderTest.java", "test/io/flutter/embedding/engine/plugins/shim/ShimPluginRegistryTest.java", "test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java", + "test/io/flutter/embedding/engine/systemchannels/KeyEventChannelTest.java", "test/io/flutter/embedding/engine/systemchannels/PlatformChannelTest.java", "test/io/flutter/embedding/engine/systemchannels/RestorationChannelTest.java", "test/io/flutter/external/FlutterLaunchTests.java", diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index 5d823ef8ab674..094b8ef1c53e8 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -4,37 +4,122 @@ package io.flutter.embedding.android; +import android.util.Log; import android.view.KeyCharacterMap; import android.view.KeyEvent; +import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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 + * {@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"; + private static long eventIdSerial = 0; + @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 occurance 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 KeyEventChannel keyEventChannel, @NonNull TextInputPlugin textInputPlugin) { + @NonNull View view, + @NonNull KeyEventChannel keyEventChannel, + @NonNull TextInputPlugin textInputPlugin) { this.keyEventChannel = keyEventChannel; this.textInputPlugin = textInputPlugin; + this.eventResponder = new EventResponder(view); + this.keyEventChannel.setEventResponseHandler(eventResponder); } - public void onKeyUp(@NonNull KeyEvent keyEvent) { + /** + * Called when a key up event is received by the {@link FlutterView}. + * + * @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 onKeyUp(@NonNull KeyEvent keyEvent) { + if (eventResponder.dispatchingKeyEvent) { + // Don't handle it if it is from our own delayed event synthesis. + return false; + } + Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); - keyEventChannel.keyUp(new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter)); + KeyEventChannel.FlutterKeyEvent flutterEvent = + new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++); + keyEventChannel.keyUp(flutterEvent); + eventResponder.addEvent(flutterEvent.eventId, keyEvent); + return true; } - public void onKeyDown(@NonNull KeyEvent keyEvent) { + /** + * Called when a key down event is received by the {@link FlutterView}. + * + * @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 onKeyDown(@NonNull KeyEvent keyEvent) { + if (eventResponder.dispatchingKeyEvent) { + // Don't handle it if it is from our own delayed event synthesis. + return false; + } + + // 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()) { - textInputPlugin.getLastInputConnection().sendKeyEvent(keyEvent); + if (textInputPlugin.getLastInputConnection().sendKeyEvent(keyEvent)) { + return true; + } } Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); - keyEventChannel.keyDown(new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter)); + KeyEventChannel.FlutterKeyEvent flutterEvent = + new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++); + keyEventChannel.keyDown(flutterEvent); + eventResponder.addEvent(flutterEvent.eventId, keyEvent); + return true; } /** @@ -70,7 +155,7 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi return null; } - Character complexCharacter = (char) newCharacterCodePoint; + char complexCharacter = (char) newCharacterCodePoint; boolean isNewCodePointACombiningCharacter = (newCharacterCodePoint & KeyCharacterMap.COMBINING_ACCENT) != 0; if (isNewCodePointACombiningCharacter) { @@ -82,7 +167,8 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi combiningCharacter = plainCodePoint; } } else { - // The new character is a regular character. Apply combiningCharacter to it, if it exists. + // 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) { @@ -94,4 +180,92 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi 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; + boolean dispatchingKeyEvent = false; + + public EventResponder(@NonNull View view) { + this.view = view; + } + + /** + * 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) { + throw new AssertionError( + "Event response received out of order. Should have seen event " + + pendingEvents.getFirst().getKey() + + " first. Instead, received " + + id); + } + return pendingEvents.removeFirst().getValue(); + } + + /** + * 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. + */ + @Override + public void onKeyEventHandled(long id) { + removePendingEvent(id); + } + + /** + * 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. + */ + @Override + public void onKeyEventNotHandled(long id) { + dispatchKeyEvent(removePendingEvent(id)); + } + + /** 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(id, 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. + */ + public void dispatchKeyEvent(KeyEvent event) { + // Since the framework didn't handle it, dispatch the key 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); + dispatchingKeyEvent = false; + } + } + } } diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 860d9c4786025..5b7af5a1c8cbc 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -679,8 +679,7 @@ public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { return super.onKeyUp(keyCode, event); } - androidKeyProcessor.onKeyUp(event); - return super.onKeyUp(keyCode, event); + return androidKeyProcessor.onKeyUp(event) || super.onKeyUp(keyCode, event); } /** @@ -700,8 +699,7 @@ public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { return super.onKeyDown(keyCode, event); } - androidKeyProcessor.onKeyDown(event); - return super.onKeyDown(keyCode, event); + return androidKeyProcessor.onKeyDown(event) || super.onKeyDown(keyCode, event); } /** @@ -851,7 +849,7 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) { this.flutterEngine.getPlatformViewsController()); localizationPlugin = this.flutterEngine.getLocalizationPlugin(); androidKeyProcessor = - new AndroidKeyProcessor(this.flutterEngine.getKeyEventChannel(), textInputPlugin); + new AndroidKeyProcessor(this, this.flutterEngine.getKeyEventChannel(), textInputPlugin); androidTouchProcessor = new AndroidTouchProcessor(this.flutterEngine.getRenderer(), /*trackMotionEvents=*/ false); accessibilityBridge = diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java index 90455bcecefc2..87db8cf09f245 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/AccessibilityChannel.java @@ -73,6 +73,7 @@ public void onMessage( break; } } + reply.reply(null); } }; 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 1b03c94184221..3638c2d364ae7 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java @@ -9,29 +9,104 @@ import android.view.KeyEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.flutter.embedding.engine.dart.DartExecutor; +import io.flutter.Log; import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.JSONMessageCodec; import java.util.HashMap; import java.util.Map; +import org.json.JSONException; +import org.json.JSONObject; -/** TODO(mattcarroll): fill in javadoc for KeyEventChannel. */ +/** + * Event message channel for key events to/from the Flutter framework. + * + *

Sends key up/down events to the framework, and receives asynchronous messages from the + * framework about whether or not the key was handled. + */ public class KeyEventChannel { + private static final String TAG = "KeyEventChannel"; - @NonNull public final BasicMessageChannel channel; + /** + * 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 id the event id of the event to be marked as being handled by the framework. Must not + * be null. + */ + public void onKeyEventHandled(long id); - public KeyEventChannel(@NonNull DartExecutor dartExecutor) { + /** + * 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. + */ + public void onKeyEventNotHandled(long id); + } + + /** + * A constructor that creates a KeyEventChannel with the default message handler. + * + * @param binaryMessenger the binary messenger used to send messages on this channel. + */ + public KeyEventChannel(@NonNull BinaryMessenger binaryMessenger) { this.channel = - new BasicMessageChannel<>(dartExecutor, "flutter/keyevent", JSONMessageCodec.INSTANCE); + new BasicMessageChannel<>(binaryMessenger, "flutter/keyevent", JSONMessageCodec.INSTANCE); } + /** + * Creates a reply handler for this an event with the given eventId. + * + * @param eventId the event ID to create a reply for. + */ + BasicMessageChannel.Reply createReplyHandler(long eventId) { + return message -> { + if (eventResponseHandler == null) { + return; + } + + try { + if (message == null) { + eventResponseHandler.onKeyEventNotHandled(eventId); + return; + } + final JSONObject annotatedEvent = (JSONObject) message; + final boolean handled = annotatedEvent.getBoolean("handled"); + if (handled) { + eventResponseHandler.onKeyEventHandled(eventId); + } else { + eventResponseHandler.onKeyEventNotHandled(eventId); + } + } catch (JSONException e) { + Log.e(TAG, "Unable to unpack JSON message: " + e); + eventResponseHandler.onKeyEventNotHandled(eventId); + } + }; + } + + @NonNull public final BasicMessageChannel channel; + public void keyUp(@NonNull FlutterKeyEvent keyEvent) { Map message = new HashMap<>(); message.put("type", "keyup"); message.put("keymap", "android"); encodeKeyEvent(keyEvent, message); - channel.send(message); + channel.send(message, createReplyHandler(keyEvent.eventId)); } public void keyDown(@NonNull FlutterKeyEvent keyEvent) { @@ -40,7 +115,7 @@ public void keyDown(@NonNull FlutterKeyEvent keyEvent) { message.put("keymap", "android"); encodeKeyEvent(keyEvent, message); - channel.send(message); + channel.send(message, createReplyHandler(keyEvent.eventId)); } private void encodeKeyEvent( @@ -61,27 +136,105 @@ private void encodeKeyEvent( message.put("repeatCount", event.repeatCount); } - /** Key event as defined by Flutter. */ + /** A key event as defined by Flutter. */ public static class FlutterKeyEvent { + /** + * The id for the device this event came from. + * + * @see KeyEvent.getDeviceId() + */ public final int deviceId; + /** + * The flags for this key event. + * + * @see KeyEvent.getFlags() + */ public final int flags; + /** + * The code point for the Unicode character produced by this event if no meta keys were pressed + * (by passing 0 to {@code KeyEvent.getUnicodeChar(int)}). + * + * @see KeyEvent.getUnicodeChar(int) + */ public final int plainCodePoint; + /** + * The code point for the Unicode character produced by this event, taking into account the meta + * keys currently pressed. + * + * @see KeyEvent.getUnicodeChar() + */ public final int codePoint; + /** + * The Android key code for this event. + * + * @see KeyEvent.getKeyCode() + */ public final int keyCode; + /** + * The character produced by this event, including any combining characters pressed before it. + */ @Nullable public final Character complexCharacter; + /** + * The Android scan code for the key pressed. + * + * @see KeyEvent.getScanCode() + */ public final int scanCode; + /** + * The meta key state for the Android key event. + * + * @see KeyEvent.getMetaState() + */ public final int metaState; + /** + * The source of the key event. + * + * @see KeyEvent.getSource() + */ public final int source; + /** + * The vendorId of the device that produced this key event. + * + * @see InputDevice.getVendorId() + */ public final int vendorId; + /** + * The productId of the device that produced this key event. + * + * @see InputDevice.getProductId() + */ public final int productId; + /** + * The repeat count for this event. + * + * @see KeyEvent.getRepeatCount() + */ public final int repeatCount; + /** + * The unique id for this Flutter key event. + * + *

This id is used to identify pending events when results are received from the framework. + * This ID does not come from Android. + */ + public final long eventId; - public FlutterKeyEvent(@NonNull KeyEvent androidKeyEvent) { - this(androidKeyEvent, null); + public FlutterKeyEvent(@NonNull KeyEvent androidKeyEvent, long eventId) { + this(androidKeyEvent, null, eventId); } public FlutterKeyEvent( - @NonNull KeyEvent androidKeyEvent, @Nullable Character complexCharacter) { + @NonNull KeyEvent androidKeyEvent, @Nullable Character complexCharacter, long eventId) { this( androidKeyEvent.getDeviceId(), androidKeyEvent.getFlags(), @@ -92,7 +245,8 @@ public FlutterKeyEvent( androidKeyEvent.getScanCode(), androidKeyEvent.getMetaState(), androidKeyEvent.getSource(), - androidKeyEvent.getRepeatCount()); + androidKeyEvent.getRepeatCount(), + eventId); } public FlutterKeyEvent( @@ -105,7 +259,8 @@ public FlutterKeyEvent( int scanCode, int metaState, int source, - int repeatCount) { + int repeatCount, + long eventId) { this.deviceId = deviceId; this.flags = flags; this.plainCodePoint = plainCodePoint; @@ -116,6 +271,7 @@ public FlutterKeyEvent( this.metaState = metaState; this.source = source; this.repeatCount = repeatCount; + this.eventId = eventId; InputDevice device = InputDevice.getDevice(deviceId); if (device != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 1a635f13acdc5..a96b0deeabb22 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -145,7 +145,7 @@ Editable getEditable() { } /** - * * Use the current platform view input connection until unlockPlatformViewInputConnection is + * Use the current platform view input connection until unlockPlatformViewInputConnection is * called. * *

The current input connection instance is cached and any following call to @{link diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 36f74850fad2f..9c20937cac8b8 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -231,7 +231,7 @@ public void onPostResume() { mMouseCursorPlugin = null; } mLocalizationPlugin = new LocalizationPlugin(context, localizationChannel); - androidKeyProcessor = new AndroidKeyProcessor(keyEventChannel, mTextInputPlugin); + androidKeyProcessor = new AndroidKeyProcessor(this, keyEventChannel, mTextInputPlugin); androidTouchProcessor = new AndroidTouchProcessor(flutterRenderer, /*trackMotionEvents=*/ false); platformViewsController.attachToFlutterRenderer(flutterRenderer); @@ -270,8 +270,7 @@ public boolean onKeyUp(int keyCode, KeyEvent event) { if (!isAttached()) { return super.onKeyUp(keyCode, event); } - androidKeyProcessor.onKeyUp(event); - return super.onKeyUp(keyCode, event); + return androidKeyProcessor.onKeyUp(event) || super.onKeyUp(keyCode, event); } @Override @@ -279,8 +278,7 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { if (!isAttached()) { return super.onKeyDown(keyCode, event); } - androidKeyProcessor.onKeyDown(event); - return super.onKeyDown(keyCode, event); + return androidKeyProcessor.onKeyDown(event) || super.onKeyDown(keyCode, event); } public FlutterNativeView getFlutterNativeView() { diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index 3436a14eb601e..67611eac687b8 100644 --- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java +++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java @@ -4,6 +4,7 @@ package io.flutter; +import io.flutter.embedding.android.AndroidKeyProcessorTest; import io.flutter.embedding.android.FlutterActivityAndFragmentDelegateTest; import io.flutter.embedding.android.FlutterActivityTest; import io.flutter.embedding.android.FlutterAndroidComponentTest; @@ -17,6 +18,7 @@ import io.flutter.embedding.engine.RenderingComponentTest; import io.flutter.embedding.engine.plugins.shim.ShimPluginRegistryTest; import io.flutter.embedding.engine.renderer.FlutterRendererTest; +import io.flutter.embedding.engine.systemchannels.KeyEventChannelTest; import io.flutter.embedding.engine.systemchannels.PlatformChannelTest; import io.flutter.embedding.engine.systemchannels.RestorationChannelTest; import io.flutter.external.FlutterLaunchTests; @@ -40,6 +42,8 @@ @RunWith(Suite.class) @SuiteClasses({ + AccessibilityBridgeTest.class, + AndroidKeyProcessorTest.class, DartExecutorTest.class, FlutterActivityAndFragmentDelegateTest.class, FlutterActivityTest.class, @@ -51,26 +55,26 @@ FlutterFragmentTest.class, FlutterJNITest.class, FlutterLaunchTests.class, - FlutterShellArgsTest.class, FlutterRendererTest.class, + FlutterShellArgsTest.class, FlutterViewTest.class, InputConnectionAdaptorTest.class, + KeyEventChannelTest.class, LocalizationPluginTest.class, + MouseCursorPluginTest.class, + PlatformChannelTest.class, PlatformPluginTest.class, PlatformViewsControllerTest.class, PluginComponentTest.class, PreconditionsTest.class, RenderingComponentTest.class, - StandardMessageCodecTest.class, - StandardMethodCodecTest.class, + RestorationChannelTest.class, ShimPluginRegistryTest.class, SingleViewPresentationTest.class, SmokeTest.class, + StandardMessageCodecTest.class, + StandardMethodCodecTest.class, TextInputPluginTest.class, - MouseCursorPluginTest.class, - AccessibilityBridgeTest.class, - PlatformChannelTest.class, - RestorationChannelTest.class, }) /** Runs all of the unit tests listed in the {@code @SuiteClasses} annotation. */ public class FlutterTestSuite {} diff --git a/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java new file mode 100644 index 0000000000000..23369a2d3109b --- /dev/null +++ b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java @@ -0,0 +1,168 @@ +package io.flutter.embedding.android; + +import static junit.framework.TestCase.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.TargetApi; +import android.view.KeyEvent; +import android.view.View; +import androidx.annotation.NonNull; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.FlutterJNI; +import io.flutter.embedding.engine.systemchannels.KeyEventChannel; +import io.flutter.embedding.engine.systemchannels.TextInputChannel; +import io.flutter.plugin.editing.TextInputPlugin; +import io.flutter.util.FakeKeyEvent; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner.class) +@TargetApi(28) +public class AndroidKeyProcessorTest { + @Mock FlutterJNI mockFlutterJni; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mockFlutterJni.isAttached()).thenReturn(true); + } + + @Test + public void respondsTrueWhenHandlingNewEvents() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); + View fakeView = mock(View.class); + + AndroidKeyProcessor processor = + new AndroidKeyProcessor(fakeView, fakeKeyEventChannel, mock(TextInputPlugin.class)); + + boolean result = processor.onKeyDown(new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65)); + assertEquals(true, result); + verify(fakeKeyEventChannel, times(1)).keyDown(any(KeyEventChannel.FlutterKeyEvent.class)); + verify(fakeKeyEventChannel, times(0)).keyUp(any(KeyEventChannel.FlutterKeyEvent.class)); + verify(fakeView, times(0)).dispatchKeyEvent(any(KeyEvent.class)); + } + + public void synthesizesEventsWhenKeyDownNotHandled() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); + View fakeView = mock(View.class); + View fakeRootView = mock(View.class); + when(fakeView.getRootView()) + .then( + new Answer() { + @Override + public View answer(InvocationOnMock invocation) throws Throwable { + return fakeRootView; + } + }); + + ArgumentCaptor handlerCaptor = + ArgumentCaptor.forClass(KeyEventChannel.EventResponseHandler.class); + verify(fakeKeyEventChannel).setEventResponseHandler(handlerCaptor.capture()); + AndroidKeyProcessor processor = + new AndroidKeyProcessor(fakeView, fakeKeyEventChannel, mock(TextInputPlugin.class)); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(KeyEventChannel.FlutterKeyEvent.class); + FakeKeyEvent fakeKeyEvent = new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65); + + boolean result = processor.onKeyDown(fakeKeyEvent); + assertEquals(true, result); + + // Capture the FlutterKeyEvent so we can find out its event ID to use when + // faking our response. + verify(fakeKeyEventChannel, times(1)).keyDown(eventCaptor.capture()); + boolean[] dispatchResult = {true}; + when(fakeView.dispatchKeyEvent(any(KeyEvent.class))) + .then( + new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + KeyEvent event = (KeyEvent) invocation.getArguments()[0]; + assertEquals(fakeKeyEvent, event); + dispatchResult[0] = processor.onKeyDown(event); + return dispatchResult[0]; + } + }); + + // Fake a response from the framework. + handlerCaptor.getValue().onKeyEventNotHandled(eventCaptor.getValue().eventId); + verify(fakeView, times(1)).dispatchKeyEvent(fakeKeyEvent); + assertEquals(false, dispatchResult[0]); + verify(fakeKeyEventChannel, times(0)).keyUp(any(KeyEventChannel.FlutterKeyEvent.class)); + verify(fakeRootView, times(1)).dispatchKeyEvent(fakeKeyEvent); + } + + public void synthesizesEventsWhenKeyUpNotHandled() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); + View fakeView = mock(View.class); + View fakeRootView = mock(View.class); + when(fakeView.getRootView()) + .then( + new Answer() { + @Override + public View answer(InvocationOnMock invocation) throws Throwable { + return fakeRootView; + } + }); + + ArgumentCaptor handlerCaptor = + ArgumentCaptor.forClass(KeyEventChannel.EventResponseHandler.class); + verify(fakeKeyEventChannel).setEventResponseHandler(handlerCaptor.capture()); + AndroidKeyProcessor processor = + new AndroidKeyProcessor(fakeView, fakeKeyEventChannel, mock(TextInputPlugin.class)); + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(KeyEventChannel.FlutterKeyEvent.class); + FakeKeyEvent fakeKeyEvent = new FakeKeyEvent(KeyEvent.ACTION_UP, 65); + + boolean result = processor.onKeyUp(fakeKeyEvent); + assertEquals(true, result); + + // Capture the FlutterKeyEvent so we can find out its event ID to use when + // faking our response. + verify(fakeKeyEventChannel, times(1)).keyUp(eventCaptor.capture()); + boolean[] dispatchResult = {true}; + when(fakeView.dispatchKeyEvent(any(KeyEvent.class))) + .then( + new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + KeyEvent event = (KeyEvent) invocation.getArguments()[0]; + assertEquals(fakeKeyEvent, event); + dispatchResult[0] = processor.onKeyUp(event); + return dispatchResult[0]; + } + }); + + // Fake a response from the framework. + handlerCaptor.getValue().onKeyEventNotHandled(eventCaptor.getValue().eventId); + verify(fakeView, times(1)).dispatchKeyEvent(fakeKeyEvent); + assertEquals(false, dispatchResult[0]); + verify(fakeKeyEventChannel, times(0)).keyUp(any(KeyEventChannel.FlutterKeyEvent.class)); + verify(fakeRootView, times(1)).dispatchKeyEvent(fakeKeyEvent); + } + + @NonNull + private FlutterEngine mockFlutterEngine() { + // Mock FlutterEngine and all of its required direct calls. + FlutterEngine engine = mock(FlutterEngine.class); + when(engine.getKeyEventChannel()).thenReturn(mock(KeyEventChannel.class)); + when(engine.getTextInputChannel()).thenReturn(mock(TextInputChannel.class)); + + return engine; + } +} diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java index 2c8e243375fd7..8967656fa870e 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterActivityAndFragmentDelegateTest.java @@ -25,6 +25,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityControlSurface; import io.flutter.embedding.engine.renderer.FlutterRenderer; import io.flutter.embedding.engine.systemchannels.AccessibilityChannel; +import io.flutter.embedding.engine.systemchannels.KeyEventChannel; import io.flutter.embedding.engine.systemchannels.LifecycleChannel; import io.flutter.embedding.engine.systemchannels.LocalizationChannel; import io.flutter.embedding.engine.systemchannels.MouseCursorChannel; @@ -615,19 +616,20 @@ private FlutterEngine mockFlutterEngine() { // Mock FlutterEngine and all of its required direct calls. FlutterEngine engine = mock(FlutterEngine.class); - when(engine.getDartExecutor()).thenReturn(mock(DartExecutor.class)); - when(engine.getRenderer()).thenReturn(mock(FlutterRenderer.class)); - when(engine.getPlatformViewsController()).thenReturn(mock(PlatformViewsController.class)); when(engine.getAccessibilityChannel()).thenReturn(mock(AccessibilityChannel.class)); - when(engine.getSettingsChannel()).thenReturn(fakeSettingsChannel); - when(engine.getLocalizationChannel()).thenReturn(mock(LocalizationChannel.class)); + when(engine.getActivityControlSurface()).thenReturn(mock(ActivityControlSurface.class)); + when(engine.getDartExecutor()).thenReturn(mock(DartExecutor.class)); + when(engine.getKeyEventChannel()).thenReturn(mock(KeyEventChannel.class)); when(engine.getLifecycleChannel()).thenReturn(mock(LifecycleChannel.class)); + when(engine.getLocalizationChannel()).thenReturn(mock(LocalizationChannel.class)); + when(engine.getLocalizationPlugin()).thenReturn(mock(LocalizationPlugin.class)); + when(engine.getMouseCursorChannel()).thenReturn(mock(MouseCursorChannel.class)); when(engine.getNavigationChannel()).thenReturn(mock(NavigationChannel.class)); + when(engine.getPlatformViewsController()).thenReturn(mock(PlatformViewsController.class)); + when(engine.getRenderer()).thenReturn(mock(FlutterRenderer.class)); + when(engine.getSettingsChannel()).thenReturn(fakeSettingsChannel); when(engine.getSystemChannel()).thenReturn(mock(SystemChannel.class)); when(engine.getTextInputChannel()).thenReturn(mock(TextInputChannel.class)); - when(engine.getMouseCursorChannel()).thenReturn(mock(MouseCursorChannel.class)); - when(engine.getActivityControlSurface()).thenReturn(mock(ActivityControlSurface.class)); - when(engine.getLocalizationPlugin()).thenReturn(mock(LocalizationPlugin.class)); return engine; } diff --git a/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/KeyEventChannelTest.java b/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/KeyEventChannelTest.java new file mode 100644 index 0000000000000..81b34b3f3b5ad --- /dev/null +++ b/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/KeyEventChannelTest.java @@ -0,0 +1,124 @@ +package io.flutter.embedding.engine.systemchannels; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.annotation.TargetApi; +import android.view.KeyEvent; +import androidx.annotation.NonNull; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.JSONMessageCodec; +import io.flutter.util.FakeKeyEvent; +import java.nio.ByteBuffer; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@Config( + manifest = Config.NONE, + shadows = {}) +@RunWith(RobolectricTestRunner.class) +@TargetApi(24) +public class KeyEventChannelTest { + + private void sendReply(boolean handled, BinaryMessenger.BinaryReply messengerReply) + throws JSONException { + JSONObject reply = new JSONObject(); + reply.put("handled", true); + ByteBuffer binaryReply = JSONMessageCodec.INSTANCE.encodeMessage(reply); + assertNotNull(binaryReply); + binaryReply.rewind(); + messengerReply.reply(binaryReply); + } + + @Test + public void keyDownEventIsSentToFramework() throws JSONException { + BinaryMessenger fakeMessenger = mock(BinaryMessenger.class); + KeyEventChannel keyEventChannel = new KeyEventChannel(fakeMessenger); + final boolean[] handled = {false}; + final long[] handledId = {-1}; + keyEventChannel.setEventResponseHandler( + new KeyEventChannel.EventResponseHandler() { + public void onKeyEventHandled(@NonNull long id) { + handled[0] = true; + handledId[0] = id; + } + + public void onKeyEventNotHandled(@NonNull long id) { + handled[0] = false; + handledId[0] = id; + } + }); + verify(fakeMessenger, times(0)).send(any(), any(), any()); + + KeyEvent event = new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65); + KeyEventChannel.FlutterKeyEvent flutterKeyEvent = + new KeyEventChannel.FlutterKeyEvent(event, null, 10); + keyEventChannel.keyDown(flutterKeyEvent); + ArgumentCaptor byteBufferArgumentCaptor = ArgumentCaptor.forClass(ByteBuffer.class); + ArgumentCaptor replyArgumentCaptor = + ArgumentCaptor.forClass(BinaryMessenger.BinaryReply.class); + verify(fakeMessenger, times(1)) + .send(any(), byteBufferArgumentCaptor.capture(), replyArgumentCaptor.capture()); + ByteBuffer capturedMessage = byteBufferArgumentCaptor.getValue(); + capturedMessage.rewind(); + JSONObject message = (JSONObject) JSONMessageCodec.INSTANCE.decodeMessage(capturedMessage); + assertNotNull(message); + assertEquals("keydown", message.get("type")); + + // Simulate a reply, and see that it is handled. + sendReply(true, replyArgumentCaptor.getValue()); + assertTrue(handled[0]); + assertEquals(10, handledId[0]); + } + + @Test + public void keyUpEventIsSentToFramework() throws JSONException { + BinaryMessenger fakeMessenger = mock(BinaryMessenger.class); + KeyEventChannel keyEventChannel = new KeyEventChannel(fakeMessenger); + final boolean[] handled = {false}; + final long[] handledId = {-1}; + keyEventChannel.setEventResponseHandler( + new KeyEventChannel.EventResponseHandler() { + public void onKeyEventHandled(long id) { + handled[0] = true; + handledId[0] = id; + } + + public void onKeyEventNotHandled(long id) { + handled[0] = false; + handledId[0] = id; + } + }); + verify(fakeMessenger, times(0)).send(any(), any(), any()); + + KeyEvent event = new FakeKeyEvent(KeyEvent.ACTION_UP, 65); + KeyEventChannel.FlutterKeyEvent flutterKeyEvent = + new KeyEventChannel.FlutterKeyEvent(event, null, 10); + keyEventChannel.keyUp(flutterKeyEvent); + ArgumentCaptor byteBufferArgumentCaptor = ArgumentCaptor.forClass(ByteBuffer.class); + ArgumentCaptor replyArgumentCaptor = + ArgumentCaptor.forClass(BinaryMessenger.BinaryReply.class); + verify(fakeMessenger, times(1)) + .send(any(), byteBufferArgumentCaptor.capture(), replyArgumentCaptor.capture()); + ByteBuffer capturedMessage = byteBufferArgumentCaptor.getValue(); + capturedMessage.rewind(); + JSONObject message = (JSONObject) JSONMessageCodec.INSTANCE.decodeMessage(capturedMessage); + assertNotNull(message); + assertEquals("keyup", message.get("type")); + + // Simulate a reply, and see that it is handled. + sendReply(true, replyArgumentCaptor.getValue()); + assertTrue(handled[0]); + assertEquals(10, handledId[0]); + } +}