Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions shell/platform/android/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,122 @@

package io.flutter.embedding.android;

import android.util.Log;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import io.flutter.embedding.engine.systemchannels.KeyEventChannel;
import io.flutter.plugin.editing.TextInputPlugin;
import java.util.AbstractMap.SimpleImmutableEntry;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map.Entry;

/**
* A class to process key events from Android, passing them to the framework as messages using
* {@link KeyEventChannel}.
*
* <p>A class that sends Android key events to the framework, and re-dispatches those not handled by
* the framework.
*
* <p>Flutter uses asynchronous event handling to avoid blocking the UI thread, but Android requires
* that events are handled synchronously. So, when a key event is received by Flutter, it tells
* Android synchronously that the key has been handled so that it won't propagate to other
* components. Flutter then uses "delayed event synthesis", where it sends the event to the
* framework, and if the framework responds that it has not handled the event, then this class
* synthesizes a new event to send to Android, without handling it this time.
*/
public class AndroidKeyProcessor {
private static final String TAG = "AndroidKeyProcessor";
private static long eventIdSerial = 0;

@NonNull private final KeyEventChannel keyEventChannel;
@NonNull private final TextInputPlugin textInputPlugin;
private int combiningCharacter;
@NonNull private EventResponder eventResponder;

/**
* Constructor for AndroidKeyProcessor.
*
* <p>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
*
* <p>It is possible that that in the middle of the async round trip, the focus chain could
* change, and instead of the native widget that was "next" when the event was fired getting the
* event, it may be the next widget when the event is synthesized that gets it. In practice, this
* shouldn't be a huge problem, as this is an unlikely occurance to happen without user input, and
* it may actually be desired behavior, but it is possible.
*
* @param view takes the activity to use for re-dispatching of events that were not handled by the
* framework.
* @param keyEventChannel the event channel to listen to for new key events.
* @param textInputPlugin a plugin, which, if set, is given key events before the framework is,
* and if it has a valid input connection and is accepting text, then it will handle the event
* and the framework will not receive it.
*/
public AndroidKeyProcessor(
@NonNull KeyEventChannel keyEventChannel, @NonNull TextInputPlugin textInputPlugin) {
@NonNull View view,
@NonNull KeyEventChannel keyEventChannel,
@NonNull TextInputPlugin textInputPlugin) {
this.keyEventChannel = keyEventChannel;
this.textInputPlugin = textInputPlugin;
this.eventResponder = new EventResponder(view);
this.keyEventChannel.setEventResponseHandler(eventResponder);
}

public void onKeyUp(@NonNull KeyEvent keyEvent) {
/**
* Called when a key up event is received by the {@link FlutterView}.
*
* @param keyEvent the Android key event to respond to.
* @return true if the key event should not be propagated to other Android components. Delayed
* synthesis events will return false, so that other components may handle them.
*/
public boolean onKeyUp(@NonNull KeyEvent keyEvent) {
if (eventResponder.dispatchingKeyEvent) {
// Don't handle it if it is from our own delayed event synthesis.
return false;
}

Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar());
keyEventChannel.keyUp(new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter));
KeyEventChannel.FlutterKeyEvent flutterEvent =
new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++);
keyEventChannel.keyUp(flutterEvent);
eventResponder.addEvent(flutterEvent.eventId, keyEvent);
return true;
}

public void onKeyDown(@NonNull KeyEvent keyEvent) {
/**
* Called when a key down event is received by the {@link FlutterView}.
*
* @param keyEvent the Android key event to respond to.
* @return true if the key event should not be propagated to other Android components. Delayed
* synthesis events will return false, so that other components may handle them.
*/
public boolean onKeyDown(@NonNull KeyEvent keyEvent) {
if (eventResponder.dispatchingKeyEvent) {
// Don't handle it if it is from our own delayed event synthesis.
return false;
}

// If the textInputPlugin is still valid and accepting text, then we'll try
// and send the key event to it, assuming that if the event can be sent,
// that it has been handled.
if (textInputPlugin.getLastInputConnection() != null
&& textInputPlugin.getInputMethodManager().isAcceptingText()) {
textInputPlugin.getLastInputConnection().sendKeyEvent(keyEvent);
if (textInputPlugin.getLastInputConnection().sendKeyEvent(keyEvent)) {
return true;
}
}

Character complexCharacter = applyCombiningCharacterToBaseCharacter(keyEvent.getUnicodeChar());
keyEventChannel.keyDown(new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter));
KeyEventChannel.FlutterKeyEvent flutterEvent =
new KeyEventChannel.FlutterKeyEvent(keyEvent, complexCharacter, eventIdSerial++);
keyEventChannel.keyDown(flutterEvent);
eventResponder.addEvent(flutterEvent.eventId, keyEvent);
return true;
}

