Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix non-vd android platform view input event offsets #52532

Merged
merged 20 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@

package io.flutter.plugin.platform;

import static android.view.MotionEvent.PointerCoords;
import static android.view.MotionEvent.PointerProperties;
import static io.flutter.Build.API_LEVELS;

import android.annotation.TargetApi;
import android.content.ComponentCallbacks2;
import android.content.Context;
import android.content.MutableContextWrapper;
import android.hardware.input.InputManager;
import android.os.Build;
import android.util.SparseArray;
import android.view.MotionEvent;
Expand All @@ -25,6 +24,7 @@
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import io.flutter.BuildConfig;
import io.flutter.Log;
import io.flutter.embedding.android.AndroidTouchProcessor;
import io.flutter.embedding.android.FlutterView;
Expand Down Expand Up @@ -53,6 +53,10 @@
public class PlatformViewsController implements PlatformViewsAccessibilityDelegate {
private static final String TAG = "PlatformViewsController";

// This input manager is only used for confirming the verification status of motion events in
// debug builds.
private InputManager inputManager;

// These view types allow out-of-band drawing commands that don't notify the Android view
// hierarchy.
// To support these cases, Flutter hosts the embedded view in a VirtualDisplay,
Expand Down Expand Up @@ -668,32 +672,64 @@ public long configureForTextureLayerComposition(
return textureId;
}

/**
* Translates an original touch event to have the same locations as the ones that Flutter
* calculates (because original + flutter's - original = flutter's).
*
* @param originalEvent The saved original input event.
* @param pointerCoords The coordinates that Flutter thinks the touch is happening at.
*/
private void translateNonVirtualDisplayMotionEvent(
gmackall marked this conversation as resolved.
Show resolved Hide resolved
gmackall marked this conversation as resolved.
Show resolved Hide resolved
MotionEvent originalEvent, PointerCoords[] pointerCoords) {
if (pointerCoords.length < 1) {
return;
}

float xOffset = pointerCoords[0].x - originalEvent.getX();
float yOffset = pointerCoords[0].y - originalEvent.getY();

originalEvent.offsetLocation(xOffset, yOffset);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a roboelectric test programmatically invokes translateNonVirtualDisplayMotionEvent and toMotionEvent and checks their return values?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote a test that invokes toMotionEvent in both the vd and non-vd case, and expects that they have equivalent x and y coords. This isn't exactly what you asked for, but does it make sense to you?

@VisibleForTesting
public MotionEvent toMotionEvent(
float density, PlatformViewsChannel.PlatformViewTouch touch, boolean usingVirtualDiplay) {
MotionEventTracker.MotionEventId motionEventId =
MotionEventTracker.MotionEventId.from(touch.motionEventId);
MotionEvent trackedEvent = motionEventTracker.pop(motionEventId);

// Pointer coordinates in the tracked events are global to FlutterView
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a deeper question about this change:

If the original MotionEvent had view-local coordinates why can't we just deliver the original MotionEvent here? Let me explain below:

When someone touches a Platform View the following happens:

  1. MotionEvent (O) is delivered directly to the PlatformView. We stash it on the side in our "motionEventTracker".
  2. Transform MotionEvent into a Flutter TouchEvent
  3. TouchEvent is delivered to Flutter Widgets and eventually to the PlatformView widget.
  4. We end up back in this code to (finally) deliver the event to the Android View.

My intuition would be that in step (1) the events in O have the correct coordinates. And then in (4) we deliver O to the view.

In what cases do we see a non-zero offset? Help me understand intuitively what's happening here.

Copy link
Member Author

@gmackall gmackall May 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is something weird going on here for sure. The reporting issue has a repro, which includes an example of a temporary fix that forces the platform view to refresh. In investigating why this temporarily fixes the issue, I printed out the original motion events and our calculated locations of platform view touches (and some more locations from framework code).

It turns out refreshing the webview changes the location of the original motion event that we store. Still need to investigate further as to why this would be the case, but it wasn't what I was originally expecting at all.

Copy link
Member Author

@gmackall gmackall May 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like probably the reason why we can't deliver the motion events as is in (4) is that the Platform view in question believes it is at (0,0) (thats the output of platformView.getView.getX() and getY()). Still not sure why that is though!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I actually know the answer here now. MotionEvents in general are relative to the view they are delivered to. So this particular motion event is global to the FlutterView, but still relative to a view.

When we deliver the motion event in (4), we are then delivering it to an inner view, and as such need to make it relative to the inner view, which is why we need to offset. Does that make sense?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Talked with @johnmccutchan offline and we still aren't sure this reasoning is 100% correct, but landing regardless as the fix works better than what we have)

// The framework converts them to be local to a widget, given that
// motion events operate on local coords, we need to replace these in the tracked
// event with their local counterparts.
// Compute this early so it can be used as input to translateNonVirtualDisplayMotionEvent.
PointerCoords[] pointerCoords =
parsePointerCoordsList(touch.rawPointerCoords, density)
.toArray(new PointerCoords[touch.pointerCount]);

if (!usingVirtualDiplay && trackedEvent != null) {
// We have the original event, deliver it as it will pass the verifiable
// We have the original event, deliver it after offsetting as it will pass the verifiable
// input check.
translateNonVirtualDisplayMotionEvent(trackedEvent, pointerCoords);
johnmccutchan marked this conversation as resolved.
Show resolved Hide resolved
if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= API_LEVELS.API_30) {
gmackall marked this conversation as resolved.
Show resolved Hide resolved
if (inputManager != null && inputManager.verifyInputEvent(trackedEvent) == null) {
// The translation we do uses MotionEvent.offsetLocation, which shouldn't affect
// verification status. This case is to warn in debug builds if this behavior changes,
// so that it doesn't go unnoticed.
throw new Error(
"Motion event that was translated in PlatformViewsController.toPlatformView "
+ "does not have verified status. Investigate if this was caused by the translation, "
+ "or if there is a case in which it isn't verified that we should ignore.");
}
}
return trackedEvent;
}
// We are in virtual display mode or don't have a reference to the original MotionEvent.
// In this case we manually recreate a MotionEvent to be delivered. This MotionEvent
// will fail the verifiable input check.

// Pointer coordinates in the tracked events are global to FlutterView
// framework converts them to be local to a widget, given that
// motion events operate on local coords, we need to replace these in the tracked
// event with their local counterparts.
PointerProperties[] pointerProperties =
parsePointerPropertiesList(touch.rawPointerPropertiesList)
.toArray(new PointerProperties[touch.pointerCount]);
PointerCoords[] pointerCoords =
parsePointerCoordsList(touch.rawPointerCoords, density)
.toArray(new PointerCoords[touch.pointerCount]);

// TODO (kaushikiska) : warn that we are potentially using an untracked
// event in the platform views.
Expand Down Expand Up @@ -751,6 +787,9 @@ public void attach(
this.textureRegistry = textureRegistry;
platformViewsChannel = new PlatformViewsChannel(dartExecutor);
platformViewsChannel.setPlatformViewsHandler(channelHandler);
if (context != null) {
inputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,31 +361,32 @@ public void itUsesActionEventTypeFromFrameworkEventAsActionChanged() {
assertNotEquals(resolvedEvent.getAction(), frameWorkTouch.action);
}

@Ignore
@Test
public void itUsesActionEventTypeFromMotionEventForHybridPlatformViews() {
public void toMotionEvent_returnsSameCoordsForVdAndNonVd() {
MotionEventTracker motionEventTracker = MotionEventTracker.getInstance();
PlatformViewsController platformViewsController = new PlatformViewsController();

MotionEvent original =
MotionEvent.obtain(
100, // downTime
100, // eventTime
1, // action
0, // x
0, // y
10, // downTime
10, // eventTime
261, // action
1, // x
1, // y
0 // metaState
);

// track an event that will later get passed to us from framework
// Get the result of toMotionEvent for both the virtual display and non virtual display case,
// and make sure they are identical in their x and y coordinates.

MotionEventTracker.MotionEventId motionEventId = motionEventTracker.track(original);

PlatformViewTouch frameWorkTouch =
PlatformViewTouch frameWorkTouchNonVd =
new PlatformViewTouch(
0, // viewId
original.getDownTime(),
original.getEventTime(),
2, // action
0, // action
gmackall marked this conversation as resolved.
Show resolved Hide resolved
1, // pointerCount
Arrays.asList(Arrays.asList(0, 0)), // pointer properties
gmackall marked this conversation as resolved.
Show resolved Hide resolved
Arrays.asList(Arrays.asList(0., 1., 2., 3., 4., 5., 6., 7., 8.)), // pointer coords
gmackall marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -399,11 +400,44 @@ public void itUsesActionEventTypeFromMotionEventForHybridPlatformViews() {
original.getFlags(),
motionEventId.getId());

MotionEvent resolvedEvent =
MotionEvent resolvedNonVdEvent =
platformViewsController.toMotionEvent(
/*density=*/ 1, frameWorkTouch, /*usingVirtualDisplay=*/ false);
1, // density
frameWorkTouchNonVd,
false // usingVirtualDisplays
);

assertEquals(resolvedEvent.getAction(), frameWorkTouch.action);
// Re track the original motion event, as toMotionEvent will pop it from the motionEventTracker.
motionEventId = motionEventTracker.track(original);
PlatformViewTouch frameWorkTouchVd =
new PlatformViewTouch(
0, // viewId
gmackall marked this conversation as resolved.
Show resolved Hide resolved
original.getDownTime(),
original.getEventTime(),
0, // 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 resolvedVdEvent =
platformViewsController.toMotionEvent(
1, // density
frameWorkTouchVd,
true // usingVirtualDisplays
);

assertEquals(resolvedVdEvent.getEventTime(), resolvedNonVdEvent.getEventTime());
assertEquals(resolvedVdEvent.getX(), resolvedNonVdEvent.getX(), 0.001d);
assertEquals(resolvedVdEvent.getY(), resolvedNonVdEvent.getY(), 0.001d);
}

@Test
Expand Down