Android views using hybrid composition e2e driver test (#61507)
This commit is contained in:
parent
b7b60a2d2c
commit
293a2bf8e8
@ -0,0 +1,14 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_devicelab/framework/adb.dart';
|
||||
import 'package:flutter_devicelab/framework/framework.dart';
|
||||
import 'package:flutter_devicelab/tasks/integration_tests.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
deviceOperatingSystem = DeviceOperatingSystem.android;
|
||||
await task(createHybridAndroidViewsIntegrationTest());
|
||||
}
|
@ -60,6 +60,13 @@ TaskFunction createEmbeddedAndroidViewsIntegrationTest() {
|
||||
);
|
||||
}
|
||||
|
||||
TaskFunction createHybridAndroidViewsIntegrationTest() {
|
||||
return DriverTest(
|
||||
'${flutterDirectory.path}/dev/integration_tests/hybrid_android_views',
|
||||
'lib/main.dart',
|
||||
);
|
||||
}
|
||||
|
||||
TaskFunction createAndroidSemanticsIntegrationTest() {
|
||||
return DriverTest(
|
||||
'${flutterDirectory.path}/dev/integration_tests/android_semantics_testing',
|
||||
|
@ -249,6 +249,12 @@ tasks:
|
||||
stage: devicelab
|
||||
required_agent_capabilities: ["mac/android"]
|
||||
|
||||
hybrid_android_views_integration_test:
|
||||
description: >
|
||||
Tests hybrid Android views.
|
||||
stage: devicelab
|
||||
required_agent_capabilities: ["mac/android"]
|
||||
|
||||
android_semantics_integration_test:
|
||||
description: >
|
||||
Tests that the Android accessibility bridge produces correct semantics.
|
||||
|
31
dev/integration_tests/hybrid_android_views/README.md
Normal file
31
dev/integration_tests/hybrid_android_views/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Integration test for hybrid composition on Android
|
||||
|
||||
This test verifies that the synthesized motion events that get to embedded
|
||||
Android view are equal to the motion events that originally hit the FlutterView.
|
||||
|
||||
The test app's Android code listens to MotionEvents that get to FlutterView and
|
||||
to an embedded Android view and sends them over a platform channel to the Dart
|
||||
code where the events are matched.
|
||||
|
||||
This is what the app looks like:
|
||||
|
||||

|
||||
|
||||
The blue part is the embedded Android view, because it is positioned at the top
|
||||
left corner, the coordinate systems for FlutterView and for the embedded view's
|
||||
virtual display has the same origin (this makes the MotionEvent comparison
|
||||
easier as we don't need to translate the coordinates).
|
||||
|
||||
The app includes the following control buttons:
|
||||
* RECORD - Start listening for MotionEvents for 3 seconds, matched/unmatched events are
|
||||
displayed in the listview as they arrive.
|
||||
* CLEAR - Clears the events that were recorded so far.
|
||||
* SAVE - Saves the events that hit FlutterView to a file.
|
||||
* PLAY FILE - Send a list of events from a bundled asset file to FlutterView.
|
||||
|
||||
A recorded touch events sequence is bundled as an asset in the
|
||||
assets_for_android_view package which lives in the goldens repository.
|
||||
|
||||
When running this test with `flutter drive` the record touch sequences is
|
||||
replayed and the test asserts that the events that got to FlutterView are
|
||||
equivalent to the ones that got to the embedded view.
|
@ -0,0 +1,58 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
compileSdkVersion 28
|
||||
|
||||
lintOptions {
|
||||
disable 'InvalidPackage'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "io.flutter.integration.platformviews"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 28
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
<!-- Copyright 2014 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. -->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="io.flutter.integration.platformviews">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:label="platform_views">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
|
||||
android:value="true" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- Hybrid composition -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedded_views_preview"
|
||||
android:value="true" />
|
||||
</application>
|
||||
</manifest>
|
@ -0,0 +1,110 @@
|
||||
// Copyright 2014 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.integration.platformviews;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity;
|
||||
import io.flutter.embedding.engine.dart.DartExecutor;
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant;
|
||||
|
||||
public class MainActivity extends FlutterActivity implements MethodChannel.MethodCallHandler {
|
||||
final static int STORAGE_PERMISSION_CODE = 1;
|
||||
|
||||
MethodChannel mMethodChannel;
|
||||
|
||||
// The method result to complete with the Android permission request result.
|
||||
// This is null when not waiting for the Android permission request;
|
||||
private MethodChannel.Result permissionResult;
|
||||
|
||||
private View getFlutterView() {
|
||||
// TODO(egarciad): Set an unique ID in FlutterView, so it's easier to look it up.
|
||||
ViewGroup root = (ViewGroup)findViewById(android.R.id.content);
|
||||
return ((ViewGroup)root.getChildAt(0)).getChildAt(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureFlutterEngine(FlutterEngine flutterEngine) {
|
||||
DartExecutor executor = flutterEngine.getDartExecutor();
|
||||
flutterEngine
|
||||
.getPlatformViewsController()
|
||||
.getRegistry()
|
||||
.registerViewFactory("simple_view", new SimpleViewFactory(executor));
|
||||
mMethodChannel = new MethodChannel(executor, "android_views_integration");
|
||||
mMethodChannel.setMethodCallHandler(this);
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
|
||||
switch(methodCall.method) {
|
||||
case "getStoragePermission":
|
||||
if (permissionResult != null) {
|
||||
result.error("error", "already waiting for permissions", null);
|
||||
return;
|
||||
}
|
||||
permissionResult = result;
|
||||
getExternalStoragePermissions();
|
||||
return;
|
||||
case "synthesizeEvent":
|
||||
synthesizeEvent(methodCall, result);
|
||||
return;
|
||||
}
|
||||
result.notImplemented();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public void synthesizeEvent(MethodCall methodCall, MethodChannel.Result result) {
|
||||
MotionEvent event = MotionEventCodec.decode((HashMap<String, Object>) methodCall.arguments());
|
||||
getFlutterView().dispatchTouchEvent(event);
|
||||
// TODO(egarciad): This can be cleaned up.
|
||||
mMethodChannel.invokeMethod("onTouch", MotionEventCodec.encode(event));
|
||||
result.success(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
if (requestCode != STORAGE_PERMISSION_CODE || permissionResult == null)
|
||||
return;
|
||||
boolean permisisonGranted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
|
||||
sendPermissionResult(permisisonGranted);
|
||||
}
|
||||
|
||||
private void getExternalStoragePermissions() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
return;
|
||||
|
||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
sendPermissionResult(true);
|
||||
return;
|
||||
}
|
||||
|
||||
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_CODE);
|
||||
}
|
||||
|
||||
private void sendPermissionResult(boolean result) {
|
||||
if (permissionResult == null)
|
||||
return;
|
||||
permissionResult.success(result);
|
||||
permissionResult = null;
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
// Copyright 2014 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.integration.platformviews;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.os.Build;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
import static android.view.MotionEvent.PointerCoords;
|
||||
import static android.view.MotionEvent.PointerProperties;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
|
||||
public class MotionEventCodec {
|
||||
public static HashMap<String, Object> encode(MotionEvent event) {
|
||||
ArrayList<HashMap<String,Object>> pointerProperties = new ArrayList<>();
|
||||
ArrayList<HashMap<String,Object>> pointerCoords = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < event.getPointerCount(); i++) {
|
||||
MotionEvent.PointerProperties properties = new MotionEvent.PointerProperties();
|
||||
event.getPointerProperties(i, properties);
|
||||
pointerProperties.add(encodePointerProperties(properties));
|
||||
|
||||
MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
|
||||
event.getPointerCoords(i, coords);
|
||||
pointerCoords.add(encodePointerCoords(coords));
|
||||
}
|
||||
|
||||
HashMap<String, Object> eventMap = new HashMap<>();
|
||||
eventMap.put("downTime", event.getDownTime());
|
||||
eventMap.put("eventTime", event.getEventTime());
|
||||
eventMap.put("action", event.getAction());
|
||||
eventMap.put("pointerCount", event.getPointerCount());
|
||||
eventMap.put("pointerProperties", pointerProperties);
|
||||
eventMap.put("pointerCoords", pointerCoords);
|
||||
eventMap.put("metaState", event.getMetaState());
|
||||
eventMap.put("buttonState", event.getButtonState());
|
||||
eventMap.put("xPrecision", event.getXPrecision());
|
||||
eventMap.put("yPrecision", event.getYPrecision());
|
||||
eventMap.put("deviceId", event.getDeviceId());
|
||||
eventMap.put("edgeFlags", event.getEdgeFlags());
|
||||
eventMap.put("source", event.getSource());
|
||||
eventMap.put("flags", event.getFlags());
|
||||
|
||||
return eventMap;
|
||||
}
|
||||
|
||||
private static HashMap<String, Object> encodePointerProperties(PointerProperties properties) {
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
map.put("id", properties.id);
|
||||
map.put("toolType", properties.toolType);
|
||||
return map;
|
||||
}
|
||||
|
||||
private static HashMap<String, Object> encodePointerCoords(PointerCoords coords) {
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
map.put("orientation", coords.orientation);
|
||||
map.put("pressure", coords.pressure);
|
||||
map.put("size", coords.size);
|
||||
map.put("toolMajor", coords.toolMajor);
|
||||
map.put("toolMinor", coords.toolMinor);
|
||||
map.put("touchMajor", coords.touchMajor);
|
||||
map.put("touchMinor", coords.touchMinor);
|
||||
map.put("x", coords.x);
|
||||
map.put("y", coords.y);
|
||||
return map;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static MotionEvent decode(HashMap<String, Object> data) {
|
||||
List<PointerProperties> pointerProperties = new ArrayList<>();
|
||||
List<PointerCoords> pointerCoords = new ArrayList<>();
|
||||
|
||||
for (HashMap<String, Object> property : (List<HashMap<String, Object>>) data.get("pointerProperties")) {
|
||||
pointerProperties.add(decodePointerProperties(property)) ;
|
||||
}
|
||||
|
||||
for (HashMap<String, Object> coord : (List<HashMap<String, Object>>) data.get("pointerCoords")) {
|
||||
pointerCoords.add(decodePointerCoords(coord)) ;
|
||||
}
|
||||
|
||||
return MotionEvent.obtain(
|
||||
(int) data.get("downTime"),
|
||||
(int) data.get("eventTime"),
|
||||
(int) data.get("action"),
|
||||
(int) data.get("pointerCount"),
|
||||
pointerProperties.toArray(new PointerProperties[pointerProperties.size()]),
|
||||
pointerCoords.toArray(new PointerCoords[pointerCoords.size()]),
|
||||
(int) data.get("metaState"),
|
||||
(int) data.get("buttonState"),
|
||||
(float) (double) data.get("xPrecision"),
|
||||
(float) (double) data.get("yPrecision"),
|
||||
(int) data.get("deviceId"),
|
||||
(int) data.get("edgeFlags"),
|
||||
(int) data.get("source"),
|
||||
(int) data.get("flags")
|
||||
);
|
||||
}
|
||||
|
||||
private static PointerProperties decodePointerProperties(HashMap<String, Object> data) {
|
||||
PointerProperties properties = new PointerProperties();
|
||||
properties.id = (int) data.get("id");
|
||||
properties.toolType = (int) data.get("toolType");
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static PointerCoords decodePointerCoords(HashMap<String, Object> data) {
|
||||
PointerCoords coords = new PointerCoords();
|
||||
coords.orientation = (float) (double) data.get("orientation");
|
||||
coords.pressure = (float) (double) data.get("pressure");
|
||||
coords.size = (float) (double) data.get("size");
|
||||
coords.toolMajor = (float) (double) data.get("toolMajor");
|
||||
coords.toolMinor = (float) (double) data.get("toolMinor");
|
||||
coords.touchMajor = (float) (double) data.get("touchMajor");
|
||||
coords.touchMinor = (float) (double) data.get("touchMinor");
|
||||
coords.x = (float) (double) data.get("x");
|
||||
coords.y = (float) (double) data.get("y");
|
||||
return coords;
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
// Copyright 2014 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.integration.platformviews;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugin.platform.PlatformView;
|
||||
|
||||
public class SimplePlatformView implements PlatformView, MethodChannel.MethodCallHandler {
|
||||
private final FrameLayout view;
|
||||
private final MethodChannel methodChannel;
|
||||
private final io.flutter.integration.platformviews.TouchPipe touchPipe;
|
||||
|
||||
SimplePlatformView(Context context, MethodChannel methodChannel) {
|
||||
this.methodChannel = methodChannel;
|
||||
this.methodChannel.setMethodCallHandler(this);
|
||||
|
||||
view = new FrameLayout(context) {
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
view.setBackgroundColor(0xff0000ff);
|
||||
|
||||
touchPipe = new TouchPipe(this.methodChannel, view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView() {
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {}
|
||||
|
||||
@Override
|
||||
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
|
||||
switch(methodCall.method) {
|
||||
case "pipeTouchEvents":
|
||||
touchPipe.enable();
|
||||
result.success(null);
|
||||
return;
|
||||
case "stopTouchEvents":
|
||||
touchPipe.disable();
|
||||
result.success(null);
|
||||
return;
|
||||
case "showAndHideAlertDialog":
|
||||
showAndHideAlertDialog(result);
|
||||
return;
|
||||
case "addChildViewAndWaitForClick":
|
||||
addWindow(result);
|
||||
return;
|
||||
|
||||
}
|
||||
result.notImplemented();
|
||||
}
|
||||
|
||||
private void showAndHideAlertDialog(MethodChannel.Result result) {
|
||||
Context context = view.getContext();
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
TextView textView = new TextView(context);
|
||||
textView.setText("This alert dialog will close in 1 second");
|
||||
builder.setView(textView);
|
||||
final AlertDialog alertDialog = builder.show();
|
||||
result.success(null);
|
||||
view.postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
alertDialog.hide();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private void addWindow(final MethodChannel.Result result) {
|
||||
Context context = view.getContext();
|
||||
final Button button = new Button(context);
|
||||
button.setText("This view was added to the Android view");
|
||||
view.addView(button);
|
||||
button.setOnClickListener(v -> {
|
||||
view.removeView(button);
|
||||
result.success(null);
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// Copyright 2014 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.integration.platformviews;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import io.flutter.embedding.engine.dart.DartExecutor;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
import io.flutter.plugin.platform.PlatformView;
|
||||
import io.flutter.plugin.platform.PlatformViewFactory;
|
||||
|
||||
public class SimpleViewFactory extends PlatformViewFactory {
|
||||
final DartExecutor executor;
|
||||
|
||||
public SimpleViewFactory(DartExecutor executor) {
|
||||
super(null);
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlatformView create(Context context, int id, Object params) {
|
||||
MethodChannel methodChannel = new MethodChannel(executor, "simple_view/" + id);
|
||||
return new SimplePlatformView(context, methodChannel);
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
// Copyright 2014 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.integration.platformviews;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.os.Build;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
|
||||
class TouchPipe implements View.OnTouchListener {
|
||||
private final MethodChannel mMethodChannel;
|
||||
private final View mView;
|
||||
|
||||
private boolean mEnabled;
|
||||
|
||||
TouchPipe(MethodChannel methodChannel, View view) {
|
||||
mMethodChannel = methodChannel;
|
||||
mView = view;
|
||||
}
|
||||
|
||||
public void enable() {
|
||||
if (mEnabled)
|
||||
return;
|
||||
mEnabled = true;
|
||||
mView.setOnTouchListener(this);
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
if(!mEnabled)
|
||||
return;
|
||||
mEnabled = false;
|
||||
mView.setOnTouchListener(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
mMethodChannel.invokeMethod("onTouch", MotionEventCodec.encode(event));
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.0'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.enableR8=true
|
6
dev/integration_tests/hybrid_android_views/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
dev/integration_tests/hybrid_android_views/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
#Fri Jun 23 08:50:38 CEST 2017
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip
|
@ -0,0 +1,15 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
include ':app'
|
||||
|
||||
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||
def properties = new Properties()
|
||||
|
||||
assert localPropertiesFile.exists()
|
||||
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
@ -0,0 +1,61 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AndroidPlatformView extends StatelessWidget {
|
||||
/// Creates a platform view for Android, which is rendered as a
|
||||
/// native view.
|
||||
/// `viewType` identifies the type of Android view to create.
|
||||
const AndroidPlatformView({
|
||||
Key key,
|
||||
this.onPlatformViewCreated,
|
||||
@required this.viewType,
|
||||
}) : assert(viewType != null),
|
||||
super(key: key);
|
||||
|
||||
/// The unique identifier for the view type to be embedded by this widget.
|
||||
///
|
||||
/// A PlatformViewFactory for this type must have been registered.
|
||||
final String viewType;
|
||||
|
||||
/// {@template flutter.widgets.platformViews.createdParam}
|
||||
/// Callback to invoke after the platform view has been created.
|
||||
///
|
||||
/// May be null.
|
||||
/// {@endtemplate}
|
||||
final PlatformViewCreatedCallback onPlatformViewCreated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlatformViewLink(
|
||||
viewType: viewType,
|
||||
surfaceFactory:
|
||||
(BuildContext context, PlatformViewController controller) {
|
||||
return AndroidViewSurface(
|
||||
controller: controller as AndroidViewController,
|
||||
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
|
||||
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
|
||||
);
|
||||
},
|
||||
onCreatePlatformView: (PlatformViewCreationParams params) {
|
||||
final AndroidViewController controller =
|
||||
PlatformViewsService.initSurfaceAndroidView(
|
||||
id: params.id,
|
||||
viewType: params.viewType,
|
||||
layoutDirection: TextDirection.ltr,
|
||||
)
|
||||
..addOnPlatformViewCreatedListener(params.onPlatformViewCreated);
|
||||
if (onPlatformViewCreated != null) {
|
||||
controller.addOnPlatformViewCreatedListener(onPlatformViewCreated);
|
||||
}
|
||||
return controller..create();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
46
dev/integration_tests/hybrid_android_views/lib/main.dart
Normal file
46
dev/integration_tests/hybrid_android_views/lib/main.dart
Normal file
@ -0,0 +1,46 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_driver/driver_extension.dart';
|
||||
|
||||
import 'motion_events_page.dart';
|
||||
import 'nested_view_event_page.dart';
|
||||
import 'page.dart';
|
||||
|
||||
final List<PageWidget> _allPages = <PageWidget>[
|
||||
const MotionEventsPage(),
|
||||
const NestedViewEventPage(),
|
||||
];
|
||||
|
||||
void main() {
|
||||
enableFlutterDriverExtension(handler: driverDataHandler.handleMessage);
|
||||
runApp(MaterialApp(home: Home()));
|
||||
}
|
||||
|
||||
class Home extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
children: _allPages.map((PageWidget p) => _buildPageListTile(context, p)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageListTile(BuildContext context, PageWidget page) {
|
||||
return ListTile(
|
||||
title: Text(page.title),
|
||||
key: page.tileKey,
|
||||
onTap: () { _pushPage(context, page); },
|
||||
);
|
||||
}
|
||||
|
||||
void _pushPage(BuildContext context, PageWidget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
body: page,
|
||||
)));
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
// Android MotionEvent actions for which a pointer index is encoded in the
|
||||
// unmasked action code.
|
||||
const List<int> kPointerActions = <int>[
|
||||
0, // DOWN
|
||||
1, // UP
|
||||
5, // POINTER_DOWN
|
||||
6, // POINTER_UP
|
||||
];
|
||||
|
||||
const double kDoubleErrorMargin = 1e-4;
|
||||
|
||||
String diffMotionEvents(
|
||||
Map<String, dynamic> originalEvent,
|
||||
Map<String, dynamic> synthesizedEvent,
|
||||
) {
|
||||
final StringBuffer diff = StringBuffer();
|
||||
|
||||
diffMaps(originalEvent, synthesizedEvent, diff, excludeKeys: const <String>[
|
||||
'pointerProperties', // Compared separately.
|
||||
'pointerCoords', // Compared separately.
|
||||
'source', // Unused by Flutter.
|
||||
'deviceId', // Android documentation says that's an arbitrary number that shouldn't be depended on.
|
||||
'action', // Compared separately.
|
||||
]);
|
||||
|
||||
diffActions(diff, originalEvent, synthesizedEvent);
|
||||
diffPointerProperties(diff, originalEvent, synthesizedEvent);
|
||||
diffPointerCoordsList(diff, originalEvent, synthesizedEvent);
|
||||
|
||||
return diff.toString();
|
||||
}
|
||||
|
||||
void diffActions(StringBuffer diffBuffer, Map<String, dynamic> originalEvent,
|
||||
Map<String, dynamic> synthesizedEvent) {
|
||||
final int synthesizedActionMasked =
|
||||
getActionMasked(synthesizedEvent['action'] as int);
|
||||
final int originalActionMasked = getActionMasked(originalEvent['action'] as int);
|
||||
final String synthesizedActionName =
|
||||
getActionName(synthesizedActionMasked, synthesizedEvent['action'] as int);
|
||||
final String originalActionName =
|
||||
getActionName(originalActionMasked, originalEvent['action'] as int);
|
||||
|
||||
if (synthesizedActionMasked != originalActionMasked)
|
||||
diffBuffer.write(
|
||||
'action (expected: $originalActionName actual: $synthesizedActionName) ');
|
||||
|
||||
if (kPointerActions.contains(originalActionMasked) &&
|
||||
originalActionMasked == synthesizedActionMasked) {
|
||||
final int originalPointer = getPointerIdx(originalEvent['action'] as int);
|
||||
final int synthesizedPointer = getPointerIdx(synthesizedEvent['action'] as int);
|
||||
if (originalPointer != synthesizedPointer)
|
||||
diffBuffer.write(
|
||||
'pointerIdx (expected: $originalPointer actual: $synthesizedPointer action: $originalActionName ');
|
||||
}
|
||||
}
|
||||
|
||||
void diffPointerProperties(StringBuffer diffBuffer,
|
||||
Map<String, dynamic> originalEvent, Map<String, dynamic> synthesizedEvent) {
|
||||
final List<Map<dynamic, dynamic>> expectedList =
|
||||
(originalEvent['pointerProperties'] as List<dynamic>).cast<Map<dynamic, dynamic>>();
|
||||
final List<Map<dynamic, dynamic>> actualList =
|
||||
(synthesizedEvent['pointerProperties'] as List<dynamic>).cast<Map<dynamic, dynamic>>();
|
||||
|
||||
if (expectedList.length != actualList.length) {
|
||||
diffBuffer.write(
|
||||
'pointerProperties (actual length: ${actualList.length}, expected length: ${expectedList.length} ');
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < expectedList.length; i++) {
|
||||
final Map<String, dynamic> expected =
|
||||
expectedList[i].cast<String, dynamic>();
|
||||
final Map<String, dynamic> actual = actualList[i].cast<String, dynamic>();
|
||||
diffMaps(expected, actual, diffBuffer,
|
||||
messagePrefix: '[pointerProperty $i] ');
|
||||
}
|
||||
}
|
||||
|
||||
void diffPointerCoordsList(StringBuffer diffBuffer,
|
||||
Map<String, dynamic> originalEvent, Map<String, dynamic> synthesizedEvent) {
|
||||
final List<Map<dynamic, dynamic>> expectedList =
|
||||
(originalEvent['pointerCoords'] as List<dynamic>).cast<Map<dynamic, dynamic>>();
|
||||
final List<Map<dynamic, dynamic>> actualList =
|
||||
(synthesizedEvent['pointerCoords'] as List<dynamic>).cast<Map<dynamic, dynamic>>();
|
||||
|
||||
if (expectedList.length != actualList.length) {
|
||||
diffBuffer.write(
|
||||
'pointerCoords (actual length: ${actualList.length}, expected length: ${expectedList.length} ');
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < expectedList.length; i++) {
|
||||
final Map<String, dynamic> expected =
|
||||
expectedList[i].cast<String, dynamic>();
|
||||
final Map<String, dynamic> actual = actualList[i].cast<String, dynamic>();
|
||||
diffPointerCoords(expected, actual, i, diffBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
void diffPointerCoords(Map<String, dynamic> expected,
|
||||
Map<String, dynamic> actual, int pointerIdx, StringBuffer diffBuffer) {
|
||||
diffMaps(expected, actual, diffBuffer, messagePrefix: '[pointerCoord $pointerIdx] ');
|
||||
}
|
||||
|
||||
void diffMaps(
|
||||
Map<String, dynamic> expected,
|
||||
Map<String, dynamic> actual,
|
||||
StringBuffer diffBuffer, {
|
||||
List<String> excludeKeys = const <String>[],
|
||||
String messagePrefix = '',
|
||||
}) {
|
||||
const IterableEquality<String> eq = IterableEquality<String>();
|
||||
if (!eq.equals(expected.keys, actual.keys)) {
|
||||
diffBuffer.write(
|
||||
'${messagePrefix}keys (expected: ${expected.keys} actual: ${actual.keys} ');
|
||||
return;
|
||||
}
|
||||
for (final String key in expected.keys) {
|
||||
if (excludeKeys.contains(key))
|
||||
continue;
|
||||
if (doublesApproximatelyMatch(expected[key], actual[key]))
|
||||
continue;
|
||||
|
||||
if (expected[key] != actual[key]) {
|
||||
diffBuffer.write(
|
||||
'$messagePrefix$key (expected: ${expected[key]} actual: ${actual[key]}) ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int getActionMasked(int action) => action & 0xff;
|
||||
|
||||
int getPointerIdx(int action) => (action >> 8) & 0xff;
|
||||
|
||||
String getActionName(int actionMasked, int action) {
|
||||
const List<String> actionNames = <String>[
|
||||
'DOWN',
|
||||
'UP',
|
||||
'MOVE',
|
||||
'CANCEL',
|
||||
'OUTSIDE',
|
||||
'POINTER_DOWN',
|
||||
'POINTER_UP',
|
||||
'HOVER_MOVE',
|
||||
'SCROLL',
|
||||
'HOVER_ENTER',
|
||||
'HOVER_EXIT',
|
||||
'BUTTON_PRESS',
|
||||
'BUTTON_RELEASE',
|
||||
];
|
||||
if (actionMasked < actionNames.length)
|
||||
return '${actionNames[actionMasked]}($action)';
|
||||
else
|
||||
return 'ACTION_$actionMasked';
|
||||
}
|
||||
|
||||
bool doublesApproximatelyMatch(dynamic a, dynamic b) =>
|
||||
a is double && b is double && (a - b).abs() < kDoubleErrorMargin;
|
@ -0,0 +1,305 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_driver/driver_extension.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'android_platform_view.dart';
|
||||
import 'motion_event_diff.dart';
|
||||
import 'page.dart';
|
||||
|
||||
MethodChannel channel = const MethodChannel('android_views_integration');
|
||||
|
||||
const String kEventsFileName = 'touchEvents';
|
||||
|
||||
class MotionEventsPage extends PageWidget {
|
||||
const MotionEventsPage()
|
||||
: super('Motion Event Tests', const ValueKey<String>('MotionEventsListTile'));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MotionEventsBody();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps a flutter driver [DataHandler] with one that waits until a delegate is set.
|
||||
///
|
||||
/// This allows the driver test to call [FlutterDriver.requestData] before the handler was
|
||||
/// set by the app in which case the requestData call will only complete once the app is ready
|
||||
/// for it.
|
||||
class FutureDataHandler {
|
||||
final Completer<DataHandler> handlerCompleter = Completer<DataHandler>();
|
||||
|
||||
Future<String> handleMessage(String message) async {
|
||||
final DataHandler handler = await handlerCompleter.future;
|
||||
return handler(message);
|
||||
}
|
||||
}
|
||||
|
||||
FutureDataHandler driverDataHandler = FutureDataHandler();
|
||||
|
||||
class MotionEventsBody extends StatefulWidget {
|
||||
@override
|
||||
State createState() => MotionEventsBodyState();
|
||||
}
|
||||
|
||||
class MotionEventsBodyState extends State<MotionEventsBody> {
|
||||
static const int kEventsBufferSize = 1000;
|
||||
|
||||
MethodChannel viewChannel;
|
||||
|
||||
/// The list of motion events that were passed to the FlutterView.
|
||||
List<Map<String, dynamic>> flutterViewEvents = <Map<String, dynamic>>[];
|
||||
|
||||
/// The list of motion events that were passed to the embedded view.
|
||||
List<Map<String, dynamic>> embeddedViewEvents = <Map<String, dynamic>>[];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
height: 300.0,
|
||||
child: AndroidPlatformView(
|
||||
key: const ValueKey<String>('PlatformView'),
|
||||
viewType: 'simple_view',
|
||||
onPlatformViewCreated: onPlatformViewCreated,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemBuilder: buildEventTile,
|
||||
itemCount: flutterViewEvents.length,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: RaisedButton(
|
||||
child: const Text('RECORD'),
|
||||
onPressed: listenToFlutterViewEvents,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RaisedButton(
|
||||
child: const Text('CLEAR'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
flutterViewEvents.clear();
|
||||
embeddedViewEvents.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RaisedButton(
|
||||
child: const Text('SAVE'),
|
||||
onPressed: () {
|
||||
const StandardMessageCodec codec = StandardMessageCodec();
|
||||
saveRecordedEvents(
|
||||
codec.encodeMessage(flutterViewEvents), context);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RaisedButton(
|
||||
key: const ValueKey<String>('play'),
|
||||
child: const Text('PLAY FILE'),
|
||||
onPressed: () { playEventsFile(); },
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RaisedButton(
|
||||
key: const ValueKey<String>('back'),
|
||||
child: const Text('BACK'),
|
||||
onPressed: () { Navigator.pop(context); },
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> playEventsFile() async {
|
||||
const StandardMessageCodec codec = StandardMessageCodec();
|
||||
try {
|
||||
final ByteData data = await rootBundle.load('packages/assets_for_android_views/assets/touchEvents');
|
||||
final List<dynamic> unTypedRecordedEvents = codec.decodeMessage(data) as List<dynamic>;
|
||||
final List<Map<String, dynamic>> recordedEvents = unTypedRecordedEvents
|
||||
.cast<Map<dynamic, dynamic>>()
|
||||
.map<Map<String, dynamic>>((Map<dynamic, dynamic> e) =>e.cast<String, dynamic>())
|
||||
.toList();
|
||||
await viewChannel.invokeMethod<void>('pipeTouchEvents');
|
||||
print('replaying ${recordedEvents.length} motion events');
|
||||
for (final Map<String, dynamic> event in recordedEvents.reversed) {
|
||||
await channel.invokeMethod<void>('synthesizeEvent', event);
|
||||
}
|
||||
|
||||
await viewChannel.invokeMethod<void>('stopTouchEvents');
|
||||
|
||||
if (flutterViewEvents.length != embeddedViewEvents.length)
|
||||
return 'Synthesized ${flutterViewEvents.length} events but the embedded view received ${embeddedViewEvents.length} events';
|
||||
|
||||
final StringBuffer diff = StringBuffer();
|
||||
for (int i = 0; i < flutterViewEvents.length; ++i) {
|
||||
final String currentDiff = diffMotionEvents(flutterViewEvents[i], embeddedViewEvents[i]);
|
||||
if (currentDiff.isEmpty)
|
||||
continue;
|
||||
if (diff.isNotEmpty)
|
||||
diff.write(', ');
|
||||
diff.write(currentDiff);
|
||||
}
|
||||
return diff.toString();
|
||||
} catch(e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
channel.setMethodCallHandler(onMethodChannelCall);
|
||||
}
|
||||
|
||||
Future<void> saveRecordedEvents(ByteData data, BuildContext context) async {
|
||||
if (!await channel.invokeMethod<bool>('getStoragePermission')) {
|
||||
showMessage(
|
||||
context, 'External storage permissions are required to save events');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final Directory outDir = await getExternalStorageDirectory();
|
||||
// This test only runs on Android so we can assume path separator is '/'.
|
||||
final File file = File('${outDir.path}/$kEventsFileName');
|
||||
await file.writeAsBytes(data.buffer.asUint8List(0, data.lengthInBytes), flush: true);
|
||||
showMessage(context, 'Saved original events to ${file.path}');
|
||||
} catch (e) {
|
||||
showMessage(context, 'Failed saving ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
void showMessage(BuildContext context, String message) {
|
||||
Scaffold.of(context).showSnackBar(SnackBar(
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 3),
|
||||
));
|
||||
}
|
||||
|
||||
void onPlatformViewCreated(int id) {
|
||||
viewChannel = MethodChannel('simple_view/$id');
|
||||
viewChannel.setMethodCallHandler(onViewMethodChannelCall);
|
||||
driverDataHandler.handlerCompleter.complete(handleDriverMessage);
|
||||
}
|
||||
|
||||
void listenToFlutterViewEvents() {
|
||||
viewChannel.invokeMethod<void>('pipeTouchEvents');
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
viewChannel.invokeMethod<void>('stopTouchEvents');
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> handleDriverMessage(String message) async {
|
||||
switch (message) {
|
||||
case 'run test':
|
||||
return playEventsFile();
|
||||
}
|
||||
return 'unknown message: "$message"';
|
||||
}
|
||||
|
||||
Future<dynamic> onMethodChannelCall(MethodCall call) {
|
||||
switch (call.method) {
|
||||
case 'onTouch':
|
||||
final Map<dynamic, dynamic> map = call.arguments as Map<dynamic, dynamic>;
|
||||
flutterViewEvents.insert(0, map.cast<String, dynamic>());
|
||||
if (flutterViewEvents.length > kEventsBufferSize)
|
||||
flutterViewEvents.removeLast();
|
||||
setState(() {});
|
||||
break;
|
||||
}
|
||||
return Future<dynamic>.sync(null);
|
||||
}
|
||||
|
||||
Future<dynamic> onViewMethodChannelCall(MethodCall call) {
|
||||
switch (call.method) {
|
||||
case 'onTouch':
|
||||
final Map<dynamic, dynamic> map = call.arguments as Map<dynamic, dynamic>;
|
||||
embeddedViewEvents.insert(0, map.cast<String, dynamic>());
|
||||
if (embeddedViewEvents.length > kEventsBufferSize)
|
||||
embeddedViewEvents.removeLast();
|
||||
setState(() {});
|
||||
break;
|
||||
}
|
||||
return Future<dynamic>.sync(null);
|
||||
}
|
||||
|
||||
Widget buildEventTile(BuildContext context, int index) {
|
||||
if (embeddedViewEvents.length > index)
|
||||
return TouchEventDiff(
|
||||
flutterViewEvents[index], embeddedViewEvents[index]);
|
||||
return Text(
|
||||
'Unmatched event, action: ${flutterViewEvents[index]['action']}');
|
||||
}
|
||||
}
|
||||
|
||||
class TouchEventDiff extends StatelessWidget {
|
||||
const TouchEventDiff(this.originalEvent, this.synthesizedEvent);
|
||||
|
||||
final Map<String, dynamic> originalEvent;
|
||||
final Map<String, dynamic> synthesizedEvent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
Color color;
|
||||
final String diff = diffMotionEvents(originalEvent, synthesizedEvent);
|
||||
String msg;
|
||||
final int action = synthesizedEvent['action'] as int;
|
||||
final String actionName = getActionName(getActionMasked(action), action);
|
||||
if (diff.isEmpty) {
|
||||
color = Colors.green;
|
||||
msg = 'Matched event (action $actionName)';
|
||||
} else {
|
||||
color = Colors.red;
|
||||
msg = '[$actionName] $diff';
|
||||
}
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
print('expected:');
|
||||
prettyPrintEvent(originalEvent);
|
||||
print('\nactual:');
|
||||
prettyPrintEvent(synthesizedEvent);
|
||||
},
|
||||
child: Container(
|
||||
color: color,
|
||||
margin: const EdgeInsets.only(bottom: 2.0),
|
||||
child: Text(msg),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void prettyPrintEvent(Map<String, dynamic> event) {
|
||||
final StringBuffer buffer = StringBuffer();
|
||||
final int action = event['action'] as int;
|
||||
final int maskedAction = getActionMasked(action);
|
||||
final String actionName = getActionName(maskedAction, action);
|
||||
|
||||
buffer.write('$actionName ');
|
||||
if (maskedAction == 5 || maskedAction == 6) {
|
||||
buffer.write('pointer: ${getPointerIdx(action)} ');
|
||||
}
|
||||
|
||||
final List<Map<dynamic, dynamic>> coords = (event['pointerCoords'] as List<dynamic>).cast<Map<dynamic, dynamic>>();
|
||||
for (int i = 0; i < coords.length; i++) {
|
||||
buffer.write('p$i x: ${coords[i]['x']} y: ${coords[i]['y']}, pressure: ${coords[i]['pressure']} ');
|
||||
}
|
||||
print(buffer.toString());
|
||||
}
|
||||
}
|
@ -0,0 +1,156 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'android_platform_view.dart';
|
||||
import 'page.dart';
|
||||
|
||||
class NestedViewEventPage extends PageWidget {
|
||||
const NestedViewEventPage()
|
||||
: super('Nested View Event Tests', const ValueKey<String>('NestedViewEventTile'));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => NestedViewEventBody();
|
||||
}
|
||||
|
||||
class NestedViewEventBody extends StatefulWidget {
|
||||
@override
|
||||
State<NestedViewEventBody> createState() => NestedViewEventBodyState();
|
||||
}
|
||||
|
||||
enum _LastTestStatus {
|
||||
pending,
|
||||
success,
|
||||
error
|
||||
}
|
||||
|
||||
class NestedViewEventBodyState extends State<NestedViewEventBody> {
|
||||
|
||||
MethodChannel viewChannel;
|
||||
_LastTestStatus lastTestStatus = _LastTestStatus.pending;
|
||||
String lastError;
|
||||
int id;
|
||||
int nestedViewClickCount = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Nested view event'),
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: AndroidPlatformView(
|
||||
viewType: 'simple_view',
|
||||
onPlatformViewCreated: onPlatformViewCreated,
|
||||
),
|
||||
),
|
||||
if (lastTestStatus != _LastTestStatus.pending) _statusWidget(),
|
||||
if (viewChannel != null) ... <Widget>[
|
||||
RaisedButton(
|
||||
key: const ValueKey<String>('ShowAlertDialog'),
|
||||
child: const Text('SHOW ALERT DIALOG'),
|
||||
onPressed: onShowAlertDialogPressed,
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
RaisedButton(
|
||||
key: const ValueKey<String>('AddChildView'),
|
||||
child: const Text('ADD CHILD VIEW'),
|
||||
onPressed: onChildViewPressed,
|
||||
),
|
||||
RaisedButton(
|
||||
key: const ValueKey<String>('TapChildView'),
|
||||
child: const Text('TAP CHILD VIEW'),
|
||||
onPressed: onTapChildViewPressed,
|
||||
),
|
||||
if (nestedViewClickCount > 0)
|
||||
Text(
|
||||
'Click count: $nestedViewClickCount',
|
||||
key: const ValueKey<String>('NestedViewClickCount'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusWidget() {
|
||||
assert(lastTestStatus != _LastTestStatus.pending);
|
||||
final String message = lastTestStatus == _LastTestStatus.success ? 'Success' : lastError;
|
||||
return Container(
|
||||
color: lastTestStatus == _LastTestStatus.success ? Colors.green : Colors.red,
|
||||
child: Text(
|
||||
message,
|
||||
key: const ValueKey<String>('Status'),
|
||||
style: TextStyle(
|
||||
color: lastTestStatus == _LastTestStatus.error ? Colors.yellow : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onShowAlertDialogPressed() async {
|
||||
if (lastTestStatus != _LastTestStatus.pending) {
|
||||
setState(() {
|
||||
lastTestStatus = _LastTestStatus.pending;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await viewChannel.invokeMethod<void>('showAndHideAlertDialog');
|
||||
setState(() {
|
||||
lastTestStatus = _LastTestStatus.success;
|
||||
});
|
||||
} catch(e) {
|
||||
setState(() {
|
||||
lastTestStatus = _LastTestStatus.error;
|
||||
lastError = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onChildViewPressed() async {
|
||||
try {
|
||||
await viewChannel.invokeMethod<void>('addChildViewAndWaitForClick');
|
||||
setState(() {
|
||||
nestedViewClickCount++;
|
||||
});
|
||||
} catch(e) {
|
||||
setState(() {
|
||||
lastTestStatus = _LastTestStatus.error;
|
||||
lastError = '$e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onTapChildViewPressed() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Dispatch a tap event on the child view inside the platform view.
|
||||
//
|
||||
// Android mutates `MotionEvent` instances, so in this case *do not* dispatch
|
||||
// new instances as it won't cover the `MotionEventTracker` class in the embedding
|
||||
// which tracks events.
|
||||
//
|
||||
// See the issue this prevents: https://github.com/flutter/flutter/issues/61169
|
||||
await Process.run('input', const <String>['tap', '250', '550']);
|
||||
}
|
||||
|
||||
void onPlatformViewCreated(int id) {
|
||||
this.id = id;
|
||||
setState(() {
|
||||
viewChannel = MethodChannel('simple_view/$id');
|
||||
});
|
||||
}
|
||||
}
|
22
dev/integration_tests/hybrid_android_views/lib/page.dart
Normal file
22
dev/integration_tests/hybrid_android_views/lib/page.dart
Normal file
@ -0,0 +1,22 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// The base class of all the testing pages
|
||||
//
|
||||
/// A testing page has to override this in order to be put as one of the items in the main page.
|
||||
abstract class PageWidget extends StatelessWidget {
|
||||
const PageWidget(this.title, this.tileKey);
|
||||
|
||||
/// The title of the testing page
|
||||
///
|
||||
/// It will be shown on the main page as the text on the link which opens the page.
|
||||
final String title;
|
||||
|
||||
/// The key of the ListTile that navigates to the page.
|
||||
///
|
||||
/// Used by the integration test to navigate to the corresponding page.
|
||||
final ValueKey<String> tileKey;
|
||||
}
|
94
dev/integration_tests/hybrid_android_views/pubspec.yaml
Normal file
94
dev/integration_tests/hybrid_android_views/pubspec.yaml
Normal file
@ -0,0 +1,94 @@
|
||||
name: hybrid_platform_views
|
||||
description: An integration test for hybrid composition on Android
|
||||
version: 1.0.0+1
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_driver:
|
||||
sdk: flutter
|
||||
path_provider: 1.6.11
|
||||
collection: 1.15.0-nullsafety
|
||||
assets_for_android_views:
|
||||
git:
|
||||
url: https://github.com/flutter/goldens.git
|
||||
ref: c47f1308188dca65b3899228cac37f252ea8b411
|
||||
path: dev/integration_tests/assets_for_android_views
|
||||
|
||||
archive: 2.0.13 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
args: 1.6.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
async: 2.4.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
characters: 1.1.0-nullsafety # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
charcode: 1.1.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
convert: 2.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
crypto: 2.1.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
file: 5.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
intl: 0.16.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
json_rpc_2: 2.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
matcher: 0.12.8 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
meta: 1.3.0-nullsafety # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
path: 1.7.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
path_provider_linux: 0.0.1+2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
path_provider_macos: 0.0.4+3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
path_provider_platform_interface: 1.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
platform: 2.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
plugin_platform_interface: 1.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
process: 3.0.13 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
pub_semver: 1.4.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
source_span: 1.7.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
stack_trace: 1.9.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
stream_channel: 2.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
sync_http: 0.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
term_glyph: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
typed_data: 1.3.0-nullsafety # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
vector_math: 2.1.0-nullsafety # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
vm_service_client: 0.2.6+2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
web_socket_channel: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
webdriver: 2.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
xdg_directories: 0.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
test: 1.15.2
|
||||
|
||||
_fe_analyzer_shared: 5.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
analyzer: 0.39.12 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
boolean_selector: 2.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
clock: 1.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
coverage: 0.14.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
csslib: 0.16.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
fake_async: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
glob: 1.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
html: 0.14.0+3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
http: 0.12.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
http_multi_server: 2.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
http_parser: 3.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
io: 0.3.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
js: 0.6.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
logging: 0.11.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
mime: 0.9.6+3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
node_interop: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
node_io: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
node_preamble: 1.4.12 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
package_config: 1.9.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
pedantic: 1.9.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
pool: 1.4.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
shelf: 0.7.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
shelf_packages_handler: 2.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
shelf_static: 0.2.8 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
shelf_web_socket: 0.2.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
source_map_stack_trace: 2.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
source_maps: 0.10.9 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
string_scanner: 1.0.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
test_api: 0.2.17 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
test_core: 0.3.10 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
vm_service: 4.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
watcher: 0.9.7+15 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
webkit_inspection_protocol: 0.7.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
yaml: 2.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# PUBSPEC CHECKSUM: bd09
|
@ -0,0 +1,65 @@
|
||||
// Copyright 2014 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.
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter_driver/flutter_driver.dart';
|
||||
import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
|
||||
|
||||
Future<void> main() async {
|
||||
FlutterDriver driver;
|
||||
|
||||
setUpAll(() async {
|
||||
driver = await FlutterDriver.connect();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
driver.close();
|
||||
});
|
||||
|
||||
// Each test below must return back to the home page after finishing.
|
||||
|
||||
test('MotionEvent recomposition', () async {
|
||||
final SerializableFinder motionEventsListTile =
|
||||
find.byValueKey('MotionEventsListTile');
|
||||
await driver.tap(motionEventsListTile);
|
||||
await driver.waitFor(find.byValueKey('PlatformView'));
|
||||
final String errorMessage = await driver.requestData('run test');
|
||||
expect(errorMessage, '');
|
||||
final SerializableFinder backButton = find.byValueKey('back');
|
||||
await driver.tap(backButton);
|
||||
});
|
||||
|
||||
group('Nested View Event', ()
|
||||
{
|
||||
setUpAll(() async {
|
||||
final SerializableFinder wmListTile =
|
||||
find.byValueKey('NestedViewEventTile');
|
||||
await driver.tap(wmListTile);
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await driver.waitFor(find.pageBack());
|
||||
await driver.tap(find.pageBack());
|
||||
});
|
||||
|
||||
test('AlertDialog from platform view context', () async {
|
||||
final SerializableFinder showAlertDialog = find.byValueKey(
|
||||
'ShowAlertDialog');
|
||||
await driver.waitFor(showAlertDialog);
|
||||
await driver.tap(showAlertDialog);
|
||||
final String status = await driver.getText(find.byValueKey('Status'));
|
||||
expect(status, 'Success');
|
||||
});
|
||||
|
||||
test('Child view can handle touches', () async {
|
||||
final SerializableFinder addChildView = find.byValueKey('AddChildView');
|
||||
await driver.waitFor(addChildView);
|
||||
await driver.tap(addChildView);
|
||||
final SerializableFinder tapChildView = find.byValueKey('TapChildView');
|
||||
await driver.tap(tapChildView);
|
||||
final String nestedViewClickCount = await driver.getText(find.byValueKey('NestedViewClickCount'));
|
||||
expect(nestedViewClickCount, 'Click count: 1');
|
||||
});
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user