From 3e19c3d4881a39ec0dcbcbc5da4a4c2e42607562 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 10 Jun 2020 17:47:32 -0700 Subject: [PATCH 1/7] Initial implementation of synchronous key response for Android. --- .../android/AndroidKeyProcessor.java | 84 +++++++++++++++++-- .../embedding/android/FlutterView.java | 8 +- .../systemchannels/AccessibilityChannel.java | 1 + .../systemchannels/KeyEventChannel.java | 61 +++++++++++++- .../android/io/flutter/view/FlutterView.java | 8 +- 5 files changed, 144 insertions(+), 18 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index 5d823ef8ab674..3e7ab84fbf4a4 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -4,6 +4,13 @@ package io.flutter.embedding.android; +import java.util.Map; +import java.util.HashMap; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.util.Log; import android.view.KeyCharacterMap; import android.view.KeyEvent; import androidx.annotation.NonNull; @@ -12,29 +19,96 @@ import io.flutter.plugin.editing.TextInputPlugin; public class AndroidKeyProcessor { + private static final String TAG = "AndroidKeyProcessor"; + @NonNull private final KeyEventChannel keyEventChannel; @NonNull private final TextInputPlugin textInputPlugin; + @NonNull private final Context context; private int combiningCharacter; - public AndroidKeyProcessor( - @NonNull KeyEventChannel keyEventChannel, @NonNull TextInputPlugin textInputPlugin) { + private Map pendingEvents = new HashMap(); + private boolean dispatchingKeyEvent = false; + + public AndroidKeyProcessor(@NonNull Context context, @NonNull KeyEventChannel keyEventChannel, @NonNull TextInputPlugin textInputPlugin) { this.keyEventChannel = keyEventChannel; this.textInputPlugin = textInputPlugin; + this.context = context; + this.keyEventChannel.setKeyProcessor(this); } - public void onKeyUp(@NonNull KeyEvent keyEvent) { + public boolean onKeyUp(@NonNull KeyEvent keyEvent) { + if (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)); + pendingEvents.put(keyEvent.getEventTime(), keyEvent); + return true; } - public void onKeyDown(@NonNull KeyEvent keyEvent) { + public boolean onKeyDown(@NonNull KeyEvent keyEvent) { + if (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)); + pendingEvents.put(keyEvent.getEventTime(), keyEvent); + return true; + } + + public void onKeyEventHandled(@NonNull long timestamp) { + if (!pendingEvents.containsKey(timestamp)) { + Log.e(TAG, "Key with timestamp " + timestamp + " not found in pending key events list"); + return; + } + Log.e(TAG, "Removing handled key with timestamp " + timestamp + " from pending key events list"); + // Since this event was already reported to Android as handled, we just + // remove it from the map of pending events. + pendingEvents.remove(timestamp); + } + + public void onKeyEventNotHandled(@NonNull long timestamp) { + if (!pendingEvents.containsKey(timestamp)) { + Log.e(TAG, "Key with timestamp " + timestamp + " not found in pending key events list"); + return; + } + Log.e(TAG, "Removing unhandled key with timestamp " + timestamp + " from pending key events list"); + // Since this event was NOT handled by the framework we now synthesize a + // new, identical, key event to pass along. + KeyEvent pendingEvent = pendingEvents.remove(timestamp); + Activity activity = getActivity(context); + if (activity != null) { + // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and + // send it to the framework again. + dispatchingKeyEvent = true; + activity.dispatchKeyEvent(pendingEvent); + dispatchingKeyEvent = false; + } + } + + private Activity getActivity(Context context) { + if (context instanceof Activity) { + return (Activity) context; + } + if (context instanceof ContextWrapper) { + // Recurse up chain of base contexts until we find an Activity. + return getActivity(((ContextWrapper) context).getBaseContext()); + } + return null; } /** diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 8cf69c24cf568..afc1fae151249 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(getContext(), 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..ca4f56728d9ae 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java @@ -5,24 +5,74 @@ package io.flutter.embedding.engine.systemchannels; import android.os.Build; +import android.util.Log; import android.view.InputDevice; import android.view.KeyEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import io.flutter.embedding.android.AndroidKeyProcessor; import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.plugin.common.BasicMessageChannel; import io.flutter.plugin.common.JSONMessageCodec; import java.util.HashMap; import java.util.Map; -/** 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"; + + /** + * Sets the key processor to be used to receive messages from the framework on + * this channel. + */ + public void setKeyProcessor(AndroidKeyProcessor processor) { + this.processor = processor; + } + private AndroidKeyProcessor processor; + + private final BasicMessageChannel.MessageHandler messageHandler = + new BasicMessageChannel.MessageHandler() { + @Override + public void onMessage( + @Nullable Object message, @NonNull BasicMessageChannel.Reply reply) { + // If there is no processor to respond to this message then we don't need to + // parse it. + if (processor == null) { + return; + } + + @SuppressWarnings("unchecked") + final HashMap annotatedEvent = (HashMap) message; + final String type = (String) annotatedEvent.get("type"); + @SuppressWarnings("unchecked") + final HashMap data = (HashMap) annotatedEvent.get("data"); + + Log.v(TAG, "Received " + type + " message."); + switch (type) { + case "keyHandled": + Log.w(TAG, "Handled key event " + (long) data.get("eventId")); + processor.onKeyEventHandled((long) data.get("eventId")); + break; + case "keyNotHandled": + Log.w(TAG, "Did not handle key event " + (long) data.get("eventId")); + processor.onKeyEventNotHandled((long) data.get("eventId")); + break; + } + reply.reply(null); + } + }; @NonNull public final BasicMessageChannel channel; public KeyEventChannel(@NonNull DartExecutor dartExecutor) { this.channel = new BasicMessageChannel<>(dartExecutor, "flutter/keyevent", JSONMessageCodec.INSTANCE); + this.channel.setMessageHandler(messageHandler); } public void keyUp(@NonNull FlutterKeyEvent keyEvent) { @@ -59,6 +109,7 @@ private void encodeKeyEvent( message.put("productId", event.productId); message.put("deviceId", event.deviceId); message.put("repeatCount", event.repeatCount); + message.put("eventId", event.eventId); } /** Key event as defined by Flutter. */ @@ -75,6 +126,7 @@ public static class FlutterKeyEvent { public final int vendorId; public final int productId; public final int repeatCount; + public final long eventId; public FlutterKeyEvent(@NonNull KeyEvent androidKeyEvent) { this(androidKeyEvent, null); @@ -92,7 +144,8 @@ public FlutterKeyEvent( androidKeyEvent.getScanCode(), androidKeyEvent.getMetaState(), androidKeyEvent.getSource(), - androidKeyEvent.getRepeatCount()); + androidKeyEvent.getRepeatCount(), + androidKeyEvent.getEventTime()); } public FlutterKeyEvent( @@ -105,7 +158,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 +170,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/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index ed1499ced9641..521bb94bc857b 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(getContext(), 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() { From 8a81ffaec701114df160b3a06fceb8a33cf02b84 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 12 Jun 2020 12:30:01 -0700 Subject: [PATCH 2/7] Add synchronous key event support to Android. --- .../android/AndroidKeyProcessor.java | 37 ++++++++------ .../systemchannels/KeyEventChannel.java | 50 +++++++++++-------- 2 files changed, 51 insertions(+), 36 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index 3e7ab84fbf4a4..b8dac45eabb33 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -5,7 +5,10 @@ package io.flutter.embedding.android; import java.util.Map; +import java.util.List; +import java.util.ArrayList; import java.util.HashMap; +import java.util.MapEntry; import android.app.Activity; import android.content.Context; @@ -43,8 +46,9 @@ public boolean onKeyUp(@NonNull KeyEvent keyEvent) { } Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); - keyEventChannel.keyUp(new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter)); - pendingEvents.put(keyEvent.getEventTime(), keyEvent); + KeyEventChannel.FlutterKeyEvent flutterEvent = new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); + keyEventChannel.keyUp(flutterEvent); + pendingEvents.put(flutterEvent.eventId, keyEvent); return true; } @@ -65,31 +69,36 @@ public boolean onKeyDown(@NonNull KeyEvent keyEvent) { } Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); - keyEventChannel.keyDown(new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter)); - pendingEvents.put(keyEvent.getEventTime(), keyEvent); + KeyEventChannel.FlutterKeyEvent flutterEvent = new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); + keyEventChannel.keyDown(flutterEvent); + pendingEvents.put(flutterEvent.eventId, keyEvent); return true; } - public void onKeyEventHandled(@NonNull long timestamp) { - if (!pendingEvents.containsKey(timestamp)) { - Log.e(TAG, "Key with timestamp " + timestamp + " not found in pending key events list"); + public void onKeyEventHandled(@NonNull long id) { + if (!pendingEvents.containsKey(id)) { + Log.e(TAG, "Key with id " + id + " not found in pending key events list. " + + "There are " + pendingEvents.size() + " pending events."); return; } - Log.e(TAG, "Removing handled key with timestamp " + timestamp + " from pending key events list"); // Since this event was already reported to Android as handled, we just // remove it from the map of pending events. - pendingEvents.remove(timestamp); + pendingEvents.remove(id); + Log.e(TAG, "Removing handled key with id " + id + " from pending key events list. " + + "There are now " + pendingEvents.size() + " pending events."); } - public void onKeyEventNotHandled(@NonNull long timestamp) { - if (!pendingEvents.containsKey(timestamp)) { - Log.e(TAG, "Key with timestamp " + timestamp + " not found in pending key events list"); + public void onKeyEventNotHandled(@NonNull long id) { + if (!pendingEvents.containsKey(id)) { + Log.e(TAG, "Key with id " + id + " not found in pending key events list. " + + "There are " + pendingEvents.size() + " pending events."); return; } - Log.e(TAG, "Removing unhandled key with timestamp " + timestamp + " from pending key events list"); // Since this event was NOT handled by the framework we now synthesize a // new, identical, key event to pass along. - KeyEvent pendingEvent = pendingEvents.remove(timestamp); + KeyEvent pendingEvent = pendingEvents.remove(id); + Log.e(TAG, "Removing unhandled key with id " + id + " from pending key events list. " + + "There are now " + pendingEvents.size() + " pending events."); Activity activity = getActivity(context); if (activity != null) { // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and 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 ca4f56728d9ae..d266682561d80 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java @@ -16,6 +16,8 @@ import io.flutter.plugin.common.JSONMessageCodec; import java.util.HashMap; import java.util.Map; +import org.json.JSONObject; +import org.json.JSONException; /** * Event message channel for key events to/from the Flutter framework. @@ -25,6 +27,7 @@ */ public class KeyEventChannel { private static final String TAG = "KeyEventChannel"; + private static long eventIdSerial = 0; /** * Sets the key processor to be used to receive messages from the framework on @@ -40,30 +43,35 @@ public void setKeyProcessor(AndroidKeyProcessor processor) { @Override public void onMessage( @Nullable Object message, @NonNull BasicMessageChannel.Reply reply) { + Log.v(TAG, "Received key event channel message."); + // If there is no processor to respond to this message then we don't need to // parse it. if (processor == null) { return; } - @SuppressWarnings("unchecked") - final HashMap annotatedEvent = (HashMap) message; - final String type = (String) annotatedEvent.get("type"); - @SuppressWarnings("unchecked") - final HashMap data = (HashMap) annotatedEvent.get("data"); - - Log.v(TAG, "Received " + type + " message."); - switch (type) { - case "keyHandled": - Log.w(TAG, "Handled key event " + (long) data.get("eventId")); - processor.onKeyEventHandled((long) data.get("eventId")); - break; - case "keyNotHandled": - Log.w(TAG, "Did not handle key event " + (long) data.get("eventId")); - processor.onKeyEventNotHandled((long) data.get("eventId")); - break; + try { + final JSONObject annotatedEvent = (JSONObject) message; + final String type = annotatedEvent.getString("type"); + final JSONObject data = annotatedEvent.getJSONObject("data"); + + Log.v(TAG, "Received " + type + " message."); + switch (type) { + case "keyHandled": + Log.w(TAG, "Handled key event " + data.getLong("eventId")); + processor.onKeyEventHandled(data.getLong("eventId")); + break; + case "keyNotHandled": + Log.w(TAG, "Did not handle key event " + data.getLong("eventId")); + processor.onKeyEventNotHandled(data.getLong("eventId")); + break; + } + } catch (JSONException e) { + Log.e(TAG, "Unable to unpack JSON message: " + e); + } finally { + reply.reply(null); } - reply.reply(null); } }; @@ -144,8 +152,7 @@ public FlutterKeyEvent( androidKeyEvent.getScanCode(), androidKeyEvent.getMetaState(), androidKeyEvent.getSource(), - androidKeyEvent.getRepeatCount(), - androidKeyEvent.getEventTime()); + androidKeyEvent.getRepeatCount()); } public FlutterKeyEvent( @@ -158,8 +165,7 @@ public FlutterKeyEvent( int scanCode, int metaState, int source, - int repeatCount, - long eventId) { + int repeatCount) { this.deviceId = deviceId; this.flags = flags; this.plainCodePoint = plainCodePoint; @@ -170,7 +176,7 @@ public FlutterKeyEvent( this.metaState = metaState; this.source = source; this.repeatCount = repeatCount; - this.eventId = eventId; + this.eventId = eventIdSerial++; InputDevice device = InputDevice.getDevice(deviceId); if (device != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { From f78c3a0c85dc0ecf724e2c34843fa5f5be96bca3 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 12 Jun 2020 14:50:40 -0700 Subject: [PATCH 3/7] Converted to a deque, added comments --- .../android/AndroidKeyProcessor.java | 195 ++++++++++++------ .../embedding/android/FlutterView.java | 3 +- .../systemchannels/KeyEventChannel.java | 56 +++-- 3 files changed, 166 insertions(+), 88 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index b8dac45eabb33..962e94125d01f 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -4,12 +4,6 @@ package io.flutter.embedding.android; -import java.util.Map; -import java.util.List; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.MapEntry; - import android.app.Activity; import android.content.Context; import android.content.ContextWrapper; @@ -20,40 +14,61 @@ 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}. + */ public class AndroidKeyProcessor { private static final String TAG = "AndroidKeyProcessor"; @NonNull private final KeyEventChannel keyEventChannel; @NonNull private final TextInputPlugin textInputPlugin; - @NonNull private final Context context; - private int combiningCharacter; - - private Map pendingEvents = new HashMap(); - private boolean dispatchingKeyEvent = false; + @NonNull private int combiningCharacter; + @NonNull private EventResponder eventResponder; - public AndroidKeyProcessor(@NonNull Context context, @NonNull KeyEventChannel keyEventChannel, @NonNull TextInputPlugin textInputPlugin) { + public AndroidKeyProcessor( + @NonNull Context context, + @NonNull KeyEventChannel keyEventChannel, + @NonNull TextInputPlugin textInputPlugin) { this.keyEventChannel = keyEventChannel; this.textInputPlugin = textInputPlugin; - this.context = context; - this.keyEventChannel.setKeyProcessor(this); + this.eventResponder = new EventResponder(context); + this.keyEventChannel.setEventResponseHandler(eventResponder); } + /** + * 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 was handled and should not be propagated. + */ public boolean onKeyUp(@NonNull KeyEvent keyEvent) { - if (dispatchingKeyEvent) { + if (eventResponder.dispatchingKeyEvent) { // Don't handle it if it is from our own delayed event synthesis. return false; } Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); - KeyEventChannel.FlutterKeyEvent flutterEvent = new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); + KeyEventChannel.FlutterKeyEvent flutterEvent = + new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); keyEventChannel.keyUp(flutterEvent); - pendingEvents.put(flutterEvent.eventId, keyEvent); + eventResponder.addEvent(flutterEvent.eventId, keyEvent); return true; } + /** + * 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 was handled and should not be propagated. + */ public boolean onKeyDown(@NonNull KeyEvent keyEvent) { - if (dispatchingKeyEvent) { + if (eventResponder.dispatchingKeyEvent) { // Don't handle it if it is from our own delayed event synthesis. return false; } @@ -69,57 +84,13 @@ public boolean onKeyDown(@NonNull KeyEvent keyEvent) { } Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); - KeyEventChannel.FlutterKeyEvent flutterEvent = new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); + KeyEventChannel.FlutterKeyEvent flutterEvent = + new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); keyEventChannel.keyDown(flutterEvent); - pendingEvents.put(flutterEvent.eventId, keyEvent); + eventResponder.addEvent(flutterEvent.eventId, keyEvent); return true; } - public void onKeyEventHandled(@NonNull long id) { - if (!pendingEvents.containsKey(id)) { - Log.e(TAG, "Key with id " + id + " not found in pending key events list. " + - "There are " + pendingEvents.size() + " pending events."); - return; - } - // Since this event was already reported to Android as handled, we just - // remove it from the map of pending events. - pendingEvents.remove(id); - Log.e(TAG, "Removing handled key with id " + id + " from pending key events list. " + - "There are now " + pendingEvents.size() + " pending events."); - } - - public void onKeyEventNotHandled(@NonNull long id) { - if (!pendingEvents.containsKey(id)) { - Log.e(TAG, "Key with id " + id + " not found in pending key events list. " + - "There are " + pendingEvents.size() + " pending events."); - return; - } - // Since this event was NOT handled by the framework we now synthesize a - // new, identical, key event to pass along. - KeyEvent pendingEvent = pendingEvents.remove(id); - Log.e(TAG, "Removing unhandled key with id " + id + " from pending key events list. " + - "There are now " + pendingEvents.size() + " pending events."); - Activity activity = getActivity(context); - if (activity != null) { - // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and - // send it to the framework again. - dispatchingKeyEvent = true; - activity.dispatchKeyEvent(pendingEvent); - dispatchingKeyEvent = false; - } - } - - private Activity getActivity(Context context) { - if (context instanceof Activity) { - return (Activity) context; - } - if (context instanceof ContextWrapper) { - // Recurse up chain of base contexts until we find an Activity. - return getActivity(((ContextWrapper) context).getBaseContext()); - } - return 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 @@ -165,7 +136,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) { @@ -177,4 +149,93 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi return complexCharacter; } + + public 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; + private final Deque> pendingEvents = + new ArrayDeque>(); + @NonNull private final Context context; + public boolean dispatchingKeyEvent = false; + + public EventResponder(@NonNull Context context) { + this.context = context; + } + + /** + * 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(@NonNull long id) { + if (pendingEvents.getFirst().getKey() != id) { + throw new AssertionError("Event response received out of order"); + } + 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(@NonNull 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(@NonNull long id) { + KeyEvent pendingEvent = removePendingEvent(id); + + // Since the framework didn't handle it, dispatch the key again. + Activity activity = getActivity(context); + if (activity != null) { + // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and + // send it to the framework again. + dispatchingKeyEvent = true; + activity.dispatchKeyEvent(pendingEvent); + dispatchingKeyEvent = false; + } + } + + /** 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) { + 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?"); + } + } + + /** + * Gets the nearest ancestor Activity for the given Context. + * + * @param context the context to look in for the activity. + * @return null if no Activity found. + */ + private Activity getActivity(Context context) { + if (context instanceof Activity) { + return (Activity) context; + } + if (context instanceof ContextWrapper) { + // Recurse up chain of base contexts until we find an Activity. + return getActivity(((ContextWrapper) context).getBaseContext()); + } + return null; + } + } } diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index afc1fae151249..a17549b92b6e1 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -849,7 +849,8 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) { this.flutterEngine.getPlatformViewsController()); localizationPlugin = this.flutterEngine.getLocalizationPlugin(); androidKeyProcessor = - new AndroidKeyProcessor(getContext(), this.flutterEngine.getKeyEventChannel(), textInputPlugin); + new AndroidKeyProcessor( + getContext(), this.flutterEngine.getKeyEventChannel(), textInputPlugin); androidTouchProcessor = new AndroidTouchProcessor(this.flutterEngine.getRenderer(), /*trackMotionEvents=*/ false); accessibilityBridge = 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 d266682561d80..fdf9a9bb7d48e 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java @@ -10,44 +10,63 @@ import android.view.KeyEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.flutter.embedding.android.AndroidKeyProcessor; import io.flutter.embedding.engine.dart.DartExecutor; import io.flutter.plugin.common.BasicMessageChannel; import io.flutter.plugin.common.JSONMessageCodec; import java.util.HashMap; import java.util.Map; -import org.json.JSONObject; import org.json.JSONException; +import org.json.JSONObject; /** * 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. -*/ + *

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"; private static long eventIdSerial = 0; /** - * Sets the key processor to be used to receive messages from the framework on - * this channel. + * Sets the event response handler to be used to receive key event response messages from the + * framework on this channel. */ - public void setKeyProcessor(AndroidKeyProcessor processor) { - this.processor = processor; + 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(@NonNull long 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. + */ + public void onKeyEventNotHandled(@NonNull long id); } - private AndroidKeyProcessor processor; private final BasicMessageChannel.MessageHandler messageHandler = new BasicMessageChannel.MessageHandler() { @Override public void onMessage( @Nullable Object message, @NonNull BasicMessageChannel.Reply reply) { - Log.v(TAG, "Received key event channel message."); - - // If there is no processor to respond to this message then we don't need to - // parse it. - if (processor == null) { + // If there is no handler to respond to this message then we don't + // need to parse it. + if (eventResponseHandler == null) { return; } @@ -56,15 +75,12 @@ public void onMessage( final String type = annotatedEvent.getString("type"); final JSONObject data = annotatedEvent.getJSONObject("data"); - Log.v(TAG, "Received " + type + " message."); switch (type) { case "keyHandled": - Log.w(TAG, "Handled key event " + data.getLong("eventId")); - processor.onKeyEventHandled(data.getLong("eventId")); + eventResponseHandler.onKeyEventHandled(data.getLong("eventId")); break; case "keyNotHandled": - Log.w(TAG, "Did not handle key event " + data.getLong("eventId")); - processor.onKeyEventNotHandled(data.getLong("eventId")); + eventResponseHandler.onKeyEventNotHandled(data.getLong("eventId")); break; } } catch (JSONException e) { From 7bd15f6b7ddf8f668e65359adc03d547c5a0151c Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Mon, 15 Jun 2020 12:32:46 -0700 Subject: [PATCH 4/7] Added tests --- .../android/AndroidKeyProcessor.java | 2 +- .../test/io/flutter/FlutterTestSuite.java | 3 + .../android/AndroidKeyProcessorTest.java | 84 +++++++++++++++++++ .../embedding/engine/KeyEventChannelTest.java | 75 +++++++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java create mode 100644 shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index 962e94125d01f..b530aec266235 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -29,7 +29,7 @@ public class AndroidKeyProcessor { @NonNull private final KeyEventChannel keyEventChannel; @NonNull private final TextInputPlugin textInputPlugin; @NonNull private int combiningCharacter; - @NonNull private EventResponder eventResponder; + @NonNull public final EventResponder eventResponder; public AndroidKeyProcessor( @NonNull Context context, diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index 075e33c24b53e..a2df580c2cc1b 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; @@ -14,6 +15,7 @@ import io.flutter.embedding.engine.FlutterEnginePluginRegistryTest; import io.flutter.embedding.engine.FlutterJNITest; import io.flutter.embedding.engine.LocalizationPluginTest; +import io.flutter.embedding.engine.KeyEventChannelTest; import io.flutter.embedding.engine.RenderingComponentTest; import io.flutter.embedding.engine.plugins.shim.ShimPluginRegistryTest; import io.flutter.embedding.engine.renderer.FlutterRendererTest; @@ -40,6 +42,7 @@ @RunWith(Suite.class) @SuiteClasses({ DartExecutorTest.class, + AndroidKeyProcessorTest.class, FlutterActivityAndFragmentDelegateTest.class, FlutterActivityTest.class, FlutterAndroidComponentTest.class, 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..e3ffadbf32e8d --- /dev/null +++ b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java @@ -0,0 +1,84 @@ +package io.flutter.embedding.android; + +import static junit.framework.TestCase.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.view.WindowManager; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.FlutterJNI; +import io.flutter.embedding.engine.loader.FlutterLoader; +import io.flutter.embedding.engine.renderer.FlutterRenderer; +import io.flutter.embedding.engine.systemchannels.KeyEventChannel; +import io.flutter.embedding.android.AndroidKeyProcessor; +import io.flutter.plugin.platform.PlatformViewsController; +import java.util.concurrent.atomic.AtomicReference; +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.Spy; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowDisplay; + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner.class) +@TargetApi(28) +public class AndroidKeyProcessorTest { + @Mock FlutterJNI mockFlutterJni; + @Mock FlutterLoader mockFlutterLoader; + @Spy PlatformViewsController platformViewsController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mockFlutterJni.isAttached()).thenReturn(true); + } + + @Test + public void sendsKeyEventsToEventResponder() { + // Setup test. + AtomicReference reportedBrightness = + new AtomicReference<>(); + + Context spiedContext = spy(RuntimeEnvironment.application); + + Resources spiedResources = spy(spiedContext.getResources()); + when(spiedContext.getResources()).thenReturn(spiedResources); + + FlutterView flutterView = new FlutterView(spiedContext); + FlutterEngine flutterEngine = + spy(new FlutterEngine(RuntimeEnvironment.application, mockFlutterLoader, mockFlutterJni)); + + KeyEventChannel fakeKeyEventChannel = mock(KeyEventChannel.class); + TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); + when(fakeTextInputPlugin.getLastInputConnection()).thenAnswer(null); + + AndroidKeyProcessor processor = AndroidKeyProcessor(spiedContext, fakeKeyEventChannel, fakeTextInputPlugin); + + processor.onKeyDown(KeyEvent(KeyEvent.ACTION_DOWN, 65)); + assertEquals(processor.eventResponder.dispatchingKeyEvent, false); + processor.onKeyUp(KeyEvent(KeyEvent.ACTION_DOWN, 65)); + assertEquals(processor.eventResponder.dispatchingKeyEvent, false); + } +} diff --git a/shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java b/shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java new file mode 100644 index 0000000000000..7ff9f72871cf8 --- /dev/null +++ b/shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java @@ -0,0 +1,75 @@ +package io.flutter.embedding.android; + +import static junit.framework.TestCase.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.view.WindowManager; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.FlutterJNI; +import io.flutter.embedding.engine.loader.FlutterLoader; +import io.flutter.embedding.engine.renderer.FlutterRenderer; +import io.flutter.embedding.engine.systemchannels.KeyEventChannel; +import io.flutter.embedding.android.AndroidKeyProcessor; +import io.flutter.plugin.platform.PlatformViewsController; +import java.util.concurrent.atomic.AtomicReference; +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.Spy; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowDisplay; + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner.class) +@TargetApi(28) +public class AndroidKeyProcessorTest { + @Mock FlutterJNI mockFlutterJni; + @Mock FlutterLoader mockFlutterLoader; + @Spy PlatformViewsController platformViewsController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mockFlutterJni.isAttached()).thenReturn(true); + } + + @Test + public void sendsKeyEventsToEventResponder() { + // Setup test. + AtomicReference reportedBrightness = + new AtomicReference<>(); + + Context spiedContext = spy(RuntimeEnvironment.application); + + Resources spiedResources = spy(spiedContext.getResources()); + when(spiedContext.getResources()).thenReturn(spiedResources); + + FlutterView flutterView = new FlutterView(spiedContext); + FlutterEngine flutterEngine = + spy(new FlutterEngine(RuntimeEnvironment.application, mockFlutterLoader, mockFlutterJni)); + + // Test stuff here. + } +} From d048b01358885d405a5ee205ec7674bb81004ff8 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 19 Jun 2020 16:07:36 -0700 Subject: [PATCH 5/7] Fixed tests --- shell/platform/android/BUILD.gn | 2 + .../android/AndroidKeyProcessor.java | 68 ++++++---- .../systemchannels/KeyEventChannel.java | 106 ++++++++------- .../test/io/flutter/FlutterTestSuite.java | 16 ++- .../android/AndroidKeyProcessorTest.java | 119 +++++++++++------ ...lutterActivityAndFragmentDelegateTest.java | 18 +-- .../embedding/engine/KeyEventChannelTest.java | 75 ----------- .../systemchannels/KeyEventChannelTest.java | 124 ++++++++++++++++++ 8 files changed, 326 insertions(+), 202 deletions(-) delete mode 100644 shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java create mode 100644 shell/platform/android/test/io/flutter/embedding/engine/systemchannels/KeyEventChannelTest.java diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 93e6b140a7304..6a941dba74acb 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -430,6 +430,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", @@ -447,6 +448,7 @@ action("robolectric_tests") { "test/io/flutter/embedding/engine/dart/DartExecutorTest.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/RestorationChannelTest.java", "test/io/flutter/external/FlutterLaunchTests.java", "test/io/flutter/plugin/common/StandardMessageCodecTest.java", diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index b530aec266235..22f11ab6ca133 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -25,11 +25,12 @@ */ public class AndroidKeyProcessor { private static final String TAG = "AndroidKeyProcessor"; + private static long eventIdSerial = 0; @NonNull private final KeyEventChannel keyEventChannel; @NonNull private final TextInputPlugin textInputPlugin; - @NonNull private int combiningCharacter; - @NonNull public final EventResponder eventResponder; + private int combiningCharacter; + @NonNull EventResponder eventResponder; public AndroidKeyProcessor( @NonNull Context context, @@ -41,6 +42,17 @@ public AndroidKeyProcessor( this.keyEventChannel.setEventResponseHandler(eventResponder); } + /** + * Set the event responder for this key processor. + * + *

Typically used by the testing framework to inject mocks. + * + * @param eventResponder the event responder to use instead of the default responder. + */ + public void setEventResponder(@NonNull EventResponder eventResponder) { + this.eventResponder = eventResponder; + } + /** * Called when a key up event is received by the {@link FlutterView}. * @@ -55,7 +67,7 @@ public boolean onKeyUp(@NonNull KeyEvent keyEvent) { Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); KeyEventChannel.FlutterKeyEvent flutterEvent = - new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); + new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++); keyEventChannel.keyUp(flutterEvent); eventResponder.addEvent(flutterEvent.eventId, keyEvent); return true; @@ -85,7 +97,7 @@ public boolean onKeyDown(@NonNull KeyEvent keyEvent) { Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar()); KeyEventChannel.FlutterKeyEvent flutterEvent = - new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter); + new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++); keyEventChannel.keyDown(flutterEvent); eventResponder.addEvent(flutterEvent.eventId, keyEvent); return true; @@ -124,7 +136,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) { @@ -154,10 +166,9 @@ public static class EventResponder implements KeyEventChannel.EventResponseHandl // The maximum number of pending events that are held before starting to // complain. private static final long MAX_PENDING_EVENTS = 1000; - private final Deque> pendingEvents = - new ArrayDeque>(); + final Deque> pendingEvents = new ArrayDeque>(); @NonNull private final Context context; - public boolean dispatchingKeyEvent = false; + boolean dispatchingKeyEvent = false; public EventResponder(@NonNull Context context) { this.context = context; @@ -168,9 +179,13 @@ public EventResponder(@NonNull Context context) { * * @param id the id of the event to be removed. */ - private KeyEvent removePendingEvent(@NonNull long id) { + private KeyEvent removePendingEvent(long id) { if (pendingEvents.getFirst().getKey() != id) { - throw new AssertionError("Event response received out of order"); + throw new AssertionError( + "Event response received out of order. Should have seen event " + + pendingEvents.getFirst().getKey() + + " first. Instead, received " + + id); } return pendingEvents.removeFirst().getValue(); } @@ -182,7 +197,7 @@ private KeyEvent removePendingEvent(@NonNull long id) { * be null. */ @Override - public void onKeyEventHandled(@NonNull long id) { + public void onKeyEventHandled(long id) { removePendingEvent(id); } @@ -194,18 +209,8 @@ public void onKeyEventHandled(@NonNull long id) { * not be null. */ @Override - public void onKeyEventNotHandled(@NonNull long id) { - KeyEvent pendingEvent = removePendingEvent(id); - - // Since the framework didn't handle it, dispatch the key again. - Activity activity = getActivity(context); - if (activity != null) { - // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and - // send it to the framework again. - dispatchingKeyEvent = true; - activity.dispatchKeyEvent(pendingEvent); - dispatchingKeyEvent = false; - } + 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. */ @@ -221,6 +226,23 @@ public void addEvent(long id, @NonNull KeyEvent event) { } } + /** + * 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. + Activity activity = getActivity(context); + if (activity != null) { + // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and + // send it to the framework again. + dispatchingKeyEvent = true; + activity.dispatchKeyEvent(event); + dispatchingKeyEvent = false; + } + } + /** * Gets the nearest ancestor Activity for the given Context. * 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 fdf9a9bb7d48e..0f2dae452e334 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java @@ -5,13 +5,13 @@ package io.flutter.embedding.engine.systemchannels; import android.os.Build; -import android.util.Log; import android.view.InputDevice; 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; @@ -26,7 +26,6 @@ */ public class KeyEventChannel { private static final String TAG = "KeyEventChannel"; - private static long eventIdSerial = 0; /** * Sets the event response handler to be used to receive key event response messages from the @@ -47,7 +46,7 @@ public interface EventResponseHandler { * @param id the event id of the event to be marked as being handled by the framework. Must not * be null. */ - public void onKeyEventHandled(@NonNull long id); + public void onKeyEventHandled(long id); /** * Called whenever the framework responds that a given key event wasn't handled by the @@ -56,56 +55,58 @@ public interface EventResponseHandler { * @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(@NonNull long id); + public void onKeyEventNotHandled(long id); } - private final BasicMessageChannel.MessageHandler messageHandler = - new BasicMessageChannel.MessageHandler() { - @Override - public void onMessage( - @Nullable Object message, @NonNull BasicMessageChannel.Reply reply) { - // If there is no handler to respond to this message then we don't - // need to parse it. - if (eventResponseHandler == null) { - return; - } - - try { - final JSONObject annotatedEvent = (JSONObject) message; - final String type = annotatedEvent.getString("type"); - final JSONObject data = annotatedEvent.getJSONObject("data"); - - switch (type) { - case "keyHandled": - eventResponseHandler.onKeyEventHandled(data.getLong("eventId")); - break; - case "keyNotHandled": - eventResponseHandler.onKeyEventNotHandled(data.getLong("eventId")); - break; - } - } catch (JSONException e) { - Log.e(TAG, "Unable to unpack JSON message: " + e); - } finally { - reply.reply(null); - } - } - }; + /** + * 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<>(binaryMessenger, "flutter/keyevent", JSONMessageCodec.INSTANCE); + } - @NonNull public final BasicMessageChannel channel; + /** + * 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; + } - public KeyEventChannel(@NonNull DartExecutor dartExecutor) { - this.channel = - new BasicMessageChannel<>(dartExecutor, "flutter/keyevent", JSONMessageCodec.INSTANCE); - this.channel.setMessageHandler(messageHandler); + 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) { @@ -114,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( @@ -136,7 +137,10 @@ private void encodeKeyEvent( message.put("eventId", event.eventId); } - /** Key event as defined by Flutter. */ + /** + * A key event as defined by Flutter that includes an id for the specific event to be used when + * responding to the event. + */ public static class FlutterKeyEvent { public final int deviceId; public final int flags; @@ -152,12 +156,12 @@ public static class FlutterKeyEvent { public final int repeatCount; 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(), @@ -168,7 +172,8 @@ public FlutterKeyEvent( androidKeyEvent.getScanCode(), androidKeyEvent.getMetaState(), androidKeyEvent.getSource(), - androidKeyEvent.getRepeatCount()); + androidKeyEvent.getRepeatCount(), + eventId); } public FlutterKeyEvent( @@ -181,7 +186,8 @@ public FlutterKeyEvent( int scanCode, int metaState, int source, - int repeatCount) { + int repeatCount, + long eventId) { this.deviceId = deviceId; this.flags = flags; this.plainCodePoint = plainCodePoint; @@ -192,7 +198,7 @@ public FlutterKeyEvent( this.metaState = metaState; this.source = source; this.repeatCount = repeatCount; - this.eventId = eventIdSerial++; + 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/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index a2df580c2cc1b..453557b3b5e0e 100644 --- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java +++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java @@ -19,6 +19,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.RestorationChannelTest; import io.flutter.external.FlutterLaunchTests; import io.flutter.plugin.common.StandardMessageCodecTest; @@ -41,8 +42,9 @@ @RunWith(Suite.class) @SuiteClasses({ - DartExecutorTest.class, + AccessibilityBridgeTest.class, AndroidKeyProcessorTest.class, + DartExecutorTest.class, FlutterActivityAndFragmentDelegateTest.class, FlutterActivityTest.class, FlutterAndroidComponentTest.class, @@ -53,25 +55,25 @@ FlutterFragmentTest.class, FlutterJNITest.class, FlutterLaunchTests.class, - FlutterShellArgsTest.class, FlutterRendererTest.class, + FlutterShellArgsTest.class, FlutterViewTest.class, InputConnectionAdaptorTest.class, + KeyEventChannelTest.class, LocalizationPluginTest.class, + MouseCursorPluginTest.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, - 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 index e3ffadbf32e8d..cb6e451a369a2 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java @@ -1,7 +1,9 @@ package io.flutter.embedding.android; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; import static junit.framework.TestCase.assertEquals; -import static org.mockito.Matchers.any; +import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -9,45 +11,31 @@ import static org.mockito.Mockito.when; import android.annotation.TargetApi; +import android.app.Application; import android.content.Context; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.view.WindowManager; +import android.view.KeyEvent; +import androidx.annotation.NonNull; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.FlutterJNI; -import io.flutter.embedding.engine.loader.FlutterLoader; -import io.flutter.embedding.engine.renderer.FlutterRenderer; import io.flutter.embedding.engine.systemchannels.KeyEventChannel; -import io.flutter.embedding.android.AndroidKeyProcessor; -import io.flutter.plugin.platform.PlatformViewsController; -import java.util.concurrent.atomic.AtomicReference; +import io.flutter.embedding.engine.systemchannels.TextInputChannel; +import io.flutter.plugin.editing.TextInputPlugin; +import io.flutter.util.FakeKeyEvent; +import java.util.Map; 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.Spy; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; -import org.robolectric.Shadows; import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.shadows.ShadowDisplay; @Config(manifest = Config.NONE) @RunWith(RobolectricTestRunner.class) @TargetApi(28) public class AndroidKeyProcessorTest { @Mock FlutterJNI mockFlutterJni; - @Mock FlutterLoader mockFlutterLoader; - @Spy PlatformViewsController platformViewsController; @Before public void setUp() { @@ -56,29 +44,82 @@ public void setUp() { } @Test - public void sendsKeyEventsToEventResponder() { - // Setup test. - AtomicReference reportedBrightness = - new AtomicReference<>(); + public void sendsKeyDownEventsToEventResponder() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); + TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); + + AndroidKeyProcessor processor = + new AndroidKeyProcessor( + RuntimeEnvironment.application, fakeKeyEventChannel, fakeTextInputPlugin); - Context spiedContext = spy(RuntimeEnvironment.application); + processor.onKeyDown(new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65)); + assertFalse(processor.eventResponder.dispatchingKeyEvent); + verify(fakeKeyEventChannel, times(1)).keyDown(any(KeyEventChannel.FlutterKeyEvent.class)); + verify(fakeKeyEventChannel, times(0)).keyUp(any(KeyEventChannel.FlutterKeyEvent.class)); + assertEquals(1, processor.eventResponder.pendingEvents.size()); + Map.Entry firstPendingEvent = + processor.eventResponder.pendingEvents.peekFirst(); + assertNotNull(firstPendingEvent); + processor.eventResponder.onKeyEventHandled(firstPendingEvent.getKey()); + assertEquals(0, processor.eventResponder.pendingEvents.size()); + } - Resources spiedResources = spy(spiedContext.getResources()); - when(spiedContext.getResources()).thenReturn(spiedResources); + @Test + public void unhandledKeyEventsAreSynthesized() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); + TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); + Application spiedApplication = spy(RuntimeEnvironment.application); + Context spiedContext = spy(spiedApplication.getBaseContext()); + when(spiedApplication.getBaseContext()).thenReturn(spiedContext); - FlutterView flutterView = new FlutterView(spiedContext); - FlutterEngine flutterEngine = - spy(new FlutterEngine(RuntimeEnvironment.application, mockFlutterLoader, mockFlutterJni)); + AndroidKeyProcessor processor = + new AndroidKeyProcessor(spiedApplication, fakeKeyEventChannel, fakeTextInputPlugin); + AndroidKeyProcessor.EventResponder eventResponder = + spy(new AndroidKeyProcessor.EventResponder(spiedContext)); + processor.setEventResponder(eventResponder); - KeyEventChannel fakeKeyEventChannel = mock(KeyEventChannel.class); + KeyEvent event = new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65); + processor.onKeyDown(event); + assertFalse(processor.eventResponder.dispatchingKeyEvent); + assertEquals(1, processor.eventResponder.pendingEvents.size()); + Map.Entry firstPendingEvent = + processor.eventResponder.pendingEvents.peekFirst(); + assertNotNull(firstPendingEvent); + processor.eventResponder.onKeyEventNotHandled(firstPendingEvent.getKey()); + assertEquals(0, processor.eventResponder.pendingEvents.size()); + verify(eventResponder).dispatchKeyEvent(event); + } + + @Test + public void sendsKeyUpEventsToEventResponder() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); - when(fakeTextInputPlugin.getLastInputConnection()).thenAnswer(null); - AndroidKeyProcessor processor = AndroidKeyProcessor(spiedContext, fakeKeyEventChannel, fakeTextInputPlugin); + AndroidKeyProcessor processor = + new AndroidKeyProcessor( + RuntimeEnvironment.application, fakeKeyEventChannel, fakeTextInputPlugin); + + processor.onKeyUp(new FakeKeyEvent(KeyEvent.ACTION_UP, 65)); + assertFalse(processor.eventResponder.dispatchingKeyEvent); + verify(fakeKeyEventChannel, times(0)).keyDown(any(KeyEventChannel.FlutterKeyEvent.class)); + verify(fakeKeyEventChannel, times(1)).keyUp(any(KeyEventChannel.FlutterKeyEvent.class)); + Map.Entry firstPendingEvent = + processor.eventResponder.pendingEvents.peekFirst(); + assertNotNull(firstPendingEvent); + processor.eventResponder.onKeyEventHandled(firstPendingEvent.getKey()); + assertEquals(0, processor.eventResponder.pendingEvents.size()); + } + + @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)); - processor.onKeyDown(KeyEvent(KeyEvent.ACTION_DOWN, 65)); - assertEquals(processor.eventResponder.dispatchingKeyEvent, false); - processor.onKeyUp(KeyEvent(KeyEvent.ACTION_DOWN, 65)); - assertEquals(processor.eventResponder.dispatchingKeyEvent, false); + 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/KeyEventChannelTest.java b/shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java deleted file mode 100644 index 7ff9f72871cf8..0000000000000 --- a/shell/platform/android/test/io/flutter/embedding/engine/KeyEventChannelTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package io.flutter.embedding.android; - -import static junit.framework.TestCase.assertEquals; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import android.annotation.TargetApi; -import android.content.Context; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowInsets; -import android.view.WindowManager; -import io.flutter.embedding.engine.FlutterEngine; -import io.flutter.embedding.engine.FlutterJNI; -import io.flutter.embedding.engine.loader.FlutterLoader; -import io.flutter.embedding.engine.renderer.FlutterRenderer; -import io.flutter.embedding.engine.systemchannels.KeyEventChannel; -import io.flutter.embedding.android.AndroidKeyProcessor; -import io.flutter.plugin.platform.PlatformViewsController; -import java.util.concurrent.atomic.AtomicReference; -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.Spy; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.Shadows; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.shadows.ShadowDisplay; - -@Config(manifest = Config.NONE) -@RunWith(RobolectricTestRunner.class) -@TargetApi(28) -public class AndroidKeyProcessorTest { - @Mock FlutterJNI mockFlutterJni; - @Mock FlutterLoader mockFlutterLoader; - @Spy PlatformViewsController platformViewsController; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - when(mockFlutterJni.isAttached()).thenReturn(true); - } - - @Test - public void sendsKeyEventsToEventResponder() { - // Setup test. - AtomicReference reportedBrightness = - new AtomicReference<>(); - - Context spiedContext = spy(RuntimeEnvironment.application); - - Resources spiedResources = spy(spiedContext.getResources()); - when(spiedContext.getResources()).thenReturn(spiedResources); - - FlutterView flutterView = new FlutterView(spiedContext); - FlutterEngine flutterEngine = - spy(new FlutterEngine(RuntimeEnvironment.application, mockFlutterLoader, mockFlutterJni)); - - // Test stuff here. - } -} 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]); + } +} From 945bc602cad981d1735e9fc7717e633cc28902ba Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 24 Jun 2020 19:59:03 -0700 Subject: [PATCH 6/7] Review Changes --- .../android/AndroidKeyProcessor.java | 37 ++++++++- .../systemchannels/KeyEventChannel.java | 83 +++++++++++++++++-- .../plugin/editing/TextInputPlugin.java | 2 +- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index 22f11ab6ca133..a6f39f07a829e 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -12,6 +12,7 @@ import android.view.KeyEvent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import io.flutter.embedding.engine.systemchannels.KeyEventChannel; import io.flutter.plugin.editing.TextInputPlugin; import java.util.AbstractMap.SimpleImmutableEntry; @@ -22,6 +23,16 @@ /** * 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"; @@ -32,6 +43,16 @@ public class AndroidKeyProcessor { private int combiningCharacter; @NonNull EventResponder eventResponder; + /** + * Constructor for AndroidKeyProcessor. + * + * @param context takes the application context so that this processor can find the activity 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 Context context, @NonNull KeyEventChannel keyEventChannel, @@ -49,6 +70,7 @@ public AndroidKeyProcessor( * * @param eventResponder the event responder to use instead of the default responder. */ + @VisibleForTesting public void setEventResponder(@NonNull EventResponder eventResponder) { this.eventResponder = eventResponder; } @@ -57,7 +79,8 @@ public void setEventResponder(@NonNull EventResponder eventResponder) { * 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 was handled and should not be propagated. + * @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) { @@ -77,7 +100,8 @@ public boolean onKeyUp(@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 was handled and should not be propagated. + * @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) { @@ -168,7 +192,7 @@ public static class EventResponder implements KeyEventChannel.EventResponseHandl private static final long MAX_PENDING_EVENTS = 1000; final Deque> pendingEvents = new ArrayDeque>(); @NonNull private final Context context; - boolean dispatchingKeyEvent = false; + @VisibleForTesting boolean dispatchingKeyEvent = false; public EventResponder(@NonNull Context context) { this.context = context; @@ -215,6 +239,13 @@ public void onKeyEventNotHandled(long 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( 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 0f2dae452e334..3638c2d364ae7 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/KeyEventChannel.java @@ -134,26 +134,99 @@ private void encodeKeyEvent( message.put("productId", event.productId); message.put("deviceId", event.deviceId); message.put("repeatCount", event.repeatCount); - message.put("eventId", event.eventId); } - /** - * A key event as defined by Flutter that includes an id for the specific event to be used when - * responding to the event. - */ + /** 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, long eventId) { diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index 698a44cccfd73..716246d189299 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -127,7 +127,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 From 9276b95ce573e4fc8ec67bee4e24df6dd4761bf3 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 25 Jun 2020 19:29:52 -0700 Subject: [PATCH 7/7] Test behavior instead of implementation. Made more of AndroidKeyProcessor guts private --- .../android/AndroidKeyProcessor.java | 73 ++++------ .../embedding/android/FlutterView.java | 3 +- .../android/io/flutter/view/FlutterView.java | 2 +- .../test/io/flutter/FlutterTestSuite.java | 1 - .../android/AndroidKeyProcessorTest.java | 135 ++++++++++-------- 5 files changed, 105 insertions(+), 109 deletions(-) diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index a6f39f07a829e..d0d5c1d68cdab 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -4,15 +4,12 @@ package io.flutter.embedding.android; -import android.app.Activity; -import android.content.Context; -import android.content.ContextWrapper; 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 androidx.annotation.VisibleForTesting; import io.flutter.embedding.engine.systemchannels.KeyEventChannel; import io.flutter.plugin.editing.TextInputPlugin; import java.util.AbstractMap.SimpleImmutableEntry; @@ -41,40 +38,38 @@ public class AndroidKeyProcessor { @NonNull private final KeyEventChannel keyEventChannel; @NonNull private final TextInputPlugin textInputPlugin; private int combiningCharacter; - @NonNull EventResponder eventResponder; + @NonNull private EventResponder eventResponder; /** * Constructor for AndroidKeyProcessor. * - * @param context takes the application context so that this processor can find the activity for - * re-dispatching of events that were not handled by the framework. + *

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 Context context, + @NonNull View view, @NonNull KeyEventChannel keyEventChannel, @NonNull TextInputPlugin textInputPlugin) { this.keyEventChannel = keyEventChannel; this.textInputPlugin = textInputPlugin; - this.eventResponder = new EventResponder(context); + this.eventResponder = new EventResponder(view); this.keyEventChannel.setEventResponseHandler(eventResponder); } - /** - * Set the event responder for this key processor. - * - *

Typically used by the testing framework to inject mocks. - * - * @param eventResponder the event responder to use instead of the default responder. - */ - @VisibleForTesting - public void setEventResponder(@NonNull EventResponder eventResponder) { - this.eventResponder = eventResponder; - } - /** * Called when a key up event is received by the {@link FlutterView}. * @@ -110,8 +105,8 @@ public boolean onKeyDown(@NonNull KeyEvent keyEvent) { } // 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. + // 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()) { if (textInputPlugin.getLastInputConnection().sendKeyEvent(keyEvent)) { @@ -186,16 +181,16 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi return complexCharacter; } - public static class EventResponder implements KeyEventChannel.EventResponseHandler { + 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 Context context; - @VisibleForTesting boolean dispatchingKeyEvent = false; + @NonNull private final View view; + boolean dispatchingKeyEvent = false; - public EventResponder(@NonNull Context context) { - this.context = context; + public EventResponder(@NonNull View view) { + this.view = view; } /** @@ -264,31 +259,13 @@ public void addEvent(long id, @NonNull KeyEvent event) { */ public void dispatchKeyEvent(KeyEvent event) { // Since the framework didn't handle it, dispatch the key again. - Activity activity = getActivity(context); - if (activity != null) { + if (view != null) { // Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and // send it to the framework again. dispatchingKeyEvent = true; - activity.dispatchKeyEvent(event); + view.dispatchKeyEvent(event); dispatchingKeyEvent = false; } } - - /** - * Gets the nearest ancestor Activity for the given Context. - * - * @param context the context to look in for the activity. - * @return null if no Activity found. - */ - private Activity getActivity(Context context) { - if (context instanceof Activity) { - return (Activity) context; - } - if (context instanceof ContextWrapper) { - // Recurse up chain of base contexts until we find an Activity. - return getActivity(((ContextWrapper) context).getBaseContext()); - } - return null; - } } } diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index a17549b92b6e1..fa198d38190f0 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -849,8 +849,7 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) { this.flutterEngine.getPlatformViewsController()); localizationPlugin = this.flutterEngine.getLocalizationPlugin(); androidKeyProcessor = - new AndroidKeyProcessor( - getContext(), 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/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 521bb94bc857b..fea199e0a84e2 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(getContext(), keyEventChannel, mTextInputPlugin); + androidKeyProcessor = new AndroidKeyProcessor(this, keyEventChannel, mTextInputPlugin); androidTouchProcessor = new AndroidTouchProcessor(flutterRenderer, /*trackMotionEvents=*/ false); platformViewsController.attachToFlutterRenderer(flutterRenderer); diff --git a/shell/platform/android/test/io/flutter/FlutterTestSuite.java b/shell/platform/android/test/io/flutter/FlutterTestSuite.java index 453557b3b5e0e..05e295eec8667 100644 --- a/shell/platform/android/test/io/flutter/FlutterTestSuite.java +++ b/shell/platform/android/test/io/flutter/FlutterTestSuite.java @@ -15,7 +15,6 @@ import io.flutter.embedding.engine.FlutterEnginePluginRegistryTest; import io.flutter.embedding.engine.FlutterJNITest; import io.flutter.embedding.engine.LocalizationPluginTest; -import io.flutter.embedding.engine.KeyEventChannelTest; import io.flutter.embedding.engine.RenderingComponentTest; import io.flutter.embedding.engine.plugins.shim.ShimPluginRegistryTest; import io.flutter.embedding.engine.renderer.FlutterRendererTest; diff --git a/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java index cb6e451a369a2..0f885c7d2642b 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java @@ -1,19 +1,15 @@ package io.flutter.embedding.android; -import static junit.framework.Assert.assertFalse; -import static junit.framework.Assert.assertNotNull; import static junit.framework.TestCase.assertEquals; import static org.mockito.Mockito.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.annotation.TargetApi; -import android.app.Application; -import android.content.Context; import android.view.KeyEvent; +import android.view.View; import androidx.annotation.NonNull; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.embedding.engine.FlutterJNI; @@ -21,14 +17,15 @@ import io.flutter.embedding.engine.systemchannels.TextInputChannel; import io.flutter.plugin.editing.TextInputPlugin; import io.flutter.util.FakeKeyEvent; -import java.util.Map; 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.RuntimeEnvironment; import org.robolectric.annotation.Config; @Config(manifest = Config.NONE) @@ -44,73 +41,97 @@ public void setUp() { } @Test - public void sendsKeyDownEventsToEventResponder() { + public void respondsTrueWhenHandlingNewEvents() { FlutterEngine flutterEngine = mockFlutterEngine(); KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); - TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); + View fakeView = mock(View.class); AndroidKeyProcessor processor = - new AndroidKeyProcessor( - RuntimeEnvironment.application, fakeKeyEventChannel, fakeTextInputPlugin); + new AndroidKeyProcessor(fakeView, fakeKeyEventChannel, mock(TextInputPlugin.class)); - processor.onKeyDown(new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65)); - assertFalse(processor.eventResponder.dispatchingKeyEvent); + 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)); - assertEquals(1, processor.eventResponder.pendingEvents.size()); - Map.Entry firstPendingEvent = - processor.eventResponder.pendingEvents.peekFirst(); - assertNotNull(firstPendingEvent); - processor.eventResponder.onKeyEventHandled(firstPendingEvent.getKey()); - assertEquals(0, processor.eventResponder.pendingEvents.size()); + verify(fakeView, times(0)).dispatchKeyEvent(any(KeyEvent.class)); } - @Test - public void unhandledKeyEventsAreSynthesized() { + public void synthesizesEventsWhenKeyDownNotHandled() { FlutterEngine flutterEngine = mockFlutterEngine(); KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); - TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); - Application spiedApplication = spy(RuntimeEnvironment.application); - Context spiedContext = spy(spiedApplication.getBaseContext()); - when(spiedApplication.getBaseContext()).thenReturn(spiedContext); - + View fakeView = mock(View.class); + ArgumentCaptor handlerCaptor = + ArgumentCaptor.forClass(KeyEventChannel.EventResponseHandler.class); + verify(fakeKeyEventChannel).setEventResponseHandler(handlerCaptor.capture()); AndroidKeyProcessor processor = - new AndroidKeyProcessor(spiedApplication, fakeKeyEventChannel, fakeTextInputPlugin); - AndroidKeyProcessor.EventResponder eventResponder = - spy(new AndroidKeyProcessor.EventResponder(spiedContext)); - processor.setEventResponder(eventResponder); - - KeyEvent event = new FakeKeyEvent(KeyEvent.ACTION_DOWN, 65); - processor.onKeyDown(event); - assertFalse(processor.eventResponder.dispatchingKeyEvent); - assertEquals(1, processor.eventResponder.pendingEvents.size()); - Map.Entry firstPendingEvent = - processor.eventResponder.pendingEvents.peekFirst(); - assertNotNull(firstPendingEvent); - processor.eventResponder.onKeyEventNotHandled(firstPendingEvent.getKey()); - assertEquals(0, processor.eventResponder.pendingEvents.size()); - verify(eventResponder).dispatchKeyEvent(event); + 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)); } - @Test - public void sendsKeyUpEventsToEventResponder() { + public void synthesizesEventsWhenKeyUpNotHandled() { FlutterEngine flutterEngine = mockFlutterEngine(); KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); - TextInputPlugin fakeTextInputPlugin = mock(TextInputPlugin.class); - + View fakeView = mock(View.class); + ArgumentCaptor handlerCaptor = + ArgumentCaptor.forClass(KeyEventChannel.EventResponseHandler.class); + verify(fakeKeyEventChannel).setEventResponseHandler(handlerCaptor.capture()); AndroidKeyProcessor processor = - new AndroidKeyProcessor( - RuntimeEnvironment.application, fakeKeyEventChannel, fakeTextInputPlugin); - - processor.onKeyUp(new FakeKeyEvent(KeyEvent.ACTION_UP, 65)); - assertFalse(processor.eventResponder.dispatchingKeyEvent); - verify(fakeKeyEventChannel, times(0)).keyDown(any(KeyEventChannel.FlutterKeyEvent.class)); - verify(fakeKeyEventChannel, times(1)).keyUp(any(KeyEventChannel.FlutterKeyEvent.class)); - Map.Entry firstPendingEvent = - processor.eventResponder.pendingEvents.peekFirst(); - assertNotNull(firstPendingEvent); - processor.eventResponder.onKeyEventHandled(firstPendingEvent.getKey()); - assertEquals(0, processor.eventResponder.pendingEvents.size()); + 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)); } @NonNull