/**
Expand Down Expand Up @@ -70,7 +155,7 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi
return null;
}

Character complexCharacter = (char) newCharacterCodePoint;
char complexCharacter = (char) newCharacterCodePoint;
boolean isNewCodePointACombiningCharacter =
(newCharacterCodePoint & KeyCharacterMap.COMBINING_ACCENT) != 0;
if (isNewCodePointACombiningCharacter) {
Expand All @@ -82,7 +167,8 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi
combiningCharacter = plainCodePoint;
}
} else {
// The new character is a regular character. Apply combiningCharacter to it, if it exists.
// The new character is a regular character. Apply combiningCharacter to it, if
// it exists.
if (combiningCharacter != 0) {
int combinedChar = KeyCharacterMap.getDeadChar(combiningCharacter, newCharacterCodePoint);
if (combinedChar > 0) {
Expand All @@ -94,4 +180,92 @@ private Character applyCombiningCharacterToBaseCharacter(int newCharacterCodePoi

return complexCharacter;
}

private static class EventResponder implements KeyEventChannel.EventResponseHandler {
// The maximum number of pending events that are held before starting to
// complain.
private static final long MAX_PENDING_EVENTS = 1000;
final Deque<Entry<Long, KeyEvent>> pendingEvents = new ArrayDeque<Entry<Long, KeyEvent>>();
@NonNull private final View view;
boolean dispatchingKeyEvent = false;

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

/**
* Removes the pending event with the given id from the cache of pending events.
*
* @param id the id of the event to be removed.
*/
private KeyEvent removePendingEvent(long id) {
if (pendingEvents.getFirst().getKey() != id) {
throw new AssertionError(
"Event response received out of order. Should have seen event "
+ pendingEvents.getFirst().getKey()
+ " first. Instead, received "
+ id);
}
return pendingEvents.removeFirst().getValue();
}

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

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

/** Adds an Android key event with an id to the event responder to wait for a response. */
public void addEvent(long id, @NonNull KeyEvent event) {
if (pendingEvents.size() > 0 && pendingEvents.getFirst().getKey() >= id) {
throw new AssertionError(
"New events must have ids greater than the most recent pending event. New id "
+ id
+ " is less than or equal to the last event id of "
+ pendingEvents.getFirst().getKey());
}
pendingEvents.addLast(new SimpleImmutableEntry<Long, KeyEvent>(id, event));
if (pendingEvents.size() > MAX_PENDING_EVENTS) {
Log.e(
TAG,
"There are "
+ pendingEvents.size()
+ " keyboard events "
+ "that have not yet received a response. Are responses being sent?");
}
}

/**
* Dispatches the event to the activity associated with the context.
*
* @param event the event to be dispatched to the activity.
*/
public void dispatchKeyEvent(KeyEvent event) {
// Since the framework didn't handle it, dispatch the key again.
if (view != null) {
// Turn on dispatchingKeyEvent so that we don't dispatch to ourselves and
// send it to the framework again.
dispatchingKeyEvent = true;
view.dispatchKeyEvent(event);
dispatchingKeyEvent = false;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -851,7 +849,7 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) {
this.flutterEngine.getPlatformViewsController());
localizationPlugin = this.flutterEngine.getLocalizationPlugin();
androidKeyProcessor =
new AndroidKeyProcessor(this.flutterEngine.getKeyEventChannel(), textInputPlugin);
new AndroidKeyProcessor(this, this.flutterEngine.getKeyEventChannel(), textInputPlugin);
androidTouchProcessor =
new AndroidTouchProcessor(this.flutterEngine.getRenderer(), /*trackMotionEvents=*/ false);
accessibilityBridge =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public void onMessage(
break;
}
}
reply.reply(null);
}
};

Expand Down
Loading