diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 4c28df56f499f..d004764ca7ec7 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -1189,9 +1189,10 @@ FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/Platfor FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewFactory.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewRegistry.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewRegistryImpl.java -FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewsAccessibilityDelegate.java FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java +FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/SingleViewPresentation.java +FILE: ../../../flutter/shell/platform/android/io/flutter/plugin/platform/VirtualDisplayController.java FILE: ../../../flutter/shell/platform/android/io/flutter/util/PathUtils.java FILE: ../../../flutter/shell/platform/android/io/flutter/util/Preconditions.java FILE: ../../../flutter/shell/platform/android/io/flutter/util/Predicate.java diff --git a/shell/platform/android/BUILD.gn b/shell/platform/android/BUILD.gn index 41f9e3aed0ec7..ac44391e0d85a 100644 --- a/shell/platform/android/BUILD.gn +++ b/shell/platform/android/BUILD.gn @@ -268,9 +268,10 @@ android_java_sources = [ "io/flutter/plugin/platform/PlatformViewFactory.java", "io/flutter/plugin/platform/PlatformViewRegistry.java", "io/flutter/plugin/platform/PlatformViewRegistryImpl.java", - "io/flutter/plugin/platform/PlatformViewWrapper.java", "io/flutter/plugin/platform/PlatformViewsAccessibilityDelegate.java", "io/flutter/plugin/platform/PlatformViewsController.java", + "io/flutter/plugin/platform/SingleViewPresentation.java", + "io/flutter/plugin/platform/VirtualDisplayController.java", "io/flutter/util/PathUtils.java", "io/flutter/util/Preconditions.java", "io/flutter/util/Predicate.java", diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidTouchProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidTouchProcessor.java index 32c6d954d5414..9e712e28043a4 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidTouchProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidTouchProcessor.java @@ -107,7 +107,7 @@ public boolean onTouchEvent(@NonNull MotionEvent event) { * the gesture pointers into screen coordinates. * @return True if the event was handled. */ - public boolean onTouchEvent(@NonNull MotionEvent event, @NonNull Matrix transformMatrix) { + public boolean onTouchEvent(@NonNull MotionEvent event, Matrix transformMatrix) { int pointerCount = event.getPointerCount(); // Prepare a data packet of the appropriate size and order. diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index cb6269756a9d6..39b4ebd23c704 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -791,6 +791,7 @@ navigationBarVisible && guessBottomKeyboardInset(insets) == 0 + viewportMetrics.viewInsetBottom); sendViewportMetricsToFlutter(); + return newInsets; } @@ -866,6 +867,21 @@ public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) { return textInputPlugin.createInputConnection(this, keyboardManager, outAttrs); } + /** + * Allows a {@code View} that is not currently the input connection target to invoke commands on + * the {@link android.view.inputmethod.InputMethodManager}, which is otherwise disallowed. + * + *

Returns true to allow non-input-connection-targets to invoke methods on {@code + * InputMethodManager}, or false to exclusively allow the input connection target to invoke such + * methods. + */ + @Override + public boolean checkInputConnectionProxy(View view) { + return flutterEngine != null + ? flutterEngine.getPlatformViewsController().checkInputConnectionProxy(view) + : super.checkInputConnectionProxy(view); + } + /** * Invoked when a hardware key is pressed or released. * diff --git a/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java b/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java index 9be312cc53baf..d46f5346d7ab3 100644 --- a/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java +++ b/shell/platform/android/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorView.java @@ -9,13 +9,13 @@ import android.graphics.Path; import android.view.MotionEvent; import android.view.View; +import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import io.flutter.embedding.android.AndroidTouchProcessor; -import io.flutter.util.ViewUtils; /** * A view that applies the {@link io.flutter.embedding.engine.mutatorsstack.FlutterMutatorsStack} to @@ -49,6 +49,31 @@ public FlutterMutatorView(@NonNull Context context) { this(context, 1, /* androidTouchProcessor=*/ null); } + /** + * Determines if the current view or any descendant view has focus. + * + * @param root The root view. + * @return True if the current view or any descendant view has focus. + */ + @VisibleForTesting + public static boolean childHasFocus(@Nullable View root) { + if (root == null) { + return false; + } + if (root.hasFocus()) { + return true; + } + if (root instanceof ViewGroup) { + final ViewGroup viewGroup = (ViewGroup) root; + for (int idx = 0; idx < viewGroup.getChildCount(); idx++) { + if (childHasFocus(viewGroup.getChildAt(idx))) { + return true; + } + } + } + return false; + } + @Nullable @VisibleForTesting ViewTreeObserver.OnGlobalFocusChangeListener activeFocusListener; /** @@ -70,7 +95,7 @@ public void setOnDescendantFocusChangeListener(@NonNull OnFocusChangeListener us new ViewTreeObserver.OnGlobalFocusChangeListener() { @Override public void onGlobalFocusChanged(View oldFocus, View newFocus) { - userFocusListener.onFocusChange(mutatorView, ViewUtils.childHasFocus(mutatorView)); + userFocusListener.onFocusChange(mutatorView, childHasFocus(mutatorView)); } }; observer.addOnGlobalFocusChangeListener(activeFocusListener); diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java index 4b50d01e856d9..87891710bd784 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/PlatformViewsChannel.java @@ -14,7 +14,6 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.nio.ByteBuffer; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -65,9 +64,6 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result case "resize": resize(call, result); break; - case "offset": - offset(call, result); - break; case "touch": touch(call, result); break; @@ -86,40 +82,29 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result } private void create(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - final Map createArgs = call.arguments(); - // TODO(egarciad): Remove the "hybrid" case. - final boolean usesPlatformViewLayer = + Map createArgs = call.arguments(); + boolean usesHybridComposition = createArgs.containsKey("hybrid") && (boolean) createArgs.get("hybrid"); - final ByteBuffer additionalParams = - createArgs.containsKey("params") - ? ByteBuffer.wrap((byte[]) createArgs.get("params")) - : null; + // In hybrid mode, the size of the view is determined by the size of the Flow layer. + double width = (usesHybridComposition) ? 0 : (double) createArgs.get("width"); + double height = (usesHybridComposition) ? 0 : (double) createArgs.get("height"); + + PlatformViewCreationRequest request = + new PlatformViewCreationRequest( + (int) createArgs.get("id"), + (String) createArgs.get("viewType"), + width, + height, + (int) createArgs.get("direction"), + createArgs.containsKey("params") + ? ByteBuffer.wrap((byte[]) createArgs.get("params")) + : null); try { - if (usesPlatformViewLayer) { - final PlatformViewCreationRequest request = - new PlatformViewCreationRequest( - (int) createArgs.get("id"), - (String) createArgs.get("viewType"), - 0, - 0, - 0, - 0, - (int) createArgs.get("direction"), - additionalParams); - handler.createForPlatformViewLayer(request); + if (usesHybridComposition) { + handler.createAndroidViewForPlatformView(request); result.success(null); } else { - final PlatformViewCreationRequest request = - new PlatformViewCreationRequest( - (int) createArgs.get("id"), - (String) createArgs.get("viewType"), - createArgs.containsKey("top") ? (double) createArgs.get("top") : 0.0, - createArgs.containsKey("left") ? (double) createArgs.get("left") : 0.0, - (double) createArgs.get("width"), - (double) createArgs.get("height"), - (int) createArgs.get("direction"), - additionalParams); - long textureId = handler.createForTextureLayer(request); + long textureId = handler.createVirtualDisplayForPlatformView(request); result.success(textureId); } } catch (IllegalStateException exception) { @@ -130,9 +115,15 @@ private void create(@NonNull MethodCall call, @NonNull MethodChannel.Result resu private void dispose(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { Map disposeArgs = call.arguments(); int viewId = (int) disposeArgs.get("id"); + boolean usesHybridComposition = + disposeArgs.containsKey("hybrid") && (boolean) disposeArgs.get("hybrid"); try { - handler.dispose(viewId); + if (usesHybridComposition) { + handler.disposeAndroidViewForPlatformView(viewId); + } else { + handler.disposeVirtualDisplayForPlatformView(viewId); + } result.success(null); } catch (IllegalStateException exception) { result.error("error", detailedExceptionString(exception), null); @@ -147,28 +138,14 @@ private void resize(@NonNull MethodCall call, @NonNull MethodChannel.Result resu (double) resizeArgs.get("width"), (double) resizeArgs.get("height")); try { - final PlatformViewBufferSize sz = handler.resize(resizeRequest); - if (sz == null) { - result.error("error", "Failed to resize the platform view", null); - } else { - final Map response = new HashMap<>(); - response.put("width", (double) sz.width); - response.put("height", (double) sz.height); - result.success(response); - } - } catch (IllegalStateException exception) { - result.error("error", detailedExceptionString(exception), null); - } - } - - private void offset(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { - Map offsetArgs = call.arguments(); - try { - handler.offset( - (int) offsetArgs.get("id"), - (double) offsetArgs.get("top"), - (double) offsetArgs.get("left")); - result.success(null); + handler.resizePlatformView( + resizeRequest, + new Runnable() { + @Override + public void run() { + result.success(null); + } + }); } catch (IllegalStateException exception) { result.error("error", detailedExceptionString(exception), null); } @@ -272,40 +249,36 @@ public interface PlatformViewsHandler { * The Flutter application would like to display a new Android {@code View}, i.e., platform * view. * - *

The Android View is added to the view hierarchy. This view is rendered in the Flutter - * framework by a PlatformViewLayer. - * - * @param request The metadata sent from the framework. + *

The Android {@code View} is added to the view hierarchy. */ - void createForPlatformViewLayer(@NonNull PlatformViewCreationRequest request); + void createAndroidViewForPlatformView(@NonNull PlatformViewCreationRequest request); /** - * The Flutter application would like to display a new Android {@code View}, i.e., platform - * view. - * - *

The Android View is added to the view hierarchy. This view is rendered in the Flutter - * framework by a TextureLayer. - * - * @param request The metadata sent from the framework. - * @return The texture ID. + * The Flutter application would like to dispose of an existing Android {@code View} rendered in + * the view hierarchy. */ - long createForTextureLayer(@NonNull PlatformViewCreationRequest request); - - /** The Flutter application would like to dispose of an existing Android {@code View}. */ - void dispose(int viewId); + void disposeAndroidViewForPlatformView(int viewId); /** - * The Flutter application would like to resize an existing Android {@code View}. + * The Flutter application would like to display a new Android {@code View}. * - * @param request The request to resize the platform view. - * @return The buffer size where the platform view pixels are written to. + *

{@code View} is added to a {@code VirtualDisplay}. The framework uses id returned by this + * method to lookup the texture in the engine. + */ + long createVirtualDisplayForPlatformView(@NonNull PlatformViewCreationRequest request); + + /** + * The Flutter application would like to dispose of an existing Android {@code View} rendered in + * a virtual display. */ - PlatformViewBufferSize resize(@NonNull PlatformViewResizeRequest request); + void disposeVirtualDisplayForPlatformView(int viewId); /** - * The Flutter application would like to change the offset of an existing Android {@code View}. + * The Flutter application would like to resize an existing Android {@code View}, i.e., platform + * view. */ - void offset(int viewId, double top, double left); + void resizePlatformView( + @NonNull PlatformViewResizeRequest request, @NonNull Runnable onComplete); /** * The user touched a platform view within Flutter. @@ -348,12 +321,6 @@ public static class PlatformViewCreationRequest { /** The density independent height to display the platform view. */ public final double logicalHeight; - /** The density independent top position to display the platform view. */ - public final double logicalTop; - - /** The density independent left position to display the platform view. */ - public final double logicalLeft; - /** * The layout direction of the new platform view. * @@ -365,20 +332,16 @@ public static class PlatformViewCreationRequest { /** Custom parameters that are unique to the desired platform view. */ @Nullable public final ByteBuffer params; - /** Creates a request to construct a platform view. */ + /** Creates a request to construct a platform view that uses a virtual display. */ public PlatformViewCreationRequest( int viewId, @NonNull String viewType, - double logicalTop, - double logicalLeft, double logicalWidth, double logicalHeight, int direction, @Nullable ByteBuffer params) { this.viewId = viewId; this.viewType = viewType; - this.logicalTop = logicalTop; - this.logicalLeft = logicalLeft; this.logicalWidth = logicalWidth; this.logicalHeight = logicalHeight; this.direction = direction; @@ -386,7 +349,11 @@ public PlatformViewCreationRequest( } } - /** Request sent from Flutter to resize a platform view. */ + /** + * Request sent from Flutter to resize a platform view. + * + *

This only applies to platform views that use virtual displays. + */ public static class PlatformViewResizeRequest { /** The ID of the platform view as seen by the Flutter side. */ public final int viewId; @@ -404,20 +371,6 @@ public PlatformViewResizeRequest(int viewId, double newLogicalWidth, double newL } } - /** The platform view buffer size. */ - public static class PlatformViewBufferSize { - /** The width of the screen buffer. */ - public final int width; - - /** The height of the screen buffer. */ - public final int height; - - public PlatformViewBufferSize(int width, int height) { - this.width = width; - this.height = height; - } - } - /** The state of a touch event in Flutter within a platform view. */ public static class PlatformViewTouch { /** The ID of the platform view as seen by the Flutter side. */ diff --git a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java index 4c497e05d092b..ecdf86c71820e 100644 --- a/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java +++ b/shell/platform/android/io/flutter/embedding/engine/systemchannels/TextInputChannel.java @@ -89,7 +89,9 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result try { final JSONObject arguments = (JSONObject) args; final int platformViewId = arguments.getInt("platformViewId"); - textInputMethodHandler.setPlatformViewClient(platformViewId); + final boolean usesVirtualDisplay = + arguments.optBoolean("usesVirtualDisplay", false); + textInputMethodHandler.setPlatformViewClient(platformViewId, usesVirtualDisplay); result.success(null); } catch (JSONException exception) { result.error("error", exception.getMessage(), null); @@ -400,8 +402,10 @@ public interface TextInputMethodHandler { * different client is set. * * @param id the ID of the platform view to be set as a text input client. + * @param usesVirtualDisplay True if the platform view uses a virtual display, false if it uses + * hybrid composition. */ - void setPlatformViewClient(int id); + void setPlatformViewClient(int id, boolean usesVirtualDisplay); /** * Sets the size and the transform matrix of the current text input client. diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index cfd57ef7a26ea..7ca0febb19c39 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -54,6 +54,12 @@ public class TextInputPlugin implements ListenableEditingState.EditingStateWatch // Initialize the "last seen" text editing values to a non-null value. private TextEditState mLastKnownFrameworkTextEditingState; + // When true following calls to createInputConnection will return the cached lastInputConnection + // if the input + // target is a platform view. See the comments on lockPlatformViewInputConnection for more + // details. + private boolean isInputConnectionLocked; + @SuppressLint("NewApi") public TextInputPlugin( View view, @@ -99,7 +105,7 @@ public void show() { @Override public void hide() { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { + if (inputTarget.type == InputTarget.Type.HC_PLATFORM_VIEW) { notifyViewExited(); } else { hideTextInput(mView); @@ -130,8 +136,8 @@ public void setClient( } @Override - public void setPlatformViewClient(int platformViewId) { - setPlatformViewTextInputClient(platformViewId); + public void setPlatformViewClient(int platformViewId, boolean usesVirtualDisplay) { + setPlatformViewTextInputClient(platformViewId, usesVirtualDisplay); } @Override @@ -176,6 +182,34 @@ ImeSyncDeferringInsetsCallback getImeSyncCallback() { return imeSyncCallback; } + /** + * Use the current platform view input connection until unlockPlatformViewInputConnection is + * called. + * + *

The current input connection instance is cached and any following call to @{link + * createInputConnection} returns the cached connection until unlockPlatformViewInputConnection is + * called. + * + *

This is a no-op if the current input target isn't a platform view. + * + *

This is used to preserve an input connection when moving a platform view from one virtual + * display to another. + */ + public void lockPlatformViewInputConnection() { + if (inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW) { + isInputConnectionLocked = true; + } + } + + /** + * Unlocks the input connection. + * + *

See also: @{link lockPlatformViewInputConnection}. + */ + public void unlockPlatformViewInputConnection() { + isInputConnectionLocked = false; + } + /** * Detaches the text input plugin from the platform views controller. * @@ -258,10 +292,21 @@ public InputConnection createInputConnection( return null; } - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW) { + if (inputTarget.type == InputTarget.Type.HC_PLATFORM_VIEW) { return null; } + if (inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW) { + if (isInputConnectionLocked) { + return lastInputConnection; + } + lastInputConnection = + platformViewsController + .getPlatformViewById(inputTarget.id) + .onCreateInputConnection(outAttrs); + return lastInputConnection; + } + outAttrs.inputType = inputTypeFromTextInputType( configuration.inputType, @@ -316,7 +361,9 @@ public InputConnection getLastInputConnection() { * input connection. */ public void clearPlatformViewClient(int platformViewId) { - if (inputTarget.type == InputTarget.Type.PLATFORM_VIEW && inputTarget.id == platformViewId) { + if ((inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW + || inputTarget.type == InputTarget.Type.HC_PLATFORM_VIEW) + && inputTarget.id == platformViewId) { inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); notifyViewExited(); mImm.hideSoftInputFromWindow(mView.getApplicationWindowToken(), 0); @@ -377,13 +424,25 @@ void setTextInputClient(int client, TextInputChannel.Configuration configuration // setTextInputClient will be followed by a call to setTextInputEditingState. // Do a restartInput at that time. mRestartInputPending = true; + unlockPlatformViewInputConnection(); lastClientRect = null; mEditable.addEditingStateListener(this); } - private void setPlatformViewTextInputClient(int platformViewId) { - inputTarget = new InputTarget(InputTarget.Type.PLATFORM_VIEW, platformViewId); - lastInputConnection = null; + private void setPlatformViewTextInputClient(int platformViewId, boolean usesVirtualDisplay) { + if (usesVirtualDisplay) { + // We need to make sure that the Flutter view is focused so that no imm operations get short + // circuited. + // Not asking for focus here specifically manifested in a but on API 28 devices where the + // platform view's request to show a keyboard was ignored. + mView.requestFocus(); + inputTarget = new InputTarget(InputTarget.Type.VD_PLATFORM_VIEW, platformViewId); + mImm.restartInput(mView); + mRestartInputPending = false; + } else { + inputTarget = new InputTarget(InputTarget.Type.HC_PLATFORM_VIEW, platformViewId); + lastInputConnection = null; + } } private static boolean composingChanged( @@ -474,10 +533,35 @@ public void inspect(double x, double y) { @VisibleForTesting void clearTextInputClient() { + if (inputTarget.type == InputTarget.Type.VD_PLATFORM_VIEW) { + // This only applies to platform views that use a virtual display. + // Focus changes in the framework tree have no guarantees on the order focus nodes are + // notified. A node + // that lost focus may be notified before or after a node that gained focus. + // When moving the focus from a Flutter text field to an AndroidView, it is possible that the + // Flutter text + // field's focus node will be notified that it lost focus after the AndroidView was notified + // that it gained + // focus. When this happens the text field will send a clearTextInput command which we ignore. + // By doing this we prevent the framework from clearing a platform view input client (the only + // way to do so + // is to set a new framework text client). I don't see an obvious use case for "clearing" a + // platform view's + // text input client, and it may be error prone as we don't know how the platform view manages + // the input + // connection and we probably shouldn't interfere. + // If we ever want to allow the framework to clear a platform view text client we should + // probably consider + // changing the focus manager such that focus nodes that lost focus are notified before focus + // nodes that + // gained focus as part of the same focus event. + return; + } mEditable.removeEditingStateListener(this); notifyViewExited(); updateAutofillConfigurationIfNeeded(null); inputTarget = new InputTarget(InputTarget.Type.NO_TARGET, 0); + unlockPlatformViewInputConnection(); lastClientRect = null; } @@ -487,9 +571,12 @@ enum Type { // InputConnection is managed by the TextInputPlugin, and events are forwarded to the Flutter // framework. FRAMEWORK_CLIENT, - // InputConnection is managed by a platform view that is embeded in the Android view - // hierarchy. - PLATFORM_VIEW, + // InputConnection is managed by an embedded platform view that is backed by a virtual + // display (VD). + VD_PLATFORM_VIEW, + // InputConnection is managed by an embedded platform view that is embeded in the Android view + // hierarchy, and uses hybrid composition (HC). + HC_PLATFORM_VIEW, } public InputTarget(@NonNull Type type, int id) { diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformView.java b/shell/platform/android/io/flutter/plugin/platform/PlatformView.java index 85ed3c40e8c2e..92f034d840d11 100644 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformView.java +++ b/shell/platform/android/io/flutter/plugin/platform/PlatformView.java @@ -60,26 +60,24 @@ default void onFlutterViewDetached() {} void dispose(); /** - * Callback fired when the platform's input connection is locked, or should be used. + * Callback fired when the platform's input connection is locked, or should be used. See also + * {@link io.flutter.plugin.editing.TextInputPlugin#lockPlatformViewInputConnection}. * *

This hook only exists for rare cases where the plugin relies on the state of the input * connection. This probably doesn't need to be implemented. - * - *

This method is deprecated, and will be removed in a future release. */ + // Default interface methods are supported on all min SDK versions of Android. @SuppressLint("NewApi") - @Deprecated default void onInputConnectionLocked() {} /** - * Callback fired when the platform input connection has been unlocked. + * Callback fired when the platform input connection has been unlocked. See also {@link + * io.flutter.plugin.editing.TextInputPlugin#lockPlatformViewInputConnection}. * *

This hook only exists for rare cases where the plugin relies on the state of the input * connection. This probably doesn't need to be implemented. - * - *

This method is deprecated, and will be removed in a future release. */ + // Default interface methods are supported on all min SDK versions of Android. @SuppressLint("NewApi") - @Deprecated default void onInputConnectionUnlocked() {} } diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java b/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java deleted file mode 100644 index d7ebe184a656b..0000000000000 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformViewWrapper.java +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package io.flutter.plugin.platform; - -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.content.Context; -import android.graphics.BlendMode; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Matrix; -import android.graphics.SurfaceTexture; -import android.os.Build; -import android.view.MotionEvent; -import android.view.Surface; -import android.view.View; -import android.view.ViewTreeObserver; -import android.widget.FrameLayout; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import io.flutter.Log; -import io.flutter.embedding.android.AndroidTouchProcessor; -import io.flutter.util.ViewUtils; - -/** - * Wraps a platform view to intercept gestures and project this view onto a {@link SurfaceTexture}. - * - *

An Android platform view is composed by the engine using a {@code TextureLayer}. The view is - * embeded to the Android view hierarchy like a normal view, but it's projected onto a {@link - * SurfaceTexture}, so it can be efficiently composed by the engine. - * - *

Since the view is in the Android view hierarchy, keyboard and accessibility interactions - * behave normally. - */ -@TargetApi(23) -class PlatformViewWrapper extends FrameLayout { - private static final String TAG = "PlatformViewWrapper"; - - private int prevLeft; - private int prevTop; - private int left; - private int top; - private int bufferWidth; - private int bufferHeight; - private SurfaceTexture tx; - private Surface surface; - private AndroidTouchProcessor touchProcessor; - - @Nullable @VisibleForTesting ViewTreeObserver.OnGlobalFocusChangeListener activeFocusListener; - - public PlatformViewWrapper(@NonNull Context context) { - super(context); - setWillNotDraw(false); - } - - /** - * Sets the touch processor that allows to intercept gestures. - * - * @param newTouchProcessor The touch processor. - */ - public void setTouchProcessor(@Nullable AndroidTouchProcessor newTouchProcessor) { - touchProcessor = newTouchProcessor; - } - - /** - * Sets the texture where the view is projected onto. - * - *

{@link PlatformViewWrapper} doesn't take ownership of the {@link SurfaceTexture}. As a - * result, the caller is responsible for releasing the texture. - * - *

{@link io.flutter.view.TextureRegistry} is responsible for creating and registering textures - * in the engine. Therefore, the engine is responsible for also releasing the texture. - * - * @param newTx The texture where the view is projected onto. - */ - @SuppressLint("NewApi") - public void setTexture(@Nullable SurfaceTexture newTx) { - if (Build.VERSION.SDK_INT < 23) { - Log.e( - TAG, - "Platform views cannot be displayed below API level 23. " - + "You can prevent this issue by setting `minSdkVersion: 23` in build.gradle."); - return; - } - - tx = newTx; - - if (bufferWidth > 0 && bufferHeight > 0) { - tx.setDefaultBufferSize(bufferWidth, bufferHeight); - } - - if (surface != null) { - surface.release(); - } - surface = createSurface(newTx); - - // Fill the entire canvas with a transparent color. - // As a result, the background color of the platform view container is displayed - // to the user until the platform view draws its first frame. - final Canvas canvas = surface.lockHardwareCanvas(); - try { - if (Build.VERSION.SDK_INT >= 29) { - canvas.drawColor(Color.TRANSPARENT, BlendMode.CLEAR); - } else { - canvas.drawColor(Color.TRANSPARENT); - } - } finally { - surface.unlockCanvasAndPost(canvas); - } - } - - @NonNull - @VisibleForTesting - protected Surface createSurface(@NonNull SurfaceTexture tx) { - return new Surface(tx); - } - - /** Returns the texture where the view is projected. */ - @Nullable - public SurfaceTexture getTexture() { - return tx; - } - - /** - * Sets the layout parameters for this view. - * - * @param params The new parameters. - */ - public void setLayoutParams(@NonNull FrameLayout.LayoutParams params) { - super.setLayoutParams(params); - - left = params.leftMargin; - top = params.topMargin; - } - - /** - * Sets the size of the image buffer. - * - * @param width The width of the screen buffer. - * @param height The height of the screen buffer. - */ - public void setBufferSize(int width, int height) { - bufferWidth = width; - bufferHeight = height; - if (tx != null) { - tx.setDefaultBufferSize(width, height); - } - } - - /** Returns the image buffer width. */ - public int getBufferWidth() { - return bufferWidth; - } - - /** Returns the image buffer height. */ - public int getBufferHeight() { - return bufferHeight; - } - - /** Releases the surface. */ - public void release() { - // Don't release the texture. - tx = null; - if (surface != null) { - surface.release(); - surface = null; - } - } - - @Override - public boolean onInterceptTouchEvent(@NonNull MotionEvent event) { - return true; - } - - @Override - public void onDescendantInvalidated(@NonNull View child, @NonNull View target) { - super.onDescendantInvalidated(child, target); - invalidate(); - } - - @Override - @SuppressLint("NewApi") - public void draw(Canvas canvas) { - if (surface == null || !surface.isValid()) { - Log.e(TAG, "Invalid surface. The platform view cannot be displayed."); - return; - } - if (tx == null || tx.isReleased()) { - Log.e(TAG, "Invalid texture. The platform view cannot be displayed."); - return; - } - // Override the canvas that this subtree of views will use to draw. - final Canvas surfaceCanvas = surface.lockHardwareCanvas(); - try { - // Clear the current pixels in the canvas. - // This helps when a WebView renders an HTML document with transparent background. - if (Build.VERSION.SDK_INT >= 29) { - surfaceCanvas.drawColor(Color.TRANSPARENT, BlendMode.CLEAR); - } else { - surfaceCanvas.drawColor(Color.TRANSPARENT); - } - super.draw(surfaceCanvas); - } finally { - surface.unlockCanvasAndPost(surfaceCanvas); - } - } - - @Override - @SuppressLint("ClickableViewAccessibility") - public boolean onTouchEvent(@NonNull MotionEvent event) { - if (touchProcessor == null) { - return super.onTouchEvent(event); - } - final Matrix screenMatrix = new Matrix(); - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - prevLeft = left; - prevTop = top; - screenMatrix.postTranslate(left, top); - break; - case MotionEvent.ACTION_MOVE: - // While the view is dragged, use the left and top positions as - // they were at the moment the touch event fired. - screenMatrix.postTranslate(prevLeft, prevTop); - prevLeft = left; - prevTop = top; - break; - case MotionEvent.ACTION_UP: - default: - screenMatrix.postTranslate(left, top); - break; - } - return touchProcessor.onTouchEvent(event, screenMatrix); - } - - public void setOnDescendantFocusChangeListener(@NonNull OnFocusChangeListener userFocusListener) { - unsetOnDescendantFocusChangeListener(); - final ViewTreeObserver observer = getViewTreeObserver(); - if (observer.isAlive() && activeFocusListener == null) { - activeFocusListener = - new ViewTreeObserver.OnGlobalFocusChangeListener() { - @Override - public void onGlobalFocusChanged(View oldFocus, View newFocus) { - userFocusListener.onFocusChange( - PlatformViewWrapper.this, ViewUtils.childHasFocus(PlatformViewWrapper.this)); - } - }; - observer.addOnGlobalFocusChangeListener(activeFocusListener); - } - } - - public void unsetOnDescendantFocusChangeListener() { - final ViewTreeObserver observer = getViewTreeObserver(); - if (observer.isAlive() && activeFocusListener != null) { - final ViewTreeObserver.OnGlobalFocusChangeListener currFocusListener = activeFocusListener; - activeFocusListener = null; - observer.removeOnGlobalFocusChangeListener(currFocusListener); - } - } -} diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsAccessibilityDelegate.java b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsAccessibilityDelegate.java index 0ab99bfe1fc66..ad693a8724880 100644 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsAccessibilityDelegate.java +++ b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsAccessibilityDelegate.java @@ -5,7 +5,6 @@ package io.flutter.plugin.platform; import android.view.View; -import androidx.annotation.Nullable; import io.flutter.view.AccessibilityBridge; /** Facilitates interaction between the accessibility bridge and embedded platform views. */ @@ -14,8 +13,10 @@ public interface PlatformViewsAccessibilityDelegate { * Returns the root of the view hierarchy for the platform view with the requested id, or null if * there is no corresponding view. */ - @Nullable - View getPlatformViewById(int viewId); + View getPlatformViewById(Integer id); + + /** Returns true if the platform view uses virtual displays. */ + boolean usesVirtualDisplay(Integer id); /** * Attaches an accessibility bridge for this platform views accessibility delegate. diff --git a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java index 063882fced75f..29ace9e502c2d 100644 --- a/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java +++ b/shell/platform/android/io/flutter/plugin/platform/PlatformViewsController.java @@ -10,6 +10,7 @@ import android.annotation.TargetApi; import android.content.Context; import android.os.Build; +import android.util.DisplayMetrics; import android.util.SparseArray; import android.view.MotionEvent; import android.view.View; @@ -33,6 +34,7 @@ import io.flutter.view.AccessibilityBridge; import io.flutter.view.TextureRegistry; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -68,7 +70,20 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega // dispatched. private final AccessibilityEventsDelegate accessibilityEventsDelegate; - // The platform views. + // TODO(mattcarroll): Refactor overall platform views to facilitate testing and then make + // this private. This is visible as a hack to facilitate testing. This was deemed the least + // bad option at the time of writing. + @VisibleForTesting /* package */ final HashMap vdControllers; + + // Maps a virtual display's context to the platform view hosted in this virtual display. + // Since each virtual display has it's unique context this allows associating any view with the + // platform view that + // it is associated with(e.g if a platform view creates other views in the same virtual display. + @VisibleForTesting /* package */ final HashMap contextToPlatformView; + + // The views returned by `PlatformView#getView()`. + // + // This only applies to hybrid composition. private final SparseArray platformViews; // The platform view parents that are appended to `FlutterView`. @@ -78,19 +93,12 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega // This view provides a wrapper that applies scene builder operations to the platform view. // For example, a transform matrix, or setting opacity on the platform view layer. // - // This is only applies to hybrid composition (PlatformViewLayer render). - // TODO(egarciad): Eliminate this. - // https://github.com/flutter/flutter/issues/96679 + // This is only applies to hybrid composition. private final SparseArray platformViewParent; // Map of unique IDs to views that render overlay layers. private final SparseArray overlayLayerViews; - // View wrappers are FrameLayouts that contain a single child view. - // This child view is the platform view. - // This only applies to hybrid composition (TextureLayer render). - private final SparseArray viewWrappers; - // Next available unique ID for use in overlayLayerViews. private int nextOverlayLayerId = 0; @@ -116,9 +124,7 @@ public class PlatformViewsController implements PlatformViewsAccessibilityDelega @TargetApi(Build.VERSION_CODES.KITKAT) @Override - // TODO(egarciad): Remove the need for this. - // https://github.com/flutter/flutter/issues/96679 - public void createForPlatformViewLayer( + public void createAndroidViewForPlatformView( @NonNull PlatformViewsChannel.PlatformViewCreationRequest request) { // API level 19 is required for `android.graphics.ImageReader`. ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT); @@ -148,182 +154,167 @@ public void createForPlatformViewLayer( platformViews.put(request.viewId, platformView); } - @TargetApi(Build.VERSION_CODES.M) @Override - public long createForTextureLayer( - @NonNull PlatformViewsChannel.PlatformViewCreationRequest request) { - final int viewId = request.viewId; - if (viewWrappers.get(viewId) != null) { - throw new IllegalStateException( - "Trying to create an already created platform view, view id: " + viewId); + public void disposeAndroidViewForPlatformView(int viewId) { + // Hybrid view. + final PlatformView platformView = platformViews.get(viewId); + final FlutterMutatorView parentView = platformViewParent.get(viewId); + if (platformView != null) { + if (parentView != null) { + parentView.removeView(platformView.getView()); + } + platformViews.remove(viewId); + platformView.dispose(); } + if (parentView != null) { + parentView.unsetOnDescendantFocusChangeListener(); + ((ViewGroup) parentView.getParent()).removeView(parentView); + platformViewParent.remove(viewId); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + @Override + public long createVirtualDisplayForPlatformView( + @NonNull PlatformViewsChannel.PlatformViewCreationRequest request) { + // API level 20 is required for VirtualDisplay#setSurface which we use when resizing a + // platform view. + ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); if (!validateDirection(request.direction)) { throw new IllegalStateException( "Trying to create a view with unknown direction value: " + request.direction + "(view id: " - + viewId + + request.viewId + ")"); } - if (textureRegistry == null) { - throw new IllegalStateException( - "Texture registry is null. This means that platform views controller was detached, view id: " - + viewId); - } - if (flutterView == null) { + + if (vdControllers.containsKey(request.viewId)) { throw new IllegalStateException( - "Flutter view is null. This means the platform views controller doesn't have an attached view, view id: " - + viewId); + "Trying to create an already created platform view, view id: " + request.viewId); } - final PlatformViewFactory viewFactory = registry.getFactory(request.viewType); + + PlatformViewFactory viewFactory = registry.getFactory(request.viewType); if (viewFactory == null) { throw new IllegalStateException( "Trying to create a platform view of unregistered type: " + request.viewType); } + Object createParams = null; if (request.params != null) { createParams = viewFactory.getCreateArgsCodec().decodeMessage(request.params); } - final PlatformView platformView = viewFactory.create(context, viewId, createParams); - platformViews.put(viewId, platformView); - - final PlatformViewWrapper wrapperView = new PlatformViewWrapper(context); - final TextureRegistry.SurfaceTextureEntry textureEntry = - textureRegistry.createSurfaceTexture(); - wrapperView.setTexture(textureEntry.surfaceTexture()); - wrapperView.setTouchProcessor(androidTouchProcessor); - - final int physicalWidth = toPhysicalPixels(request.logicalWidth); - final int physicalHeight = toPhysicalPixels(request.logicalHeight); - wrapperView.setBufferSize(physicalWidth, physicalHeight); - - final FrameLayout.LayoutParams layoutParams = - new FrameLayout.LayoutParams(physicalWidth, physicalHeight); - - final int physicalTop = toPhysicalPixels(request.logicalTop); - final int physicalLeft = toPhysicalPixels(request.logicalLeft); - layoutParams.topMargin = physicalTop; - layoutParams.leftMargin = physicalLeft; - wrapperView.setLayoutParams(layoutParams); - - wrapperView.setLayoutDirection(request.direction); - wrapperView.addView(platformView.getView()); - wrapperView.setOnDescendantFocusChangeListener( - (view, hasFocus) -> { - if (hasFocus) { - platformViewsChannel.invokeViewFocused(viewId); - } else if (textInputPlugin != null) { - textInputPlugin.clearPlatformViewClient(viewId); - } - }); + int physicalWidth = toPhysicalPixels(request.logicalWidth); + int physicalHeight = toPhysicalPixels(request.logicalHeight); + validateVirtualDisplayDimensions(physicalWidth, physicalHeight); + + TextureRegistry.SurfaceTextureEntry textureEntry = textureRegistry.createSurfaceTexture(); + VirtualDisplayController vdController = + VirtualDisplayController.create( + context, + accessibilityEventsDelegate, + viewFactory, + textureEntry, + physicalWidth, + physicalHeight, + request.viewId, + createParams, + (view, hasFocus) -> { + if (hasFocus) { + platformViewsChannel.invokeViewFocused(request.viewId); + } + }); + + if (vdController == null) { + throw new IllegalStateException( + "Failed creating virtual display for a " + + request.viewType + + " with id: " + + request.viewId); + } + + // If our FlutterEngine is already attached to a Flutter UI, provide that Android + // View to this new platform view. + if (flutterView != null) { + vdController.onFlutterViewAttached(flutterView); + } + + vdControllers.put(request.viewId, vdController); + View platformView = vdController.getView(); + platformView.setLayoutDirection(request.direction); + contextToPlatformView.put(platformView.getContext(), platformView); + + // TODO(amirh): copy accessibility nodes to the FlutterView's accessibility tree. - flutterView.addView(wrapperView); - viewWrappers.append(viewId, wrapperView); return textureEntry.id(); } @Override - public void dispose(int viewId) { - final PlatformView platformView = platformViews.get(viewId); - if (platformView != null) { - final ViewGroup pvParent = (ViewGroup) platformView.getView().getParent(); - if (pvParent != null) { - pvParent.removeView(platformView.getView()); - } - platformViews.remove(viewId); - platformView.dispose(); - } - // The platform view is displayed using a TextureLayer. - final PlatformViewWrapper viewWrapper = viewWrappers.get(viewId); - if (viewWrapper != null) { - viewWrapper.release(); - viewWrapper.unsetOnDescendantFocusChangeListener(); - - final ViewGroup wrapperParent = (ViewGroup) viewWrapper.getParent(); - if (wrapperParent != null) { - wrapperParent.removeView(viewWrapper); - } - viewWrappers.remove(viewId); - return; + public void disposeVirtualDisplayForPlatformView(int viewId) { + ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); + VirtualDisplayController vdController = vdControllers.get(viewId); + if (vdController == null) { + throw new IllegalStateException( + "Trying to dispose a platform view with unknown id: " + viewId); } - // The platform view is displayed using a PlatformViewLayer. - // TODO(egarciad): Eliminate this case. - // https://github.com/flutter/flutter/issues/96679 - final FlutterMutatorView parentView = platformViewParent.get(viewId); - if (parentView != null) { - parentView.unsetOnDescendantFocusChangeListener(); - final ViewGroup mutatorViewParent = (ViewGroup) parentView.getParent(); - if (mutatorViewParent != null) { - mutatorViewParent.removeView(parentView); - } - platformViewParent.remove(viewId); + if (textInputPlugin != null) { + textInputPlugin.clearPlatformViewClient(viewId); } - } - @Override - public void offset(int viewId, double top, double left) { - final PlatformViewWrapper wrapper = viewWrappers.get(viewId); - if (wrapper == null) { - Log.e(TAG, "Setting offset for unknown platform view with id: " + viewId); - return; - } - final int physicalTop = toPhysicalPixels(top); - final int physicalLeft = toPhysicalPixels(left); - final FrameLayout.LayoutParams layoutParams = - (FrameLayout.LayoutParams) wrapper.getLayoutParams(); - layoutParams.topMargin = physicalTop; - layoutParams.leftMargin = physicalLeft; - wrapper.setLayoutParams(layoutParams); + contextToPlatformView.remove(vdController.getView().getContext()); + vdController.dispose(); + vdControllers.remove(viewId); } @Override - public PlatformViewsChannel.PlatformViewBufferSize resize( - @NonNull PlatformViewsChannel.PlatformViewResizeRequest request) { - final int viewId = request.viewId; - final PlatformViewWrapper view = viewWrappers.get(viewId); - if (view == null) { - Log.e(TAG, "Resizing unknown platform view with id: " + viewId); - return null; - } - final int newWidth = toPhysicalPixels(request.newLogicalWidth); - final int newHeight = toPhysicalPixels(request.newLogicalHeight); - - // Resize the buffer only when the current buffer size is smaller than the new size. - // This is required to prevent a situation when smooth keyboard animation - // resizes the texture too often, such that the GPU and the platform thread don't agree on - // the - // timing of the new size. - // Resizing the texture causes pixel stretching since the size of the GL texture used in - // the engine - // is set by the framework, but the texture buffer size is set by the platform down below. - if (newWidth > view.getBufferWidth() || newHeight > view.getBufferHeight()) { - view.setBufferSize(newWidth, newHeight); - } + public void resizePlatformView( + @NonNull PlatformViewsChannel.PlatformViewResizeRequest request, + @NonNull Runnable onComplete) { + ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); - final FrameLayout.LayoutParams layoutParams = - (FrameLayout.LayoutParams) view.getLayoutParams(); - layoutParams.width = newWidth; - layoutParams.height = newHeight; - view.setLayoutParams(layoutParams); + final VirtualDisplayController vdController = vdControllers.get(request.viewId); + if (vdController == null) { + throw new IllegalStateException( + "Trying to resize a platform view with unknown id: " + request.viewId); + } - return new PlatformViewsChannel.PlatformViewBufferSize( - toLogicalPixels(view.getBufferWidth()), toLogicalPixels(view.getBufferHeight())); + int physicalWidth = toPhysicalPixels(request.newLogicalWidth); + int physicalHeight = toPhysicalPixels(request.newLogicalHeight); + validateVirtualDisplayDimensions(physicalWidth, physicalHeight); + + // Resizing involved moving the platform view to a new virtual display. Doing so + // potentially results in losing an active input connection. To make sure we preserve + // the input connection when resizing we lock it here and unlock after the resize is + // complete. + lockInputConnection(vdController); + vdController.resize( + physicalWidth, + physicalHeight, + () -> { + unlockInputConnection(vdController); + onComplete.run(); + }); } @Override public void onTouch(@NonNull PlatformViewsChannel.PlatformViewTouch touch) { final int viewId = touch.viewId; - final PlatformView platformView = platformViews.get(viewId); - if (platformView == null) { - Log.e(TAG, "Sending touch to an unknown view with id: " + viewId); - return; - } + float density = context.getResources().getDisplayMetrics().density; ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); - final float density = context.getResources().getDisplayMetrics().density; - final MotionEvent event = toMotionEvent(density, touch); - platformView.getView().dispatchTouchEvent(event); + if (vdControllers.containsKey(viewId)) { + final MotionEvent event = toMotionEvent(density, touch, /*usingVirtualDiplays=*/ true); + vdControllers.get(touch.viewId).dispatchTouchEvent(event); + } else if (platformViews.get(viewId) != null) { + final MotionEvent event = toMotionEvent(density, touch, /*usingVirtualDiplays=*/ false); + View view = platformViews.get(touch.viewId).getView(); + if (view != null) { + view.dispatchTouchEvent(event); + } + } else { + throw new IllegalStateException("Sending touch to an unknown view with id: " + viewId); + } } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) @@ -337,23 +328,34 @@ public void setDirection(int viewId, int direction) { + viewId + ")"); } + + ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); final PlatformView platformView = platformViews.get(viewId); - if (platformView == null) { - Log.e(TAG, "Setting direction to an unknown view with id: " + viewId); + if (platformView != null) { + platformView.getView().setLayoutDirection(direction); return; } - ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); - platformViews.get(viewId).getView().setLayoutDirection(direction); + VirtualDisplayController controller = vdControllers.get(viewId); + if (controller == null) { + throw new IllegalStateException( + "Trying to set direction: " + + direction + + " to an unknown platform view with id: " + + viewId); + } + controller.getView().setLayoutDirection(direction); } @Override public void clearFocus(int viewId) { final PlatformView platformView = platformViews.get(viewId); - if (platformView == null) { - Log.e(TAG, "Clearing focus on an unknown view with id: " + viewId); + if (platformView != null) { + platformView.getView().clearFocus(); return; } - platformView.getView().clearFocus(); + ensureValidAndroidVersion(Build.VERSION_CODES.KITKAT_WATCH); + View view = vdControllers.get(viewId).getView(); + view.clearFocus(); } private void ensureValidAndroidVersion(int minSdkVersion) { @@ -373,7 +375,8 @@ public void synchronizeToNativeViewHierarchy(boolean yes) { }; @VisibleForTesting - public MotionEvent toMotionEvent(float density, PlatformViewsChannel.PlatformViewTouch touch) { + public MotionEvent toMotionEvent( + float density, PlatformViewsChannel.PlatformViewTouch touch, boolean usingVirtualDiplays) { MotionEventTracker.MotionEventId motionEventId = MotionEventTracker.MotionEventId.from(touch.motionEventId); MotionEvent trackedEvent = motionEventTracker.pop(motionEventId); @@ -389,7 +392,7 @@ public MotionEvent toMotionEvent(float density, PlatformViewsChannel.PlatformVie parsePointerCoordsList(touch.rawPointerCoords, density) .toArray(new PointerCoords[touch.pointerCount]); - if (trackedEvent != null) { + if (!usingVirtualDiplays && trackedEvent != null) { return MotionEvent.obtain( trackedEvent.getDownTime(), trackedEvent.getEventTime(), @@ -428,11 +431,13 @@ public MotionEvent toMotionEvent(float density, PlatformViewsChannel.PlatformVie public PlatformViewsController() { registry = new PlatformViewRegistryImpl(); + vdControllers = new HashMap<>(); accessibilityEventsDelegate = new AccessibilityEventsDelegate(); + contextToPlatformView = new HashMap<>(); overlayLayerViews = new SparseArray<>(); currentFrameUsedOverlayLayerIds = new HashSet<>(); currentFrameUsedPlatformViewIds = new HashSet<>(); - viewWrappers = new SparseArray<>(); + platformViews = new SparseArray<>(); platformViewParent = new SparseArray<>(); @@ -484,14 +489,13 @@ public void detach() { * This {@code PlatformViewsController} and its {@code FlutterEngine} is now attached to an * Android {@code View} that renders a Flutter UI. */ - public void attachToView(@NonNull FlutterView newFlutterView) { - flutterView = newFlutterView; + public void attachToView(@NonNull FlutterView flutterView) { + this.flutterView = flutterView; // Inform all existing platform views that they are now associated with // a Flutter View. - for (int i = 0; i < platformViews.size(); i++) { - final PlatformView view = platformViews.valueAt(i); - view.onFlutterViewAttached(flutterView); + for (VirtualDisplayController controller : vdControllers.values()) { + controller.onFlutterViewAttached(flutterView); } } @@ -503,16 +507,16 @@ public void attachToView(@NonNull FlutterView newFlutterView) { * the previously attached {@code View}. */ public void detachFromView() { - for (int i = 0; i < platformViews.size(); i++) { - final PlatformView view = platformViews.valueAt(i); - view.onFlutterViewDetached(); - } - // TODO(egarciad): Remove this. - // https://github.com/flutter/flutter/issues/96679 destroyOverlaySurfaces(); removeOverlaySurfaces(); - flutterView = null; + this.flutterView = null; flutterViewConvertedToImageView = false; + + // Inform all existing platform views that they are no longer associated with + // a Flutter View. + for (VirtualDisplayController controller : vdControllers.values()) { + controller.onFlutterViewDetached(); + } } @Override @@ -543,6 +547,29 @@ public void detachTextInputPlugin() { textInputPlugin = null; } + /** + * Returns true if Flutter should perform input connection proxying for the view. + * + *

If the view is a platform view managed by this platform views controller returns true. Else + * if the view was created in a platform view's VD, delegates the decision to the platform view's + * {@link View#checkInputConnectionProxy(View)} method. Else returns false. + */ + public boolean checkInputConnectionProxy(@Nullable View view) { + // View can be null on some devices + // See: https://github.com/flutter/flutter/issues/36517 + if (view == null) { + return false; + } + if (!contextToPlatformView.containsKey(view.getContext())) { + return false; + } + View platformView = contextToPlatformView.get(view.getContext()); + if (platformView == view) { + return true; + } + return platformView.checkInputConnectionProxy(view); + } + public PlatformViewRegistry getRegistry() { return registry; } @@ -560,6 +587,8 @@ public void onAttachedToJNI() { * PlatformViewsController} detaches from JNI. */ public void onDetachedFromJNI() { + // Dispose all virtual displays so that any future updates to textures will not be + // propagated to the native peer. flushAllViews(); } @@ -568,12 +597,37 @@ public void onPreEngineRestart() { } @Override - public View getPlatformViewById(int viewId) { - final PlatformView platformView = platformViews.get(viewId); - if (platformView == null) { + public View getPlatformViewById(Integer id) { + // Hybrid composition. + if (platformViews.get(id) != null) { + return platformViews.get(id).getView(); + } + VirtualDisplayController controller = vdControllers.get(id); + if (controller == null) { return null; } - return platformView.getView(); + return controller.getView(); + } + + @Override + public boolean usesVirtualDisplay(Integer id) { + return vdControllers.containsKey(id); + } + + private void lockInputConnection(@NonNull VirtualDisplayController controller) { + if (textInputPlugin == null) { + return; + } + textInputPlugin.lockPlatformViewInputConnection(); + controller.onInputConnectionLocked(); + } + + private void unlockInputConnection(@NonNull VirtualDisplayController controller) { + if (textInputPlugin == null) { + return; + } + textInputPlugin.unlockPlatformViewInputConnection(); + controller.onInputConnectionUnlocked(); } private static boolean validateDirection(int direction) { @@ -625,6 +679,29 @@ private static PointerCoords parsePointerCoords(Object rawCoords, float density) return coords; } + // Creating a VirtualDisplay larger than the size of the device screen size + // could cause the device to restart: https://github.com/flutter/flutter/issues/28978 + private void validateVirtualDisplayDimensions(int width, int height) { + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + if (height > metrics.heightPixels || width > metrics.widthPixels) { + String message = + "Creating a virtual display of size: " + + "[" + + width + + ", " + + height + + "] may result in problems" + + "(https://github.com/flutter/flutter/issues/2897)." + + "It is larger than the device screen size: " + + "[" + + metrics.widthPixels + + ", " + + metrics.heightPixels + + "]."; + Log.w(TAG, message); + } + } + private float getDisplayDensity() { return context.getResources().getDisplayMetrics().density; } @@ -633,13 +710,18 @@ private int toPhysicalPixels(double logicalPixels) { return (int) Math.round(logicalPixels * getDisplayDensity()); } - private int toLogicalPixels(double physicalPixels) { - return (int) Math.round(physicalPixels / getDisplayDensity()); - } - private void flushAllViews() { + for (VirtualDisplayController controller : vdControllers.values()) { + controller.dispose(); + } + vdControllers.clear(); + while (platformViews.size() > 0) { - channelHandler.dispose(platformViews.keyAt(0)); + channelHandler.disposeAndroidViewForPlatformView(platformViews.keyAt(0)); + } + + if (contextToPlatformView.size() > 0) { + contextToPlatformView.clear(); } } diff --git a/shell/platform/android/io/flutter/plugin/platform/SingleViewPresentation.java b/shell/platform/android/io/flutter/plugin/platform/SingleViewPresentation.java new file mode 100644 index 0000000000000..a561f73868d86 --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/platform/SingleViewPresentation.java @@ -0,0 +1,478 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugin.platform; + +import static android.content.Context.WINDOW_SERVICE; +import static android.view.View.OnFocusChangeListener; + +import android.annotation.TargetApi; +import android.app.AlertDialog; +import android.app.Presentation; +import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.os.Bundle; +import android.view.Display; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.flutter.Log; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +/* + * A presentation used for hosting a single Android view in a virtual display. + * + * This presentation overrides the WindowManager's addView/removeView/updateViewLayout methods, such that views added + * directly to the WindowManager are added as part of the presentation's view hierarchy (to fakeWindowViewGroup). + * + * The view hierarchy for the presentation is as following: + * + * rootView + * / \ + * / \ + * / \ + * container state.fakeWindowViewGroup + * | + * EmbeddedView + */ +@Keep +@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +class SingleViewPresentation extends Presentation { + + /* + * When an embedded view is resized in Flutterverse we move the Android view to a new virtual display + * that has the new size. This class keeps the presentation state that moves with the view to the presentation of + * the new virtual display. + */ + static class PresentationState { + // The Android view we are embedding in the Flutter app. + private PlatformView platformView; + + // The InvocationHandler for a WindowManager proxy. This is essentially the custom window + // manager for the + // presentation. + private WindowManagerHandler windowManagerHandler; + + // Contains views that were added directly to the window manager (e.g + // android.widget.PopupWindow). + private FakeWindowViewGroup fakeWindowViewGroup; + } + + private final PlatformViewFactory viewFactory; + + // A reference to the current accessibility bridge to which accessibility events will be + // delegated. + private final AccessibilityEventsDelegate accessibilityEventsDelegate; + + private final OnFocusChangeListener focusChangeListener; + + // This is the view id assigned by the Flutter framework to the embedded view, we keep it here + // so when we create the platform view we can tell it its view id. + private int viewId; + + // This is the creation parameters for the platform view, we keep it here + // so when we create the platform view we can tell it its view id. + private Object createParams; + + // The root view for the presentation, it has 2 childs: container which contains the embedded + // view, and + // fakeWindowViewGroup which contains views that were added directly to the presentation's window + // manager. + private AccessibilityDelegatingFrameLayout rootView; + + // Contains the embedded platform view (platformView.getView()) when it is attached to the + // presentation. + private FrameLayout container; + + private final PresentationState state; + + private boolean startFocused = false; + + // The context for the application window that hosts FlutterView. + private final Context outerContext; + + /** + * Creates a presentation that will use the view factory to create a new platform view in the + * presentation's onCreate, and attach it. + */ + public SingleViewPresentation( + Context outerContext, + Display display, + PlatformViewFactory viewFactory, + AccessibilityEventsDelegate accessibilityEventsDelegate, + int viewId, + Object createParams, + OnFocusChangeListener focusChangeListener) { + super(new ImmContext(outerContext), display); + this.viewFactory = viewFactory; + this.accessibilityEventsDelegate = accessibilityEventsDelegate; + this.viewId = viewId; + this.createParams = createParams; + this.focusChangeListener = focusChangeListener; + this.outerContext = outerContext; + state = new PresentationState(); + getWindow() + .setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + getWindow().setType(WindowManager.LayoutParams.TYPE_PRIVATE_PRESENTATION); + } + } + + /** + * Creates a presentation that will attach an already existing view as its root view. + * + *

The display's density must match the density of the context used when the view was created. + */ + public SingleViewPresentation( + Context outerContext, + Display display, + AccessibilityEventsDelegate accessibilityEventsDelegate, + PresentationState state, + OnFocusChangeListener focusChangeListener, + boolean startFocused) { + super(new ImmContext(outerContext), display); + this.accessibilityEventsDelegate = accessibilityEventsDelegate; + viewFactory = null; + this.state = state; + this.focusChangeListener = focusChangeListener; + this.outerContext = outerContext; + getWindow() + .setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + this.startFocused = startFocused; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // This makes sure we preserve alpha for the VD's content. + getWindow().setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); + if (state.fakeWindowViewGroup == null) { + state.fakeWindowViewGroup = new FakeWindowViewGroup(getContext()); + } + if (state.windowManagerHandler == null) { + WindowManager windowManagerDelegate = + (WindowManager) getContext().getSystemService(WINDOW_SERVICE); + state.windowManagerHandler = + new WindowManagerHandler(windowManagerDelegate, state.fakeWindowViewGroup); + } + + container = new FrameLayout(getContext()); + + // Our base mContext has already been wrapped with an IMM cache at instantiation time, but + // we want to wrap it again here to also return state.windowManagerHandler. + Context context = + new PresentationContext(getContext(), state.windowManagerHandler, outerContext); + + if (state.platformView == null) { + state.platformView = viewFactory.create(context, viewId, createParams); + } + + View embeddedView = state.platformView.getView(); + container.addView(embeddedView); + rootView = + new AccessibilityDelegatingFrameLayout( + getContext(), accessibilityEventsDelegate, embeddedView); + rootView.addView(container); + rootView.addView(state.fakeWindowViewGroup); + + embeddedView.setOnFocusChangeListener(focusChangeListener); + rootView.setFocusableInTouchMode(true); + if (startFocused) { + embeddedView.requestFocus(); + } else { + rootView.requestFocus(); + } + setContentView(rootView); + } + + public PresentationState detachState() { + container.removeAllViews(); + rootView.removeAllViews(); + return state; + } + + public PlatformView getView() { + if (state.platformView == null) return null; + return state.platformView; + } + + /* + * A view group that implements the same layout protocol that exist between the WindowManager and its direct + * children. + * + * Currently only a subset of the protocol is supported (gravity, x, and y). + */ + static class FakeWindowViewGroup extends ViewGroup { + // Used in onLayout to keep the bounds of the current view. + // We keep it as a member to avoid object allocations during onLayout which are discouraged. + private final Rect viewBounds; + + // Used in onLayout to keep the bounds of the child views. + // We keep it as a member to avoid object allocations during onLayout which are discouraged. + private final Rect childRect; + + public FakeWindowViewGroup(Context context) { + super(context); + viewBounds = new Rect(); + childRect = new Rect(); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + WindowManager.LayoutParams params = (WindowManager.LayoutParams) child.getLayoutParams(); + viewBounds.set(l, t, r, b); + Gravity.apply( + params.gravity, + child.getMeasuredWidth(), + child.getMeasuredHeight(), + viewBounds, + params.x, + params.y, + childRect); + child.layout(childRect.left, childRect.top, childRect.right, childRect.bottom); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + child.measure(atMost(widthMeasureSpec), atMost(heightMeasureSpec)); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + private static int atMost(int measureSpec) { + return MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(measureSpec), MeasureSpec.AT_MOST); + } + } + + /** Answers calls for {@link InputMethodManager} with an instance cached at creation time. */ + // TODO(mklim): This caches the IMM at construction time and won't pick up any changes. In rare + // cases where the FlutterView changes windows this will return an outdated instance. This + // should be fixed to instead defer returning the IMM to something that know's FlutterView's + // true Context. + private static class ImmContext extends ContextWrapper { + private @NonNull final InputMethodManager inputMethodManager; + + ImmContext(Context base) { + this(base, /*inputMethodManager=*/ null); + } + + private ImmContext(Context base, @Nullable InputMethodManager inputMethodManager) { + super(base); + this.inputMethodManager = + inputMethodManager != null + ? inputMethodManager + : (InputMethodManager) base.getSystemService(INPUT_METHOD_SERVICE); + } + + @Override + public Object getSystemService(String name) { + if (INPUT_METHOD_SERVICE.equals(name)) { + return inputMethodManager; + } + return super.getSystemService(name); + } + + @Override + public Context createDisplayContext(Display display) { + Context displayContext = super.createDisplayContext(display); + return new ImmContext(displayContext, inputMethodManager); + } + } + + /** Proxies a Context replacing the WindowManager with our custom instance. */ + // TODO(mklim): This caches the IMM at construction time and won't pick up any changes. In rare + // cases where the FlutterView changes windows this will return an outdated instance. This + // should be fixed to instead defer returning the IMM to something that know's FlutterView's + // true Context. + private static class PresentationContext extends ContextWrapper { + private @NonNull final WindowManagerHandler windowManagerHandler; + private @Nullable WindowManager windowManager; + private final Context flutterAppWindowContext; + + PresentationContext( + Context base, + @NonNull WindowManagerHandler windowManagerHandler, + Context flutterAppWindowContext) { + super(base); + this.windowManagerHandler = windowManagerHandler; + this.flutterAppWindowContext = flutterAppWindowContext; + } + + @Override + public Object getSystemService(String name) { + if (WINDOW_SERVICE.equals(name)) { + if (isCalledFromAlertDialog()) { + // Alert dialogs are showing on top of the entire application and should not be limited to + // the virtual + // display. If we detect that an android.app.AlertDialog constructor is what's fetching + // the window manager + // we return the one for the application's window. + // + // Note that if we don't do this AlertDialog will throw a ClassCastException as down the + // line it tries + // to case this instance to a WindowManagerImpl which the object returned by + // getWindowManager is not + // a subclass of. + return flutterAppWindowContext.getSystemService(name); + } + return getWindowManager(); + } + return super.getSystemService(name); + } + + private WindowManager getWindowManager() { + if (windowManager == null) { + windowManager = windowManagerHandler.getWindowManager(); + } + return windowManager; + } + + private boolean isCalledFromAlertDialog() { + StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace(); + for (int i = 0; i < stackTraceElements.length && i < 11; i++) { + if (stackTraceElements[i].getClassName().equals(AlertDialog.class.getCanonicalName()) + && stackTraceElements[i].getMethodName().equals("")) { + return true; + } + } + return false; + } + } + + /* + * A dynamic proxy handler for a WindowManager with custom overrides. + * + * The presentation's window manager delegates all calls to the default window manager. + * WindowManager#addView calls triggered by views that are attached to the virtual display are crashing + * (see: https://github.com/flutter/flutter/issues/20714). This was triggered when selecting text in an embedded + * WebView (as the selection handles are implemented as popup windows). + * + * This dynamic proxy overrides the addView, removeView, removeViewImmediate, and updateViewLayout methods + * to prevent these crashes. + * + * This will be more efficient as a static proxy that's not using reflection, but as the engine is currently + * not being built against the latest Android SDK we cannot override all relevant method. + * Tracking issue for upgrading the engine's Android sdk: https://github.com/flutter/flutter/issues/20717 + */ + static class WindowManagerHandler implements InvocationHandler { + private static final String TAG = "PlatformViewsController"; + + private final WindowManager delegate; + FakeWindowViewGroup fakeWindowRootView; + + WindowManagerHandler(WindowManager delegate, FakeWindowViewGroup fakeWindowViewGroup) { + this.delegate = delegate; + fakeWindowRootView = fakeWindowViewGroup; + } + + public WindowManager getWindowManager() { + return (WindowManager) + Proxy.newProxyInstance( + WindowManager.class.getClassLoader(), new Class[] {WindowManager.class}, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + switch (method.getName()) { + case "addView": + addView(args); + return null; + case "removeView": + removeView(args); + return null; + case "removeViewImmediate": + removeViewImmediate(args); + return null; + case "updateViewLayout": + updateViewLayout(args); + return null; + } + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + private void addView(Object[] args) { + if (fakeWindowRootView == null) { + Log.w(TAG, "Embedded view called addView while detached from presentation"); + return; + } + View view = (View) args[0]; + WindowManager.LayoutParams layoutParams = (WindowManager.LayoutParams) args[1]; + fakeWindowRootView.addView(view, layoutParams); + } + + private void removeView(Object[] args) { + if (fakeWindowRootView == null) { + Log.w(TAG, "Embedded view called removeView while detached from presentation"); + return; + } + View view = (View) args[0]; + fakeWindowRootView.removeView(view); + } + + private void removeViewImmediate(Object[] args) { + if (fakeWindowRootView == null) { + Log.w(TAG, "Embedded view called removeViewImmediate while detached from presentation"); + return; + } + View view = (View) args[0]; + view.clearAnimation(); + fakeWindowRootView.removeView(view); + } + + private void updateViewLayout(Object[] args) { + if (fakeWindowRootView == null) { + Log.w(TAG, "Embedded view called updateViewLayout while detached from presentation"); + return; + } + View view = (View) args[0]; + WindowManager.LayoutParams layoutParams = (WindowManager.LayoutParams) args[1]; + fakeWindowRootView.updateViewLayout(view, layoutParams); + } + } + + private static class AccessibilityDelegatingFrameLayout extends FrameLayout { + private final AccessibilityEventsDelegate accessibilityEventsDelegate; + private final View embeddedView; + + public AccessibilityDelegatingFrameLayout( + Context context, + AccessibilityEventsDelegate accessibilityEventsDelegate, + View embeddedView) { + super(context); + this.accessibilityEventsDelegate = accessibilityEventsDelegate; + this.embeddedView = embeddedView; + } + + @Override + public boolean requestSendAccessibilityEvent(View child, AccessibilityEvent event) { + return accessibilityEventsDelegate.requestSendAccessibilityEvent(embeddedView, child, event); + } + } +} diff --git a/shell/platform/android/io/flutter/plugin/platform/VirtualDisplayController.java b/shell/platform/android/io/flutter/plugin/platform/VirtualDisplayController.java new file mode 100644 index 0000000000000..fec53e89a6d9b --- /dev/null +++ b/shell/platform/android/io/flutter/plugin/platform/VirtualDisplayController.java @@ -0,0 +1,249 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugin.platform; + +import static android.view.View.OnFocusChangeListener; + +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.os.Build; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.View; +import android.view.ViewTreeObserver; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import io.flutter.view.TextureRegistry; + +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +class VirtualDisplayController { + + public static VirtualDisplayController create( + Context context, + AccessibilityEventsDelegate accessibilityEventsDelegate, + PlatformViewFactory viewFactory, + TextureRegistry.SurfaceTextureEntry textureEntry, + int width, + int height, + int viewId, + Object createParams, + OnFocusChangeListener focusChangeListener) { + textureEntry.surfaceTexture().setDefaultBufferSize(width, height); + Surface surface = new Surface(textureEntry.surfaceTexture()); + DisplayManager displayManager = + (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + + int densityDpi = context.getResources().getDisplayMetrics().densityDpi; + VirtualDisplay virtualDisplay = + displayManager.createVirtualDisplay("flutter-vd", width, height, densityDpi, surface, 0); + + if (virtualDisplay == null) { + return null; + } + + return new VirtualDisplayController( + context, + accessibilityEventsDelegate, + virtualDisplay, + viewFactory, + surface, + textureEntry, + focusChangeListener, + viewId, + createParams); + } + + private final Context context; + private final AccessibilityEventsDelegate accessibilityEventsDelegate; + private final int densityDpi; + private final TextureRegistry.SurfaceTextureEntry textureEntry; + private final OnFocusChangeListener focusChangeListener; + private VirtualDisplay virtualDisplay; + @VisibleForTesting SingleViewPresentation presentation; + private final Surface surface; + + private VirtualDisplayController( + Context context, + AccessibilityEventsDelegate accessibilityEventsDelegate, + VirtualDisplay virtualDisplay, + PlatformViewFactory viewFactory, + Surface surface, + TextureRegistry.SurfaceTextureEntry textureEntry, + OnFocusChangeListener focusChangeListener, + int viewId, + Object createParams) { + this.context = context; + this.accessibilityEventsDelegate = accessibilityEventsDelegate; + this.textureEntry = textureEntry; + this.focusChangeListener = focusChangeListener; + this.surface = surface; + this.virtualDisplay = virtualDisplay; + densityDpi = context.getResources().getDisplayMetrics().densityDpi; + presentation = + new SingleViewPresentation( + context, + this.virtualDisplay.getDisplay(), + viewFactory, + accessibilityEventsDelegate, + viewId, + createParams, + focusChangeListener); + presentation.show(); + } + + public void resize(final int width, final int height, final Runnable onNewSizeFrameAvailable) { + boolean isFocused = getView().isFocused(); + final SingleViewPresentation.PresentationState presentationState = presentation.detachState(); + // We detach the surface to prevent it being destroyed when releasing the vd. + // + // setSurface is only available starting API 20. We could support API 19 by re-creating a new + // SurfaceTexture here. This will require refactoring the TextureRegistry to allow recycling + // texture + // entry IDs. + virtualDisplay.setSurface(null); + virtualDisplay.release(); + + textureEntry.surfaceTexture().setDefaultBufferSize(width, height); + DisplayManager displayManager = + (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + virtualDisplay = + displayManager.createVirtualDisplay("flutter-vd", width, height, densityDpi, surface, 0); + + final View embeddedView = getView(); + // There's a bug in Android version older than O where view tree observer onDrawListeners don't + // get properly + // merged when attaching to window, as a workaround we register the on draw listener after the + // view is attached. + embeddedView.addOnAttachStateChangeListener( + new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + OneTimeOnDrawListener.schedule( + embeddedView, + new Runnable() { + @Override + public void run() { + // We need some delay here until the frame propagates through the vd surface to + // to the texture, + // 128ms was picked pretty arbitrarily based on trial and error. + // As long as we invoke the runnable after a new frame is available we avoid the + // scaling jank + // described in: https://github.com/flutter/flutter/issues/19572 + // We should ideally run onNewSizeFrameAvailable ASAP to make the embedded view + // more responsive + // following a resize. + embeddedView.postDelayed(onNewSizeFrameAvailable, 128); + } + }); + embeddedView.removeOnAttachStateChangeListener(this); + } + + @Override + public void onViewDetachedFromWindow(View v) {} + }); + + // Create a new SingleViewPresentation and show() it before we cancel() the existing + // presentation. Calling show() and cancel() in this order fixes + // https://github.com/flutter/flutter/issues/26345 and maintains seamless transition + // of the contents of the presentation. + SingleViewPresentation newPresentation = + new SingleViewPresentation( + context, + virtualDisplay.getDisplay(), + accessibilityEventsDelegate, + presentationState, + focusChangeListener, + isFocused); + newPresentation.show(); + presentation.cancel(); + presentation = newPresentation; + } + + public void dispose() { + PlatformView view = presentation.getView(); + // Fix rare crash on HuaWei device described in: https://github.com/flutter/engine/pull/9192 + presentation.cancel(); + presentation.detachState(); + view.dispose(); + virtualDisplay.release(); + textureEntry.release(); + } + + /** See {@link PlatformView#onFlutterViewAttached(View)} */ + /*package*/ void onFlutterViewAttached(@NonNull View flutterView) { + if (presentation == null || presentation.getView() == null) { + return; + } + presentation.getView().onFlutterViewAttached(flutterView); + } + + /** See {@link PlatformView#onFlutterViewDetached()} */ + /*package*/ void onFlutterViewDetached() { + if (presentation == null || presentation.getView() == null) { + return; + } + presentation.getView().onFlutterViewDetached(); + } + + /*package*/ void onInputConnectionLocked() { + if (presentation == null || presentation.getView() == null) { + return; + } + presentation.getView().onInputConnectionLocked(); + } + + /*package*/ void onInputConnectionUnlocked() { + if (presentation == null || presentation.getView() == null) { + return; + } + presentation.getView().onInputConnectionUnlocked(); + } + + public View getView() { + if (presentation == null) return null; + PlatformView platformView = presentation.getView(); + return platformView.getView(); + } + + /** Dispatches a motion event to the presentation for this controller. */ + public void dispatchTouchEvent(MotionEvent event) { + if (presentation == null) return; + presentation.dispatchTouchEvent(event); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + static class OneTimeOnDrawListener implements ViewTreeObserver.OnDrawListener { + static void schedule(View view, Runnable runnable) { + OneTimeOnDrawListener listener = new OneTimeOnDrawListener(view, runnable); + view.getViewTreeObserver().addOnDrawListener(listener); + } + + final View mView; + Runnable mOnDrawRunnable; + + OneTimeOnDrawListener(View view, Runnable onDrawRunnable) { + this.mView = view; + this.mOnDrawRunnable = onDrawRunnable; + } + + @Override + public void onDraw() { + if (mOnDrawRunnable == null) { + return; + } + mOnDrawRunnable.run(); + mOnDrawRunnable = null; + mView.post( + new Runnable() { + @Override + public void run() { + mView.getViewTreeObserver().removeOnDrawListener(OneTimeOnDrawListener.this); + } + }); + } + } +} diff --git a/shell/platform/android/io/flutter/util/ViewUtils.java b/shell/platform/android/io/flutter/util/ViewUtils.java index bac41ac7c06ca..6ebac49a00f89 100644 --- a/shell/platform/android/io/flutter/util/ViewUtils.java +++ b/shell/platform/android/io/flutter/util/ViewUtils.java @@ -9,8 +9,6 @@ import android.content.ContextWrapper; import android.os.Build; import android.view.View; -import android.view.ViewGroup; -import androidx.annotation.Nullable; public final class ViewUtils { /** @@ -47,28 +45,4 @@ public static int generateViewId(int fallbackId) { } return fallbackId; } - - /** - * Determines if the current view or any descendant view has focus. - * - * @param root The root view. - * @return True if the current view or any descendant view has focus. - */ - public static boolean childHasFocus(@Nullable View root) { - if (root == null) { - return false; - } - if (root.hasFocus()) { - return true; - } - if (root instanceof ViewGroup) { - final ViewGroup viewGroup = (ViewGroup) root; - for (int idx = 0; idx < viewGroup.getChildCount(); idx++) { - if (childHasFocus(viewGroup.getChildAt(idx))) { - return true; - } - } - } - return false; - } } diff --git a/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/shell/platform/android/io/flutter/view/AccessibilityBridge.java index b79191bd09970..2381d879898e5 100644 --- a/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -573,6 +573,23 @@ public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { return null; } + // Generate accessibility node for platform views using a virtual display. + // + // In this case, register the accessibility node in the view embedder, + // so the accessibility tree can be mirrored as a subtree of the Flutter accessibility tree. + // This is in constrast to hybrid composition where the embedded view is in the view hiearchy, + // so it doesn't need to be mirrored. + // + // See the case down below for how hybrid composition is handled. + if (semanticsNode.platformViewId != -1) { + View embeddedView = + platformViewsAccessibilityDelegate.getPlatformViewById(semanticsNode.platformViewId); + if (platformViewsAccessibilityDelegate.usesVirtualDisplay(semanticsNode.platformViewId)) { + Rect bounds = semanticsNode.getGlobalRect(); + return accessibilityViewEmbedder.getRootNode(embeddedView, semanticsNode.id, bounds); + } + } + AccessibilityNodeInfo result = obtainAccessibilityNodeInfo(rootAccessibilityView, virtualViewId); // Work around for https://github.com/flutter/flutter/issues/2101 @@ -887,10 +904,17 @@ && shouldSetCollectionInfo(semanticsNode)) { // Add the embedded view as a child of the current accessibility node if it's using // hybrid composition. - result.addChild(embeddedView); - } else { - result.addChild(rootAccessibilityView, child.id); + // + // In this case, the view is in the Activity's view hierarchy, so it doesn't need to be + // mirrored. + // + // See the case above for how virtual displays are handled. + if (!platformViewsAccessibilityDelegate.usesVirtualDisplay(child.platformViewId)) { + result.addChild(embeddedView); + continue; + } } + result.addChild(rootAccessibilityView, child.id); } return result; } @@ -1521,7 +1545,8 @@ void updateSemantics( if (semanticsNode.hadPreviousConfig) { updated.add(semanticsNode); } - if (semanticsNode.platformViewId != -1) { + if (semanticsNode.platformViewId != -1 + && !platformViewsAccessibilityDelegate.usesVirtualDisplay(semanticsNode.platformViewId)) { View embeddedView = platformViewsAccessibilityDelegate.getPlatformViewById(semanticsNode.platformViewId); if (embeddedView != null) { @@ -1933,7 +1958,9 @@ private void willRemoveSemanticsNode(SemanticsNode semanticsNodeToBeRemoved) { embeddedAccessibilityFocusedNodeId = null; } - if (semanticsNodeToBeRemoved.platformViewId != -1) { + if (semanticsNodeToBeRemoved.platformViewId != -1 + && !platformViewsAccessibilityDelegate.usesVirtualDisplay( + semanticsNodeToBeRemoved.platformViewId)) { View embeddedView = platformViewsAccessibilityDelegate.getPlatformViewById( semanticsNodeToBeRemoved.platformViewId); diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index beb15c38c0a8f..9b86ed08193fe 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -26,6 +26,7 @@ import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; +import android.view.View; import android.view.ViewConfiguration; import android.view.ViewStructure; import android.view.WindowInsets; @@ -426,6 +427,14 @@ public InputConnection onCreateInputConnection(EditorInfo outAttrs) { return mTextInputPlugin.createInputConnection(this, mKeyboardManager, outAttrs); } + @Override + public boolean checkInputConnectionProxy(View view) { + return mNativeView + .getPluginRegistry() + .getPlatformViewsController() + .checkInputConnectionProxy(view); + } + @Override public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { super.onProvideAutofillVirtualStructure(structure, flags); diff --git a/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java b/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java index d4bdf50dc873a..16e5496ef9a50 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/mutatorsstack/FlutterMutatorViewTest.java @@ -6,6 +6,8 @@ import android.graphics.Matrix; import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; import android.view.ViewTreeObserver; import androidx.test.ext.junit.runners.AndroidJUnit4; import io.flutter.embedding.android.AndroidTouchProcessor; @@ -79,6 +81,49 @@ public void canDragViews() { } } + @Test + public void childHasFocus_rootHasFocus() { + final View rootView = mock(View.class); + when(rootView.hasFocus()).thenReturn(true); + assertTrue(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void childHasFocus_rootDoesNotHaveFocus() { + final View rootView = mock(View.class); + when(rootView.hasFocus()).thenReturn(false); + assertFalse(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void childHasFocus_rootIsNull() { + assertFalse(FlutterMutatorView.childHasFocus(null)); + } + + @Test + public void childHasFocus_childHasFocus() { + final View childView = mock(View.class); + when(childView.hasFocus()).thenReturn(true); + + final ViewGroup rootView = mock(ViewGroup.class); + when(rootView.getChildCount()).thenReturn(1); + when(rootView.getChildAt(0)).thenReturn(childView); + + assertTrue(FlutterMutatorView.childHasFocus(rootView)); + } + + @Test + public void childHasFocus_childDoesNotHaveFocus() { + final View childView = mock(View.class); + when(childView.hasFocus()).thenReturn(false); + + final ViewGroup rootView = mock(ViewGroup.class); + when(rootView.getChildCount()).thenReturn(1); + when(rootView.getChildAt(0)).thenReturn(childView); + + assertFalse(FlutterMutatorView.childHasFocus(rootView)); + } + @Test public void focusChangeListener_hasFocus() { final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); diff --git a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java deleted file mode 100644 index 1c2d4f5168028..0000000000000 --- a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java +++ /dev/null @@ -1,265 +0,0 @@ -package io.flutter.plugin.platform; - -import static android.view.View.OnFocusChangeListener; -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -import android.annotation.TargetApi; -import android.content.Context; -import android.graphics.BlendMode; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.SurfaceTexture; -import android.view.Surface; -import android.view.View; -import android.view.ViewTreeObserver; -import androidx.annotation.NonNull; -import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.robolectric.RuntimeEnvironment; - -@TargetApi(31) -@RunWith(AndroidJUnit4.class) -public class PlatformViewWrapperTest { - @Test - public void setTexture_writesToBuffer() { - final Surface surface = mock(Surface.class); - final Context ctx = ApplicationProvider.getApplicationContext(); - final PlatformViewWrapper wrapper = - new PlatformViewWrapper(ctx) { - @Override - protected Surface createSurface(@NonNull SurfaceTexture tx) { - return surface; - } - }; - - final SurfaceTexture tx = mock(SurfaceTexture.class); - when(tx.isReleased()).thenReturn(false); - - final Canvas canvas = mock(Canvas.class); - when(surface.lockHardwareCanvas()).thenReturn(canvas); - - // Test. - wrapper.setTexture(tx); - - // Verify. - verify(surface, times(1)).lockHardwareCanvas(); - verify(surface, times(1)).unlockCanvasAndPost(canvas); - verify(canvas, times(1)).drawColor(Color.TRANSPARENT, BlendMode.CLEAR); - verifyNoMoreInteractions(surface); - verifyNoMoreInteractions(canvas); - } - - @Test - public void draw_writesToBuffer() { - final Surface surface = mock(Surface.class); - final Context ctx = ApplicationProvider.getApplicationContext(); - final PlatformViewWrapper wrapper = - new PlatformViewWrapper(ctx) { - @Override - protected Surface createSurface(@NonNull SurfaceTexture tx) { - return surface; - } - }; - - wrapper.addView( - new View(ctx) { - @Override - public void draw(Canvas canvas) { - super.draw(canvas); - canvas.drawColor(Color.RED); - } - }); - - final int size = 100; - wrapper.measure(size, size); - wrapper.layout(0, 0, size, size); - - final SurfaceTexture tx = mock(SurfaceTexture.class); - when(tx.isReleased()).thenReturn(false); - - when(surface.lockHardwareCanvas()).thenReturn(mock(Canvas.class)); - - wrapper.setTexture(tx); - - reset(surface); - - final Canvas canvas = mock(Canvas.class); - when(surface.lockHardwareCanvas()).thenReturn(canvas); - when(surface.isValid()).thenReturn(true); - - // Test. - wrapper.invalidate(); - wrapper.draw(new Canvas()); - - // Verify. - verify(canvas, times(1)).drawColor(Color.TRANSPARENT, BlendMode.CLEAR); - verify(surface, times(1)).isValid(); - verify(surface, times(1)).lockHardwareCanvas(); - verify(surface, times(1)).unlockCanvasAndPost(canvas); - verifyNoMoreInteractions(surface); - verifyNoMoreInteractions(canvas); - } - - @Test - public void release() { - final Surface surface = mock(Surface.class); - final Context ctx = ApplicationProvider.getApplicationContext(); - final PlatformViewWrapper wrapper = - new PlatformViewWrapper(ctx) { - @Override - protected Surface createSurface(@NonNull SurfaceTexture tx) { - return surface; - } - }; - - final SurfaceTexture tx = mock(SurfaceTexture.class); - when(tx.isReleased()).thenReturn(false); - - final Canvas canvas = mock(Canvas.class); - when(surface.lockHardwareCanvas()).thenReturn(canvas); - - wrapper.setTexture(tx); - reset(surface); - reset(tx); - - // Test. - wrapper.release(); - - // Verify. - verify(surface, times(1)).release(); - verifyNoMoreInteractions(surface); - verifyNoMoreInteractions(tx); - } - - @Test - public void focusChangeListener_hasFocus() { - final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); - when(viewTreeObserver.isAlive()).thenReturn(true); - - final PlatformViewWrapper view = - new PlatformViewWrapper(RuntimeEnvironment.application) { - @Override - public ViewTreeObserver getViewTreeObserver() { - return viewTreeObserver; - } - - @Override - public boolean hasFocus() { - return true; - } - }; - - final OnFocusChangeListener focusListener = mock(OnFocusChangeListener.class); - view.setOnDescendantFocusChangeListener(focusListener); - - final ArgumentCaptor focusListenerCaptor = - ArgumentCaptor.forClass(ViewTreeObserver.OnGlobalFocusChangeListener.class); - verify(viewTreeObserver).addOnGlobalFocusChangeListener(focusListenerCaptor.capture()); - - focusListenerCaptor.getValue().onGlobalFocusChanged(null, null); - verify(focusListener).onFocusChange(view, true); - } - - @Test - public void focusChangeListener_doesNotHaveFocus() { - final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); - when(viewTreeObserver.isAlive()).thenReturn(true); - - final PlatformViewWrapper view = - new PlatformViewWrapper(RuntimeEnvironment.application) { - @Override - public ViewTreeObserver getViewTreeObserver() { - return viewTreeObserver; - } - - @Override - public boolean hasFocus() { - return false; - } - }; - - final OnFocusChangeListener focusListener = mock(OnFocusChangeListener.class); - view.setOnDescendantFocusChangeListener(focusListener); - - final ArgumentCaptor focusListenerCaptor = - ArgumentCaptor.forClass(ViewTreeObserver.OnGlobalFocusChangeListener.class); - verify(viewTreeObserver).addOnGlobalFocusChangeListener(focusListenerCaptor.capture()); - - focusListenerCaptor.getValue().onGlobalFocusChanged(null, null); - verify(focusListener).onFocusChange(view, false); - } - - @Test - public void focusChangeListener_viewTreeObserverIsAliveFalseDoesNotThrow() { - final PlatformViewWrapper view = - new PlatformViewWrapper(RuntimeEnvironment.application) { - @Override - public ViewTreeObserver getViewTreeObserver() { - final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); - when(viewTreeObserver.isAlive()).thenReturn(false); - return viewTreeObserver; - } - }; - view.setOnDescendantFocusChangeListener(mock(OnFocusChangeListener.class)); - } - - @Test - public void setOnDescendantFocusChangeListener_keepsSingleListener() { - final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); - when(viewTreeObserver.isAlive()).thenReturn(true); - - final PlatformViewWrapper view = - new PlatformViewWrapper(RuntimeEnvironment.application) { - @Override - public ViewTreeObserver getViewTreeObserver() { - return viewTreeObserver; - } - }; - - assertNull(view.activeFocusListener); - - view.setOnDescendantFocusChangeListener(mock(OnFocusChangeListener.class)); - assertNotNull(view.activeFocusListener); - - final ViewTreeObserver.OnGlobalFocusChangeListener activeFocusListener = - view.activeFocusListener; - - view.setOnDescendantFocusChangeListener(mock(OnFocusChangeListener.class)); - assertNotNull(view.activeFocusListener); - - verify(viewTreeObserver, times(1)).removeOnGlobalFocusChangeListener(activeFocusListener); - } - - @Test - public void unsetOnDescendantFocusChangeListener_removesActiveListener() { - final ViewTreeObserver viewTreeObserver = mock(ViewTreeObserver.class); - when(viewTreeObserver.isAlive()).thenReturn(true); - - final PlatformViewWrapper view = - new PlatformViewWrapper(RuntimeEnvironment.application) { - @Override - public ViewTreeObserver getViewTreeObserver() { - return viewTreeObserver; - } - }; - - assertNull(view.activeFocusListener); - - view.setOnDescendantFocusChangeListener(mock(OnFocusChangeListener.class)); - assertNotNull(view.activeFocusListener); - - final ViewTreeObserver.OnGlobalFocusChangeListener activeFocusListener = - view.activeFocusListener; - - view.unsetOnDescendantFocusChangeListener(); - assertNull(view.activeFocusListener); - - view.unsetOnDescendantFocusChangeListener(); - verify(viewTreeObserver, times(1)).removeOnGlobalFocusChangeListener(activeFocusListener); - } -} diff --git a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java index 0398f88003630..66669638e910e 100644 --- a/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java +++ b/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java @@ -17,6 +17,7 @@ import android.view.SurfaceView; import android.view.View; import android.view.ViewParent; +import android.widget.FrameLayout.LayoutParams; import androidx.test.ext.junit.runners.AndroidJUnit4; import io.flutter.embedding.android.FlutterImageView; import io.flutter.embedding.android.FlutterView; @@ -55,6 +56,120 @@ @RunWith(AndroidJUnit4.class) public class PlatformViewsControllerTest { + @Ignore + @Test + public void itNotifiesVirtualDisplayControllersOfViewAttachmentAndDetachment() { + // Setup test structure. + // Create a fake View that represents the View that renders a Flutter UI. + FlutterView fakeFlutterView = new FlutterView(RuntimeEnvironment.systemContext); + + // Create fake VirtualDisplayControllers. This requires internal knowledge of + // PlatformViewsController. We know that all PlatformViewsController does is + // forward view attachment/detachment calls to it's VirtualDisplayControllers. + // + // TODO(mattcarroll): once PlatformViewsController is refactored into testable + // pieces, remove this test and avoid verifying private behavior. + VirtualDisplayController fakeVdController1 = mock(VirtualDisplayController.class); + VirtualDisplayController fakeVdController2 = mock(VirtualDisplayController.class); + + // Create the PlatformViewsController that is under test. + PlatformViewsController platformViewsController = new PlatformViewsController(); + + // Manually inject fake VirtualDisplayControllers into the PlatformViewsController. + platformViewsController.vdControllers.put(0, fakeVdController1); + platformViewsController.vdControllers.put(1, fakeVdController1); + + // Execute test & verify results. + // Attach PlatformViewsController to the fake Flutter View. + platformViewsController.attachToView(fakeFlutterView); + + // Verify that all virtual display controllers were notified of View attachment. + verify(fakeVdController1, times(1)).onFlutterViewAttached(eq(fakeFlutterView)); + verify(fakeVdController1, never()).onFlutterViewDetached(); + verify(fakeVdController2, times(1)).onFlutterViewAttached(eq(fakeFlutterView)); + verify(fakeVdController2, never()).onFlutterViewDetached(); + + // Detach PlatformViewsController from the fake Flutter View. + platformViewsController.detachFromView(); + + // Verify that all virtual display controllers were notified of the View detachment. + verify(fakeVdController1, times(1)).onFlutterViewAttached(eq(fakeFlutterView)); + verify(fakeVdController1, times(1)).onFlutterViewDetached(); + verify(fakeVdController2, times(1)).onFlutterViewAttached(eq(fakeFlutterView)); + verify(fakeVdController2, times(1)).onFlutterViewDetached(); + } + + @Ignore + @Test + public void itCancelsOldPresentationOnResize() { + // Setup test structure. + // Create a fake View that represents the View that renders a Flutter UI. + View fakeFlutterView = new View(RuntimeEnvironment.systemContext); + + // Create fake VirtualDisplayControllers. This requires internal knowledge of + // PlatformViewsController. We know that all PlatformViewsController does is + // forward view attachment/detachment calls to it's VirtualDisplayControllers. + // + // TODO(mattcarroll): once PlatformViewsController is refactored into testable + // pieces, remove this test and avoid verifying private behavior. + VirtualDisplayController fakeVdController1 = mock(VirtualDisplayController.class); + + SingleViewPresentation presentation = fakeVdController1.presentation; + + fakeVdController1.resize(10, 10, null); + + assertEquals(fakeVdController1.presentation != presentation, true); + assertEquals(presentation.isShowing(), false); + } + + @Test + public void itUsesActionEventTypeFromFrameworkEventForVirtualDisplays() { + MotionEventTracker motionEventTracker = MotionEventTracker.getInstance(); + PlatformViewsController platformViewsController = new PlatformViewsController(); + + MotionEvent original = + MotionEvent.obtain( + 100, // downTime + 100, // eventTime + 1, // action + 0, // x + 0, // y + 0 // metaState + ); + + // track an event that will later get passed to us from framework + MotionEventTracker.MotionEventId motionEventId = motionEventTracker.track(original); + + PlatformViewTouch frameWorkTouch = + new PlatformViewTouch( + 0, // viewId + original.getDownTime(), + original.getEventTime(), + 2, // action + 1, // pointerCount + Arrays.asList(Arrays.asList(0, 0)), // pointer properties + Arrays.asList(Arrays.asList(0., 1., 2., 3., 4., 5., 6., 7., 8.)), // pointer coords + original.getMetaState(), + original.getButtonState(), + original.getXPrecision(), + original.getYPrecision(), + original.getDeviceId(), + original.getEdgeFlags(), + original.getSource(), + original.getFlags(), + motionEventId.getId()); + + MotionEvent resolvedEvent = + platformViewsController.toMotionEvent( + 1, // density + frameWorkTouch, + true // usingVirtualDisplays + ); + + assertEquals(resolvedEvent.getAction(), frameWorkTouch.action); + assertNotEquals(resolvedEvent.getAction(), original.getAction()); + } + @Ignore @Test public void itUsesActionEventTypeFromMotionEventForHybridPlatformViews() { @@ -94,7 +209,11 @@ public void itUsesActionEventTypeFromMotionEventForHybridPlatformViews() { motionEventId.getId()); MotionEvent resolvedEvent = - platformViewsController.toMotionEvent(/*density=*/ 1, frameWorkTouch); + platformViewsController.toMotionEvent( + 1, // density + frameWorkTouch, + false // usingVirtualDisplays + ); assertNotEquals(resolvedEvent.getAction(), frameWorkTouch.action); assertEquals(resolvedEvent.getAction(), original.getAction()); @@ -178,6 +297,66 @@ public void createPlatformViewMessage__throwsIfViewIsNull() { }); } + @Test + @Config(shadows = {ShadowFlutterJNI.class, ShadowPlatformTaskQueue.class}) + public void onDetachedFromJNI_clearsPlatformViewContext() { + PlatformViewsController platformViewsController = new PlatformViewsController(); + + int platformViewId = 0; + assertNull(platformViewsController.getPlatformViewById(platformViewId)); + + PlatformViewFactory viewFactory = mock(PlatformViewFactory.class); + PlatformView platformView = mock(PlatformView.class); + + View pv = mock(View.class); + when(pv.getLayoutParams()).thenReturn(new LayoutParams(1, 1)); + + when(platformView.getView()).thenReturn(pv); + when(viewFactory.create(any(), eq(platformViewId), any())).thenReturn(platformView); + platformViewsController.getRegistry().registerViewFactory("testType", viewFactory); + + FlutterJNI jni = new FlutterJNI(); + attach(jni, platformViewsController); + + // Simulate create call from the framework. + createPlatformView( + jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ false); + + assertFalse(platformViewsController.contextToPlatformView.isEmpty()); + platformViewsController.onDetachedFromJNI(); + assertTrue(platformViewsController.contextToPlatformView.isEmpty()); + } + + @Test + @Config(shadows = {ShadowFlutterJNI.class, ShadowPlatformTaskQueue.class}) + public void onPreEngineRestart_clearsPlatformViewContext() { + PlatformViewsController platformViewsController = new PlatformViewsController(); + + int platformViewId = 0; + assertNull(platformViewsController.getPlatformViewById(platformViewId)); + + PlatformViewFactory viewFactory = mock(PlatformViewFactory.class); + PlatformView platformView = mock(PlatformView.class); + + View pv = mock(View.class); + when(pv.getLayoutParams()).thenReturn(new LayoutParams(1, 1)); + + when(platformView.getView()).thenReturn(pv); + when(viewFactory.create(any(), eq(platformViewId), any())).thenReturn(platformView); + platformViewsController.getRegistry().registerViewFactory("testType", viewFactory); + + FlutterJNI jni = new FlutterJNI(); + attach(jni, platformViewsController); + + // Simulate create call from the framework. + createPlatformView( + jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ false); + + assertFalse(platformViewsController.contextToPlatformView.isEmpty()); + platformViewsController.onDetachedFromJNI(); + assertTrue(platformViewsController.contextToPlatformView.isEmpty()); + } + @Test @Config(shadows = {ShadowFlutterJNI.class, ShadowPlatformTaskQueue.class}) public void createPlatformViewMessage__throwsIfViewHasParent() { @@ -593,6 +772,13 @@ public void destroyOverlaySurfaces__doesNotRemoveOverlayView() { verify(flutterView, never()).removeView(overlayImageView); } + @Test + public void checkInputConnectionProxy__falseIfViewIsNull() { + final PlatformViewsController platformViewsController = new PlatformViewsController(); + boolean shouldProxying = platformViewsController.checkInputConnectionProxy(null); + assertFalse(shouldProxying); + } + @Test @Config(shadows = {ShadowFlutterJNI.class, ShadowPlatformTaskQueue.class}) public void convertPlatformViewRenderSurfaceAsDefault() { diff --git a/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java b/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java new file mode 100644 index 0000000000000..2cc177fd86648 --- /dev/null +++ b/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java @@ -0,0 +1,82 @@ +package io.flutter.plugin.platform; + +import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; +import static android.os.Build.VERSION_CODES.P; +import static android.os.Build.VERSION_CODES.R; +import static junit.framework.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.view.Display; +import android.view.inputmethod.InputMethodManager; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@Config(manifest = Config.NONE) +@RunWith(AndroidJUnit4.class) +@TargetApi(P) +public class SingleViewPresentationTest { + @Test + @Config(minSdk = JELLY_BEAN_MR1, maxSdk = R) + public void returnsOuterContextInputMethodManager() { + // There's a bug in Android Q caused by the IMM being instanced per display. + // https://github.com/flutter/flutter/issues/38375. We need the context returned by + // SingleViewPresentation to be consistent from its instantiation instead of defaulting to + // what the system would have returned at call time. + + // It's not possible to set up the exact same conditions as the unit test in the bug here, + // but we can make sure that we're wrapping the Context passed in at instantiation time and + // returning the same InputMethodManager from it. This test passes in a Spy context instance + // that initially returns a mock. Without the bugfix this test falls back to Robolectric's + // system service instead of the spy's and fails. + + // Create an SVP under test with a Context that returns a local IMM mock. + Context context = spy(RuntimeEnvironment.application); + InputMethodManager expected = mock(InputMethodManager.class); + when(context.getSystemService(Context.INPUT_METHOD_SERVICE)).thenReturn(expected); + DisplayManager dm = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + SingleViewPresentation svp = + new SingleViewPresentation(context, dm.getDisplay(0), null, null, null, false); + + // Get the IMM from the SVP's context. + InputMethodManager actual = + (InputMethodManager) svp.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + + // This should be the mocked instance from construction, not the IMM from the greater + // Android OS (or Robolectric's shadow, in this case). + assertEquals(expected, actual); + } + + @Test + @Config(minSdk = JELLY_BEAN_MR1, maxSdk = R) + public void returnsOuterContextInputMethodManager_createDisplayContext() { + // The IMM should also persist across display contexts created from the base context. + + // Create an SVP under test with a Context that returns a local IMM mock. + Context context = spy(RuntimeEnvironment.application); + InputMethodManager expected = mock(InputMethodManager.class); + when(context.getSystemService(Context.INPUT_METHOD_SERVICE)).thenReturn(expected); + Display display = + ((DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE)).getDisplay(0); + SingleViewPresentation svp = + new SingleViewPresentation(context, display, null, null, null, false); + + // Get the IMM from the SVP's context. + InputMethodManager actual = + (InputMethodManager) + svp.getContext() + .createDisplayContext(display) + .getSystemService(Context.INPUT_METHOD_SERVICE); + + // This should be the mocked instance from construction, not the IMM from the greater + // Android OS (or Robolectric's shadow, in this case). + assertEquals(expected, actual); + } +} diff --git a/shell/platform/android/test/io/flutter/util/ViewUtilsTest.java b/shell/platform/android/test/io/flutter/util/ViewUtilsTest.java index 2adbc6c2afaba..937aa06dae408 100644 --- a/shell/platform/android/test/io/flutter/util/ViewUtilsTest.java +++ b/shell/platform/android/test/io/flutter/util/ViewUtilsTest.java @@ -5,16 +5,11 @@ package io.flutter.util; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import android.app.Activity; import android.content.Context; import android.content.ContextWrapper; -import android.view.View; -import android.view.ViewGroup; import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; @@ -35,47 +30,4 @@ public void canGetActivity() { ContextWrapper wrapper = new ContextWrapper(new ContextWrapper(activity)); assertEquals(activity, ViewUtils.getActivity(wrapper)); } - - @Test - public void childHasFocus_rootHasFocus() { - final View rootView = mock(View.class); - when(rootView.hasFocus()).thenReturn(true); - assertTrue(ViewUtils.childHasFocus(rootView)); - } - - @Test - public void childHasFocus_rootDoesNotHaveFocus() { - final View rootView = mock(View.class); - when(rootView.hasFocus()).thenReturn(false); - assertFalse(ViewUtils.childHasFocus(rootView)); - } - - @Test - public void childHasFocus_rootIsNull() { - assertFalse(ViewUtils.childHasFocus(null)); - } - - @Test - public void childHasFocus_childHasFocus() { - final View childView = mock(View.class); - when(childView.hasFocus()).thenReturn(true); - - final ViewGroup rootView = mock(ViewGroup.class); - when(rootView.getChildCount()).thenReturn(1); - when(rootView.getChildAt(0)).thenReturn(childView); - - assertTrue(ViewUtils.childHasFocus(rootView)); - } - - @Test - public void childHasFocus_childDoesNotHaveFocus() { - final View childView = mock(View.class); - when(childView.hasFocus()).thenReturn(false); - - final ViewGroup rootView = mock(ViewGroup.class); - when(rootView.getChildCount()).thenReturn(1); - when(rootView.getChildAt(0)).thenReturn(childView); - - assertFalse(ViewUtils.childHasFocus(rootView)); - } } diff --git a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 194465e9a5570..a1135bb6873ff 100644 --- a/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -1467,6 +1467,7 @@ public void itProducesPlatformViewNodeForHybridComposition() { View embeddedView = mock(View.class); when(accessibilityDelegate.getPlatformViewById(1)).thenReturn(embeddedView); + when(accessibilityDelegate.usesVirtualDisplay(1)).thenReturn(false); AccessibilityNodeInfo nodeInfo = mock(AccessibilityNodeInfo.class); when(embeddedView.createAccessibilityNodeInfo()).thenReturn(nodeInfo); @@ -1504,6 +1505,7 @@ public void itMakesPlatformViewImportantForAccessibility() { View embeddedView = mock(View.class); when(accessibilityDelegate.getPlatformViewById(1)).thenReturn(embeddedView); + when(accessibilityDelegate.usesVirtualDisplay(1)).thenReturn(false); TestSemanticsUpdate testSemanticsRootUpdate = root.toUpdate(); testSemanticsRootUpdate.sendUpdateToBridge(accessibilityBridge); @@ -1538,6 +1540,7 @@ public void itMakesPlatformViewNoImportantForAccessibility() { View embeddedView = mock(View.class); when(accessibilityDelegate.getPlatformViewById(1)).thenReturn(embeddedView); + when(accessibilityDelegate.usesVirtualDisplay(1)).thenReturn(false); TestSemanticsUpdate testSemanticsRootWithPlatformViewUpdate = rootWithPlatformView.toUpdate(); testSemanticsRootWithPlatformViewUpdate.sendUpdateToBridge(accessibilityBridge); @@ -1552,6 +1555,34 @@ public void itMakesPlatformViewNoImportantForAccessibility() { .setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); } + @Test + public void itProducesPlatformViewNodeForVirtualDisplay() { + PlatformViewsAccessibilityDelegate accessibilityDelegate = + mock(PlatformViewsAccessibilityDelegate.class); + AccessibilityViewEmbedder accessibilityViewEmbedder = mock(AccessibilityViewEmbedder.class); + AccessibilityBridge accessibilityBridge = + setUpBridge( + /*rootAccessibilityView=*/ null, + /*accessibilityChannel=*/ null, + /*accessibilityManager=*/ null, + /*contentResolver=*/ null, + accessibilityViewEmbedder, + accessibilityDelegate); + + TestSemanticsNode platformView = new TestSemanticsNode(); + platformView.platformViewId = 1; + + TestSemanticsUpdate testSemanticsUpdate = platformView.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + View embeddedView = mock(View.class); + when(accessibilityDelegate.getPlatformViewById(1)).thenReturn(embeddedView); + when(accessibilityDelegate.usesVirtualDisplay(1)).thenReturn(true); + + accessibilityBridge.createAccessibilityNodeInfo(0); + verify(accessibilityViewEmbedder).getRootNode(eq(embeddedView), eq(0), any(Rect.class)); + } + @Test public void releaseDropsChannelMessageHandler() { AccessibilityChannel mockChannel = mock(AccessibilityChannel.class); diff --git a/tools/android_lint/baseline.xml b/tools/android_lint/baseline.xml index 00aad19954db8..0e47fcf9f88ea 100644 --- a/tools/android_lint/baseline.xml +++ b/tools/android_lint/baseline.xml @@ -133,6 +133,17 @@ column="27"/> + + + + + @@ -39,21 +40,20 @@ - - + - + @@ -151,12 +151,13 @@ + + - @@ -177,8 +178,8 @@ - +