[espresso] Adds EspressoFlutter as a first-party plugin (#2369)
* Initial open source release of Espresso bindings for Flutter as a new first-party plugin, espresso.
diff --git a/packages/espresso/.gitignore b/packages/espresso/.gitignore
new file mode 100644
index 0000000..e9dc58d
--- /dev/null
+++ b/packages/espresso/.gitignore
@@ -0,0 +1,7 @@
+.DS_Store
+.dart_tool/
+
+.packages
+.pub/
+
+build/
diff --git a/packages/espresso/.metadata b/packages/espresso/.metadata
new file mode 100644
index 0000000..e6c63f5
--- /dev/null
+++ b/packages/espresso/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: 0190e40457d43e17bdfaf046dfa634cbc5bf28b9
+ channel: unknown
+
+project_type: plugin
diff --git a/packages/espresso/CHANGELOG.md b/packages/espresso/CHANGELOG.md
new file mode 100644
index 0000000..116f3aa
--- /dev/null
+++ b/packages/espresso/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.0.1
+
+* Initial open-source release of Espresso bindings for Flutter.
diff --git a/packages/espresso/LICENSE b/packages/espresso/LICENSE
new file mode 100644
index 0000000..0c382ce
--- /dev/null
+++ b/packages/espresso/LICENSE
@@ -0,0 +1,27 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// * Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above
+// copyright notice, this list of conditions and the following disclaimer
+// in the documentation and/or other materials provided with the
+// distribution.
+// * Neither the name of Google Inc. nor the names of its
+// contributors may be used to endorse or promote products derived from
+// this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/espresso/README.md b/packages/espresso/README.md
new file mode 100644
index 0000000..c0678e9
--- /dev/null
+++ b/packages/espresso/README.md
@@ -0,0 +1,125 @@
+# espresso
+
+Provides bindings for Espresso tests of Flutter Android apps.
+
+## Installation
+
+Add the `espresso` package as a `dev_dependency` in your app's pubspec.yaml. If you're testing the example app of a package, add it as a dev_dependency of the main package as well.
+
+Add ```android:usesCleartextTraffic="true"``` in the ```<application>``` in the AndroidManifest.xml
+of the Android app used for testing. It's best to put this in a debug or androidTest
+AndroidManifest.xml so that you don't ship it to end users. (See the example app of this package.)
+
+Add dependencies to your build.gradle:
+
+```groovy
+dependencies {
+ testImplementation 'junit:junit:4.12'
+ testImplementation "com.google.truth:truth:1.0"
+ androidTestImplementation 'androidx.test:runner:1.1.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
+
+ // Core library
+ api 'androidx.test:core:1.2.0'
+
+ // AndroidJUnitRunner and JUnit Rules
+ androidTestImplementation 'androidx.test:runner:1.1.0'
+ androidTestImplementation 'androidx.test:rules:1.1.0'
+
+ // Assertions
+ androidTestImplementation 'androidx.test.ext:junit:1.0.0'
+ androidTestImplementation 'androidx.test.ext:truth:1.0.0'
+ androidTestImplementation 'com.google.truth:truth:0.42'
+
+ // Espresso dependencies
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-web:3.1.0'
+ androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.1.0'
+
+ // The following Espresso dependency can be either "implementation"
+ // or "androidTestImplementation", depending on whether you want the
+ // dependency to appear on your APK's compile classpath or the test APK
+ // classpath.
+ androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.1.0'
+}
+```
+
+Create an `android/app/src/androidTest` folder and put a test file in a package-appropriate subfolder, e.g. `android/app/src/androidTest/java/com/example/MainActivityTest.java`:
+
+```java
+package com.example.espresso_example;
+
+import static androidx.test.espresso.flutter.EspressoFlutter.onFlutterWidget;
+import static androidx.test.espresso.flutter.action.FlutterActions.click;
+import static androidx.test.espresso.flutter.action.FlutterActions.syntheticClick;
+import static androidx.test.espresso.flutter.assertion.FlutterAssertions.matches;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.isDescendantOf;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withText;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withTooltip;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withType;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withValueKey;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.fail;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.espresso.flutter.EspressoFlutter.WidgetInteraction;
+import androidx.test.espresso.flutter.assertion.FlutterAssertions;
+import androidx.test.espresso.flutter.matcher.FlutterMatchers;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link EspressoFlutter}. */
+@RunWith(AndroidJUnit4.class)
+public class MainActivityTest {
+
+ @Before
+ public void setUp() throws Exception {
+ ActivityScenario.launch(MainActivity.class);
+ }
+
+ @Test
+ public void performClick() {
+ onFlutterWidget(withTooltip("Increment")).perform(click());
+ onFlutterWidget(withValueKey("CountText")).check(matches(withText("Button tapped 1 time.")));
+ }
+ ```
+
+You'll need to create a test app that enables the Flutter driver extension.
+You can put this in your test_driver/ folder, e.g. test_driver/example.dart.
+
+```dart
+import 'package:flutter_driver/driver_extension.dart';
+import '../lib/main.dart' as app;
+
+void main() {
+ enableFlutterDriverExtension();
+ app.main();
+}
+```
+
+The following command line command runs the test locally:
+
+```
+./gradlew app:connectedAndroidTest -Ptarget=`pwd`/../test_driver/example.dart
+```
+
+Espresso tests can also be run on [Firebase Test Lab](https://firebase.google.com/docs/test-lab):
+
+```
+./gradlew app:assembleAndroidTest
+./gradlew app:assembleDebug -Ptarget=<path_to_test>.dart
+gcloud auth activate-service-account --key-file=<PATH_TO_KEY_FILE>
+gcloud --quiet config set project <PROJECT_NAME>
+gcloud firebase test android run --type instrumentation \
+ --app build/app/outputs/apk/debug/app-debug.apk \
+ --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk\
+ --timeout 2m \
+ --results-bucket=<RESULTS_BUCKET> \
+ --results-dir=<RESULTS_DIRECTORY>
+```
+
diff --git a/packages/espresso/android/.gitignore b/packages/espresso/android/.gitignore
new file mode 100644
index 0000000..c6cbe56
--- /dev/null
+++ b/packages/espresso/android/.gitignore
@@ -0,0 +1,8 @@
+*.iml
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
+/build
+/captures
diff --git a/packages/espresso/android/build.gradle b/packages/espresso/android/build.gradle
new file mode 100644
index 0000000..4af1d3e
--- /dev/null
+++ b/packages/espresso/android/build.gradle
@@ -0,0 +1,74 @@
+group 'com.example.espresso'
+version '1.0'
+
+buildscript {
+ repositories {
+ google()
+ jcenter()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.5.0'
+ }
+}
+
+rootProject.allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
+
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 28
+
+ defaultConfig {
+ minSdkVersion 16
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ lintOptions {
+ disable 'InvalidPackage'
+ }
+}
+
+dependencies {
+ implementation 'com.google.guava:guava:28.1-android'
+ implementation 'com.squareup.okhttp3:okhttp:3.12.1'
+ implementation 'com.google.code.gson:gson:2.8.6'
+ androidTestImplementation 'org.hamcrest:hamcrest:2.2'
+
+ testImplementation 'junit:junit:4.12'
+ testImplementation "com.google.truth:truth:1.0"
+ api 'androidx.test:runner:1.1.1'
+ api 'androidx.test.espresso:espresso-core:3.1.1'
+
+ // Core library
+ api 'androidx.test:core:1.0.0'
+
+ // AndroidJUnitRunner and JUnit Rules
+ api 'androidx.test:runner:1.1.0'
+ api 'androidx.test:rules:1.1.0'
+
+ // Assertions
+ api 'androidx.test.ext:junit:1.0.0'
+ api 'androidx.test.ext:truth:1.0.0'
+ api 'com.google.truth:truth:0.42'
+
+ // Espresso dependencies
+ api 'androidx.test.espresso:espresso-core:3.1.0'
+ api 'androidx.test.espresso:espresso-contrib:3.1.0'
+ api 'androidx.test.espresso:espresso-intents:3.1.0'
+ api 'androidx.test.espresso:espresso-accessibility:3.1.0'
+ api 'androidx.test.espresso:espresso-web:3.1.0'
+ api 'androidx.test.espresso.idling:idling-concurrent:3.1.0'
+
+ // The following Espresso dependency can be either "implementation"
+ // or "androidTestImplementation", depending on whether you want the
+ // dependency to appear on your APK's compile classpath or the test APK
+ // classpath.
+ api 'androidx.test.espresso:espresso-idling-resource:3.1.0'
+}
+
+
diff --git a/packages/espresso/android/gradle.properties b/packages/espresso/android/gradle.properties
new file mode 100644
index 0000000..38c8d45
--- /dev/null
+++ b/packages/espresso/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx1536M
+android.enableR8=true
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/packages/espresso/android/gradle/wrapper/gradle-wrapper.properties b/packages/espresso/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..4751774
--- /dev/null
+++ b/packages/espresso/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Nov 26 13:04:21 PST 2019
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
diff --git a/packages/espresso/android/settings.gradle b/packages/espresso/android/settings.gradle
new file mode 100644
index 0000000..46643c1
--- /dev/null
+++ b/packages/espresso/android/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'espresso'
diff --git a/packages/espresso/android/src/main/AndroidManifest.xml b/packages/espresso/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a70b4d1
--- /dev/null
+++ b/packages/espresso/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.espresso">
+</manifest>
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/EspressoFlutter.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/EspressoFlutter.java
new file mode 100644
index 0000000..106436f
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/EspressoFlutter.java
@@ -0,0 +1,198 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter;
+
+import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.flutter.common.Constants.DEFAULT_INTERACTION_TIMEOUT;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.isFlutterView;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.hamcrest.Matchers.any;
+
+import android.util.Log;
+import android.view.View;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.flutter.action.FlutterViewAction;
+import androidx.test.espresso.flutter.action.WidgetInfoFetcher;
+import androidx.test.espresso.flutter.api.FlutterAction;
+import androidx.test.espresso.flutter.api.WidgetAction;
+import androidx.test.espresso.flutter.api.WidgetAssertion;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.assertion.FlutterViewAssertion;
+import androidx.test.espresso.flutter.common.Duration;
+import androidx.test.espresso.flutter.exception.NoMatchingWidgetException;
+import androidx.test.espresso.flutter.internal.idgenerator.IdGenerator;
+import androidx.test.espresso.flutter.internal.idgenerator.IdGenerators;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nonnull;
+import okhttp3.OkHttpClient;
+import org.hamcrest.Matcher;
+
+/** Entry point to the Espresso testing APIs on Flutter. */
+public final class EspressoFlutter {
+
+ private static final String TAG = EspressoFlutter.class.getSimpleName();
+
+ private static final OkHttpClient okHttpClient;
+ private static final IdGenerator<Integer> idGenerator;
+ private static final ExecutorService taskExecutor;
+
+ static {
+ okHttpClient = new OkHttpClient();
+ idGenerator = IdGenerators.newIntegerIdGenerator();
+ taskExecutor = Executors.newCachedThreadPool();
+ }
+
+ /**
+ * Creates a {@link WidgetInteraction} for the Flutter widget matched by the given {@code
+ * widgetMatcher}, which is an entry point to perform actions or asserts.
+ *
+ * @param widgetMatcher the matcher used to uniquely match a Flutter widget on the screen.
+ */
+ public static WidgetInteraction onFlutterWidget(@Nonnull WidgetMatcher widgetMatcher) {
+ return new WidgetInteraction(isFlutterView(), widgetMatcher);
+ }
+
+ /**
+ * Provides fluent testing APIs for test authors to perform actions or asserts on Flutter widgets,
+ * similar to {@code ViewInteraction} and {@code WebInteraction}.
+ */
+ public static final class WidgetInteraction {
+
+ /**
+ * Adds a little delay to the interaction timeout so that we make sure not to time out before
+ * the action or assert does.
+ */
+ private static final Duration INTERACTION_TIMEOUT_DELAY = new Duration(1, TimeUnit.SECONDS);
+
+ private final Matcher<View> flutterViewMatcher;
+ private final WidgetMatcher widgetMatcher;
+ private final Duration timeout;
+
+ private WidgetInteraction(Matcher<View> flutterViewMatcher, WidgetMatcher widgetMatcher) {
+ this(
+ flutterViewMatcher,
+ widgetMatcher,
+ DEFAULT_INTERACTION_TIMEOUT.plus(INTERACTION_TIMEOUT_DELAY));
+ }
+
+ private WidgetInteraction(
+ Matcher<View> flutterViewMatcher, WidgetMatcher widgetMatcher, Duration timeout) {
+ this.flutterViewMatcher = checkNotNull(flutterViewMatcher);
+ this.widgetMatcher = checkNotNull(widgetMatcher);
+ this.timeout = checkNotNull(timeout);
+ }
+
+ /**
+ * Executes the given action(s) with synchronization guarantees: Espresso ensures Flutter's in
+ * an idle state before interacting with the Flutter UI.
+ *
+ * <p>If more than one action is provided, actions are executed in the order provided.
+ *
+ * @param widgetActions one or more actions that shall be performed. Cannot be {@code null}.
+ * @return this interaction for further perform/verification calls.
+ */
+ public WidgetInteraction perform(@Nonnull final WidgetAction... widgetActions) {
+ checkNotNull(widgetActions);
+ for (WidgetAction widgetAction : widgetActions) {
+ // If any error occurred, an unchecked exception will be thrown that stops execution of
+ // following actions.
+ performInternal(widgetAction);
+ }
+ return this;
+ }
+
+ /**
+ * Evaluates the given widget assertion.
+ *
+ * @param assertion a widget assertion that shall be made on the matched Flutter widget. Cannot
+ * be {@code null}.
+ */
+ public WidgetInteraction check(@Nonnull WidgetAssertion assertion) {
+ checkNotNull(
+ assertion,
+ "Assertion cannot be null. You must specify an assertion on the matched Flutter widget.");
+ WidgetInfo widgetInfo = performInternal(new WidgetInfoFetcher());
+ if (widgetInfo == null) {
+ Log.w(TAG, String.format("Widget info that matches %s is null.", widgetMatcher));
+ throw new NoMatchingWidgetException(
+ String.format("Widget info that matches %s is null.", widgetMatcher));
+ }
+ FlutterViewAssertion flutterViewAssertion = new FlutterViewAssertion(assertion, widgetInfo);
+ onView(flutterViewMatcher).check(flutterViewAssertion);
+ return this;
+ }
+
+ private <T> T performInternal(FlutterAction<T> flutterAction) {
+ checkNotNull(
+ flutterAction,
+ "The action cannot be null. You must specify an action to perform on the matched"
+ + " Flutter widget.");
+ FlutterViewAction<T> flutterViewAction =
+ new FlutterViewAction(
+ widgetMatcher, flutterAction, okHttpClient, idGenerator, taskExecutor);
+ onView(flutterViewMatcher).perform(flutterViewAction);
+ T result;
+ try {
+ if (timeout != null && timeout.getQuantity() > 0) {
+ result = flutterViewAction.waitUntilCompleted(timeout.getQuantity(), timeout.getUnit());
+ } else {
+ result = flutterViewAction.waitUntilCompleted();
+ }
+ return result;
+ } catch (ExecutionException e) {
+ propagateException(e.getCause());
+ } catch (InterruptedException | TimeoutException | RuntimeException e) {
+ propagateException(e);
+ }
+ return null;
+ }
+
+ /**
+ * Propagates exception through #onView so that it get a chance to be handled by the registered
+ * {@code FailureHandler}.
+ */
+ private void propagateException(Throwable t) {
+ onView(flutterViewMatcher).perform(new ExceptionPropagator(t));
+ }
+
+ /**
+ * An exception wrapper that propagates an exception through {@code #onView}, so that it can be
+ * handled by the registered {@code FailureHandler} for the underlying {@code ViewInteraction}.
+ */
+ static class ExceptionPropagator implements ViewAction {
+ private final RuntimeException exception;
+
+ public ExceptionPropagator(RuntimeException exception) {
+ this.exception = checkNotNull(exception);
+ }
+
+ public ExceptionPropagator(Throwable t) {
+ this(new RuntimeException(t));
+ }
+
+ @Override
+ public String getDescription() {
+ return "Propagate: " + exception;
+ }
+
+ @Override
+ public void perform(UiController uiController, View view) {
+ throw exception;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public Matcher<View> getConstraints() {
+ return any(View.class);
+ }
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ActionUtil.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ActionUtil.java
new file mode 100644
index 0000000..7dcb05b
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ActionUtil.java
@@ -0,0 +1,114 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import android.os.Looper;
+import androidx.test.espresso.IdlingRegistry;
+import androidx.test.espresso.IdlingResource;
+import androidx.test.espresso.UiController;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+
+/** Utils for the Flutter actions. */
+final class ActionUtil {
+
+ /**
+ * Loops the main thread until the given future task has been done. Users could use this method to
+ * "synchronize" between the main thread and {@code Future} instances running on its own thread
+ * (e.g. methods of the {@code FlutterTestingProtocol}), without blocking the main thread.
+ *
+ * <p>Usage:
+ *
+ * <pre>{@code
+ * Future<T> fooFuture = flutterTestingProtocol.callFoo();
+ * T fooResult = loopUntilCompletion("fooTask", androidUiController, fooFuture, executor);
+ * // Then consumes the fooResult on main thread.
+ * }</pre>
+ *
+ * @param taskName the name that shall be used when registering the task as an {@link
+ * IdlingResource}. Espresso ignores {@link IdlingResource} with the same name, so always uses
+ * a unique name if you don't want Espresso to ignore your task.
+ * @param androidUiController the controller to use to interact with the Android UI.
+ * @param futureTask the future task that main thread should wait for a completion signal.
+ * @param executor the executor to use for running async tasks within the method.
+ * @param <T> the return value type.
+ * @return the result of the future task.
+ * @throws ExecutionException if any error occurs during executing the future task.
+ * @throws InterruptedException when any internal thread is interrupted.
+ */
+ public static <T> T loopUntilCompletion(
+ String taskName,
+ UiController androidUiController,
+ Future<T> futureTask,
+ ExecutorService executor)
+ throws ExecutionException, InterruptedException {
+
+ checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!");
+
+ FutureIdlingResource<T> idlingResourceFuture = new FutureIdlingResource<>(taskName, futureTask);
+ IdlingRegistry.getInstance().register(idlingResourceFuture);
+ try {
+ // It's fine to ignore this {@code Future} handler, since {@code idlingResourceFuture} should
+ // give us the result/error any way.
+ @SuppressWarnings("unused")
+ Future<?> possiblyIgnoredError = executor.submit(idlingResourceFuture);
+ androidUiController.loopMainThreadUntilIdle();
+ checkState(idlingResourceFuture.isDone(), "Future task signaled - but it wasn't done.");
+ return idlingResourceFuture.get();
+ } finally {
+ IdlingRegistry.getInstance().unregister(idlingResourceFuture);
+ }
+ }
+
+ /**
+ * An {@code IdlingResource} implementation that takes in a {@code Future}, and sends the idle
+ * signal to the main thread when the given {@code Future} is done.
+ *
+ * @param <T> the return value type of this {@code FutureTask}.
+ */
+ private static class FutureIdlingResource<T> extends FutureTask<T> implements IdlingResource {
+
+ private final String taskName;
+ // Written from main thread, read from any thread.
+ private volatile ResourceCallback resourceCallback;
+
+ public FutureIdlingResource(String taskName, final Future<T> future) {
+ super(
+ new Callable<T>() {
+ @Override
+ public T call() throws Exception {
+ return future.get();
+ }
+ });
+ this.taskName = checkNotNull(taskName);
+ }
+
+ @Override
+ public String getName() {
+ return taskName;
+ }
+
+ @Override
+ public void done() {
+ resourceCallback.onTransitionToIdle();
+ }
+
+ @Override
+ public boolean isIdleNow() {
+ return isDone();
+ }
+
+ @Override
+ public void registerIdleTransitionCallback(ResourceCallback callback) {
+ this.resourceCallback = callback;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ClickAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ClickAction.java
new file mode 100644
index 0000000..5da56fd
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ClickAction.java
@@ -0,0 +1,83 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import static androidx.test.espresso.flutter.action.ActionUtil.loopUntilCompletion;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import android.graphics.Rect;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.action.GeneralClickAction;
+import androidx.test.espresso.action.Press;
+import androidx.test.espresso.action.Tap;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.WidgetAction;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/** A click on the given Flutter widget by issuing gesture events to the Android system. */
+public final class ClickAction implements WidgetAction {
+
+ private static final String GET_LOCAL_RECT_TASK_NAME = "ClickAction#getLocalRect";
+
+ private final ExecutorService executor;
+
+ public ClickAction(@Nonnull ExecutorService executor) {
+ this.executor = checkNotNull(executor);
+ }
+
+ @Override
+ public ListenableFuture<Void> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController) {
+
+ try {
+ Future<Rect> widgetRectFuture = flutterTestingProtocol.getLocalRect(targetWidget);
+ Rect widgetRectInDp =
+ loopUntilCompletion(
+ GET_LOCAL_RECT_TASK_NAME, androidUiController, widgetRectFuture, executor);
+ WidgetCoordinatesCalculator coordinatesCalculator =
+ new WidgetCoordinatesCalculator(widgetRectInDp);
+ // Clicks at the center of the Flutter widget (with no visibility check), with all the default
+ // settings of a native View's click action.
+ ViewAction clickAction =
+ new GeneralClickAction(
+ Tap.SINGLE,
+ coordinatesCalculator,
+ Press.FINGER,
+ InputDevice.SOURCE_UNKNOWN,
+ MotionEvent.BUTTON_PRIMARY);
+ clickAction.perform(androidUiController, flutterView);
+
+ // Espresso will wait for the main thread to finish, so nothing else to wait for in the
+ // testing thread.
+ return immediateFuture(null);
+ } catch (InterruptedException ie) {
+ return immediateFailedFuture(ie);
+ } catch (ExecutionException ee) {
+ return immediateFailedFuture(ee.getCause());
+ } finally {
+ androidUiController.loopMainThreadUntilIdle();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "click";
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterActions.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterActions.java
new file mode 100644
index 0000000..258daf6
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterActions.java
@@ -0,0 +1,74 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import androidx.test.espresso.flutter.api.WidgetAction;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import javax.annotation.Nonnull;
+
+/** A collection of actions that can be performed on {@code FlutterView}s or Flutter widgets. */
+public final class FlutterActions {
+
+ private static final ExecutorService taskExecutor = Executors.newCachedThreadPool();
+
+ // Do not initialize.
+ private FlutterActions() {}
+
+ /**
+ * Returns a click action that can be performed on a Flutter widget.
+ *
+ * <p>The current implementation simply clicks at the center of the widget (with no visibility
+ * checks yet). Internally, it calculates the coordinates to click on screen based on the position
+ * of the matched Flutter widget and also its outer Flutter view, and injects gesture events to
+ * the Android system to mimic a human's click.
+ *
+ * <p>Try {@link #syntheticClick()} only when this action cannot handle your case properly, e.g.
+ * Flutter's internal state (only accessible within Flutter) affects how the action should
+ * performed.
+ */
+ public static WidgetAction click() {
+ return new ClickAction(taskExecutor);
+ }
+
+ /**
+ * Returns a synthetic click action that can be performed on a Flutter widget.
+ *
+ * <p>Note, this is not a real click gesture event issued from Android system. Espresso delegates
+ * to Flutter engine to perform the action.
+ *
+ * <p>Always prefer {@link #click()} as it exercises the entire Flutter stack and your Flutter app
+ * by directly injecting key events to the Android system. Uses this {@link #syntheticClick()}
+ * only when there are special cases that {@link #click()} cannot handle properly.
+ */
+ public static WidgetAction syntheticClick() {
+ return new SyntheticClickAction();
+ }
+
+ /**
+ * Returns an action that focuses on the widget (by clicking on it) and types the provided string
+ * into the widget. Appending a \n to the end of the string translates to a ENTER key event. Note:
+ * this method performs a tap on the widget before typing to force the widget into focus, if the
+ * widget already contains text this tap may place the cursor at an arbitrary position within the
+ * text.
+ *
+ * <p>The Flutter widget must support input methods.
+ *
+ * @param stringToBeTyped the text String that shall be input to the matched widget. Cannot be
+ * {@code null}.
+ */
+ public static WidgetAction typeText(@Nonnull String stringToBeTyped) {
+ return new FlutterTypeTextAction(stringToBeTyped, taskExecutor);
+ }
+
+ /**
+ * Returns an action that scrolls to the widget.
+ *
+ * <p>The widget must be a descendant of a scrollable widget like SingleChildScrollView.
+ */
+ public static WidgetAction scrollTo() {
+ return new FlutterScrollToAction();
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterScrollToAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterScrollToAction.java
new file mode 100644
index 0000000..b97252a
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterScrollToAction.java
@@ -0,0 +1,51 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import android.view.View;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import androidx.test.espresso.flutter.api.WidgetAction;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import com.google.gson.annotations.Expose;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * An action that scrolls the Scrollable ancestor of the widget until the widget is completely
+ * visible.
+ */
+public final class FlutterScrollToAction implements WidgetAction {
+
+ @Override
+ public Future<Void> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController) {
+ return flutterTestingProtocol.perform(targetWidget, new ScrollIntoViewAction());
+ }
+
+ @Override
+ public String toString() {
+ return "scrollTo";
+ }
+
+ static class ScrollIntoViewAction extends SyntheticAction {
+
+ @Expose private final double alignment;
+
+ public ScrollIntoViewAction() {
+ this(0.0);
+ }
+
+ public ScrollIntoViewAction(double alignment) {
+ super("scrollIntoView");
+ this.alignment = alignment;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterTypeTextAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterTypeTextAction.java
new file mode 100644
index 0000000..bb62250
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterTypeTextAction.java
@@ -0,0 +1,185 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import static androidx.test.espresso.flutter.action.ActionUtil.loopUntilCompletion;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.util.concurrent.Futures.allAsList;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.action.GeneralClickAction;
+import androidx.test.espresso.action.Press;
+import androidx.test.espresso.action.Tap;
+import androidx.test.espresso.action.TypeTextAction;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import androidx.test.espresso.flutter.api.WidgetAction;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import com.google.common.util.concurrent.JdkFutureAdapters;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.gson.annotations.Expose;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/** An action that types text on a Flutter widget. */
+public final class FlutterTypeTextAction implements WidgetAction {
+
+ private static final String TAG = FlutterTypeTextAction.class.getSimpleName();
+
+ private static final String GET_LOCAL_RECT_TASK_NAME = "FlutterTypeTextAction#getLocalRect";
+ private static final String FLUTTER_IDLE_TASK_NAME = "FlutterTypeTextAction#flutterIsIdle";
+
+ private final String stringToBeTyped;
+ private final boolean tapToFocus;
+ private final ExecutorService executor;
+
+ /**
+ * Constructs with the given input string. If the string is empty it results in no-op (nothing is
+ * typed). By default this action sends a tap event to the center of the widget to attain focus
+ * before typing.
+ *
+ * @param stringToBeTyped String To be typed in.
+ */
+ FlutterTypeTextAction(@Nonnull String stringToBeTyped, @Nonnull ExecutorService executor) {
+ this(stringToBeTyped, executor, true);
+ }
+
+ /**
+ * Constructs with the given input string. If the string is empty it results in no-op (nothing is
+ * typed). By default this action sends a tap event to the center of the widget to attain focus
+ * before typing.
+ *
+ * @param stringToBeTyped String To be typed in.
+ * @param tapToFocus indicates whether a tap should be sent to the underlying widget before
+ * typing.
+ */
+ FlutterTypeTextAction(
+ @Nonnull String stringToBeTyped, @Nonnull ExecutorService executor, boolean tapToFocus) {
+ this.stringToBeTyped = checkNotNull(stringToBeTyped, "The text to type in cannot be null.");
+ this.executor = checkNotNull(executor);
+ this.tapToFocus = tapToFocus;
+ }
+
+ @Override
+ public ListenableFuture<Void> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController) {
+
+ // No-op if string is empty.
+ if (stringToBeTyped.length() == 0) {
+ Log.w(TAG, "Text string is empty resulting in no-op (nothing is typed).");
+ return immediateFuture(null);
+ }
+
+ try {
+ ListenableFuture<Void> setTextEntryEmulationFuture =
+ JdkFutureAdapters.listenInPoolThread(
+ flutterTestingProtocol.perform(null, new SetTextEntryEmulationAction(false)));
+ ListenableFuture<Rect> widgetRectFuture =
+ JdkFutureAdapters.listenInPoolThread(flutterTestingProtocol.getLocalRect(targetWidget));
+ // Waits until both Futures return and then proceeds.
+ Rect widgetRectInDp =
+ (Rect)
+ loopUntilCompletion(
+ GET_LOCAL_RECT_TASK_NAME,
+ androidUiController,
+ allAsList(widgetRectFuture, setTextEntryEmulationFuture),
+ executor)
+ .get(0);
+
+ // Clicks at the center of the Flutter widget (with no visibility check).
+ //
+ // Calls the click action separately so we get a chance to ensure Flutter is idle before
+ // typing text.
+ WidgetCoordinatesCalculator coordinatesCalculator =
+ new WidgetCoordinatesCalculator(widgetRectInDp);
+ if (tapToFocus) {
+ GeneralClickAction clickAction =
+ new GeneralClickAction(
+ Tap.SINGLE,
+ coordinatesCalculator,
+ Press.FINGER,
+ InputDevice.SOURCE_UNKNOWN,
+ MotionEvent.BUTTON_PRIMARY);
+ clickAction.perform(androidUiController, flutterView);
+ loopUntilCompletion(
+ FLUTTER_IDLE_TASK_NAME,
+ androidUiController,
+ flutterTestingProtocol.waitUntilIdle(),
+ executor);
+ }
+
+ // Then types in text.
+ ViewAction typeTextAction = new TypeTextAction(stringToBeTyped, false);
+ typeTextAction.perform(androidUiController, flutterView);
+
+ // Espresso will wait for the main thread to finish, so nothing else to wait for in the
+ // testing thread.
+ return immediateFuture(null);
+ } catch (InterruptedException ie) {
+ return immediateFailedFuture(ie);
+ } catch (ExecutionException ee) {
+ return immediateFailedFuture(ee.getCause());
+ } finally {
+ androidUiController.loopMainThreadUntilIdle();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return String.format(Locale.ROOT, "type text(%s)", stringToBeTyped);
+ }
+
+ /**
+ * The {@link SyntheticAction} that configures text entry emulation.
+ *
+ * <p>If the text entry emulation is enabled, the operating system's configured keyboard will not
+ * be invoked when the widget is focused. Explicitly disables the text entry emulation when text
+ * input is supposed to be sent using the system's keyboard.
+ *
+ * <p>By default, the text entry emulation is enabled in the Flutter testing protocol.
+ */
+ private static final class SetTextEntryEmulationAction extends SyntheticAction {
+
+ @Expose private final boolean enabled;
+
+ /**
+ * Constructs with the given text entry emulation setting.
+ *
+ * @param enabled whether the text entry emulation is enabled. When {@code enabled} is {@code
+ * true}, the system's configured keyboard will not be invoked when the widget is focused.
+ */
+ public SetTextEntryEmulationAction(boolean enabled) {
+ super("set_text_entry_emulation");
+ this.enabled = enabled;
+ }
+
+ /**
+ * Constructs with the given text entry emulation setting and also a timeout setting for this
+ * action.
+ *
+ * @param enabled whether the text entry emulation is enabled. When {@code enabled} is {@code
+ * true}, the system's configured keyboard will not be invoked when the widget is focused.
+ * @param timeOutInMillis the timeout setting of this action.
+ */
+ public SetTextEntryEmulationAction(boolean enabled, long timeOutInMillis) {
+ super("set_text_entry_emulation", timeOutInMillis);
+ this.enabled = enabled;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterViewAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterViewAction.java
new file mode 100644
index 0000000..7864b43
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterViewAction.java
@@ -0,0 +1,224 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import static androidx.test.espresso.flutter.action.ActionUtil.loopUntilCompletion;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.isFlutterView;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.util.concurrent.Futures.transformAsync;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.os.Looper;
+import android.view.View;
+import androidx.test.annotation.Beta;
+import androidx.test.espresso.IdlingRegistry;
+import androidx.test.espresso.IdlingResource;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.flutter.api.FlutterAction;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.internal.idgenerator.IdGenerator;
+import androidx.test.espresso.flutter.internal.jsonrpc.JsonRpcClient;
+import androidx.test.espresso.flutter.internal.protocol.impl.DartVmService;
+import androidx.test.espresso.flutter.internal.protocol.impl.DartVmServiceUtil;
+import androidx.test.espresso.flutter.internal.protocol.impl.FlutterProtocolException;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.JdkFutureAdapters;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import io.flutter.embedding.android.FlutterView;
+import io.flutter.view.FlutterNativeView;
+import java.net.URI;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import okhttp3.OkHttpClient;
+import org.hamcrest.Matcher;
+
+/**
+ * A {@code ViewAction} which performs an action on the given {@code FlutterView}.
+ *
+ * <p>This class acts as a bridge to perform {@code WidgetAction} on a Flutter widget on the given
+ * {@code FlutterView}.
+ */
+@Beta
+public final class FlutterViewAction<T> implements ViewAction {
+
+ private static final String FLUTTER_IDLE_TASK_NAME = "flutterIdlingResource";
+
+ private final SettableFuture<T> resultFuture = SettableFuture.create();
+ private final WidgetMatcher widgetMatcher;
+ private final FlutterAction<T> widgetAction;
+ private final OkHttpClient webSocketClient;
+ private final IdGenerator<Integer> messageIdGenerator;
+ private final ExecutorService taskExecutor;
+
+ /**
+ * Constructs an instance based on the given params.
+ *
+ * @param widgetMatcher the matcher that uniquely matches a widget on the {@code FlutterView}.
+ * Could be {@code null} if this is a universal action that doesn't apply to any specific
+ * widget.
+ * @param widgetAction the action to be performed on the matched Flutter widget.
+ * @param webSocketClient the WebSocket client that shall be used in the {@code
+ * FlutterTestingProtocol}.
+ * @param messageIdGenerator an ID generator that shall be used in the {@code
+ * FlutterTestingProtocol}.
+ * @param taskExecutor the task executor that shall be used in the {@code WidgetAction}.
+ */
+ public FlutterViewAction(
+ WidgetMatcher widgetMatcher,
+ FlutterAction<T> widgetAction,
+ OkHttpClient webSocketClient,
+ IdGenerator<Integer> messageIdGenerator,
+ ExecutorService taskExecutor) {
+ this.widgetMatcher = widgetMatcher;
+ this.widgetAction = checkNotNull(widgetAction);
+ this.webSocketClient = checkNotNull(webSocketClient);
+ this.messageIdGenerator = checkNotNull(messageIdGenerator);
+ this.taskExecutor = checkNotNull(taskExecutor);
+ }
+
+ @Override
+ public Matcher<View> getConstraints() {
+ return isFlutterView();
+ }
+
+ @Override
+ public String getDescription() {
+ return String.format(
+ "Perform a %s action on the Flutter widget matched %s.", widgetAction, widgetMatcher);
+ }
+
+ @Override
+ public void perform(UiController uiController, View flutterView) {
+ // There could be a gap between when the Flutter view is available in the view hierarchy and the
+ // engine & Dart isolates are actually up and running. Check whether the first frame has been
+ // rendered before proceeding in an unblocking way.
+ loopUntilFlutterViewRendered(flutterView, uiController);
+ // The url {@code FlutterNativeView} returns is the http url that the Dart VM Observatory http
+ // server serves at. Need to convert to the one that the WebSocket uses.
+ URI dartVmServiceProtocolUrl =
+ DartVmServiceUtil.getServiceProtocolUri(FlutterNativeView.getObservatoryUri());
+ String isolateId = DartVmServiceUtil.getDartIsolateId(flutterView);
+ final FlutterTestingProtocol flutterTestingProtocol =
+ new DartVmService(
+ isolateId,
+ new JsonRpcClient(webSocketClient, dartVmServiceProtocolUrl),
+ messageIdGenerator,
+ taskExecutor);
+
+ try {
+ // First checks the testing protocol is ready for use and then waits until the Flutter app is
+ // idle before executing the action.
+ ListenableFuture<Void> testingProtocolReadyFuture =
+ JdkFutureAdapters.listenInPoolThread(flutterTestingProtocol.connect());
+ AsyncFunction<Void, Void> flutterIdleFunc =
+ new AsyncFunction<Void, Void>() {
+ public ListenableFuture<Void> apply(Void readyResult) {
+ return JdkFutureAdapters.listenInPoolThread(flutterTestingProtocol.waitUntilIdle());
+ }
+ };
+ ListenableFuture<Void> flutterIdleFuture =
+ transformAsync(testingProtocolReadyFuture, flutterIdleFunc, taskExecutor);
+ loopUntilCompletion(FLUTTER_IDLE_TASK_NAME, uiController, flutterIdleFuture, taskExecutor);
+ perform(flutterView, flutterTestingProtocol, uiController);
+ } catch (ExecutionException ee) {
+ resultFuture.setException(ee.getCause());
+ } catch (InterruptedException ie) {
+ resultFuture.setException(ie);
+ }
+ }
+
+ @VisibleForTesting
+ void perform(
+ View flutterView, FlutterTestingProtocol flutterTestingProtocol, UiController uiController) {
+ final ListenableFuture<T> actionResultFuture =
+ JdkFutureAdapters.listenInPoolThread(
+ widgetAction.perform(widgetMatcher, flutterView, flutterTestingProtocol, uiController));
+ actionResultFuture.addListener(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ resultFuture.set(actionResultFuture.get());
+ } catch (ExecutionException | InterruptedException e) {
+ resultFuture.setException(e);
+ }
+ }
+ },
+ directExecutor());
+ }
+
+ /** Blocks until this action has completed execution. */
+ public T waitUntilCompleted() throws ExecutionException, InterruptedException {
+ checkState(Looper.myLooper() != Looper.getMainLooper(), "On main thread!");
+ return resultFuture.get();
+ }
+
+ /** Blocks until this action has completed execution with a configurable timeout. */
+ public T waitUntilCompleted(long timeout, TimeUnit unit)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ checkState(Looper.myLooper() != Looper.getMainLooper(), "On main thread!");
+ return resultFuture.get(timeout, unit);
+ }
+
+ private static void loopUntilFlutterViewRendered(View flutterView, UiController uiController) {
+ FlutterViewRenderedIdlingResource idlingResource =
+ new FlutterViewRenderedIdlingResource(flutterView);
+ try {
+ IdlingRegistry.getInstance().register(idlingResource);
+ uiController.loopMainThreadUntilIdle();
+ } finally {
+ IdlingRegistry.getInstance().unregister(idlingResource);
+ }
+ }
+
+ /**
+ * An {@link IdlingResource} that checks whether the Flutter view's first frame has been rendered
+ * in an unblocking way.
+ */
+ static final class FlutterViewRenderedIdlingResource implements IdlingResource {
+
+ private final View flutterView;
+ // Written from main thread, read from any thread.
+ private volatile ResourceCallback resourceCallback;
+
+ FlutterViewRenderedIdlingResource(View flutterView) {
+ this.flutterView = checkNotNull(flutterView);
+ }
+
+ @Override
+ public String getName() {
+ return FlutterViewRenderedIdlingResource.class.getSimpleName();
+ }
+
+ @Override
+ public boolean isIdleNow() {
+ boolean isIdle = false;
+ if (flutterView instanceof FlutterView) {
+ isIdle = ((FlutterView) flutterView).hasRenderedFirstFrame();
+ } else if (flutterView instanceof io.flutter.view.FlutterView) {
+ isIdle = ((io.flutter.view.FlutterView) flutterView).hasRenderedFirstFrame();
+ } else {
+ throw new FlutterProtocolException(
+ String.format("This is not a Flutter View instance [id: %d].", flutterView.getId()));
+ }
+ if (isIdle) {
+ resourceCallback.onTransitionToIdle();
+ }
+ return isIdle;
+ }
+
+ @Override
+ public void registerIdleTransitionCallback(ResourceCallback callback) {
+ resourceCallback = callback;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/SyntheticClickAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/SyntheticClickAction.java
new file mode 100644
index 0000000..5036be1
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/SyntheticClickAction.java
@@ -0,0 +1,47 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import android.view.View;
+import androidx.test.annotation.Beta;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import androidx.test.espresso.flutter.api.WidgetAction;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * A synthetic click on a Flutter widget.
+ *
+ * <p>Note, this is not a real click gesture event issued from Android system. Espresso delegates to
+ * Flutter engine to perform the {@link SyntheticClick} action.
+ */
+@Beta
+public final class SyntheticClickAction implements WidgetAction {
+
+ @Override
+ public Future<Void> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController) {
+ return flutterTestingProtocol.perform(targetWidget, new SyntheticClick());
+ }
+
+ @Override
+ public String toString() {
+ return "click";
+ }
+
+ static class SyntheticClick extends SyntheticAction {
+
+ public SyntheticClick() {
+ super("tap");
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WaitUntilIdleAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WaitUntilIdleAction.java
new file mode 100644
index 0000000..b83e29b
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WaitUntilIdleAction.java
@@ -0,0 +1,32 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import android.view.View;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.WidgetAction;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/** An action that ensures Flutter is in an idle state. */
+public final class WaitUntilIdleAction implements WidgetAction {
+
+ @Override
+ public Future<Void> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController) {
+ return flutterTestingProtocol.waitUntilIdle();
+ }
+
+ @Override
+ public String toString() {
+ return "action that waits until Flutter's idle.";
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetCoordinatesCalculator.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetCoordinatesCalculator.java
new file mode 100644
index 0000000..8d541ae
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetCoordinatesCalculator.java
@@ -0,0 +1,68 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.graphics.Rect;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.View;
+import androidx.test.espresso.action.CoordinatesProvider;
+import java.util.Arrays;
+
+/** Provides coordinates of a Flutter widget. */
+final class WidgetCoordinatesCalculator implements CoordinatesProvider {
+
+ private static final String TAG = WidgetCoordinatesCalculator.class.getSimpleName();
+
+ private final Rect widgetRectInDp;
+
+ /**
+ * Constructs with the local (as relative to the outer Flutter view) coordinates of a Flutter
+ * widget in the unit of dp.
+ *
+ * @param widgetRectInDp the local widget coordinates in dp.
+ */
+ public WidgetCoordinatesCalculator(Rect widgetRectInDp) {
+ this.widgetRectInDp = checkNotNull(widgetRectInDp);
+ }
+
+ @Override
+ public float[] calculateCoordinates(View flutterView) {
+ int deviceDensityDpi = flutterView.getContext().getResources().getDisplayMetrics().densityDpi;
+ Rect widgetRectInPixel = convertDpToPixel(widgetRectInDp, deviceDensityDpi);
+ float widgetCenterX = (widgetRectInPixel.left + widgetRectInPixel.right) / 2;
+ float widgetCenterY = (widgetRectInPixel.top + widgetRectInPixel.bottom) / 2;
+ int[] viewCords = new int[] {0, 0};
+ flutterView.getLocationOnScreen(viewCords);
+ float[] coords = new float[] {viewCords[0] + widgetCenterX, viewCords[1] + widgetCenterY};
+ Log.d(
+ TAG,
+ String.format(
+ "Clicks on widget[%s] on Flutter View[%d, %d][width:%d, height:%d] at coordinates"
+ + " [%s] on screen",
+ widgetRectInPixel,
+ viewCords[0],
+ viewCords[1],
+ flutterView.getWidth(),
+ flutterView.getHeight(),
+ Arrays.toString(coords)));
+ return coords;
+ }
+
+ private static Rect convertDpToPixel(Rect rectInDp, int densityDpi) {
+ checkNotNull(rectInDp);
+ int left = (int) convertDpToPixel(rectInDp.left, densityDpi);
+ int top = (int) convertDpToPixel(rectInDp.top, densityDpi);
+ int right = (int) convertDpToPixel(rectInDp.right, densityDpi);
+ int bottom = (int) convertDpToPixel(rectInDp.bottom, densityDpi);
+ return new Rect(left, top, right, bottom);
+ }
+
+ private static float convertDpToPixel(float dp, int densityDpi) {
+ return dp * ((float) densityDpi / DisplayMetrics.DENSITY_DEFAULT);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetInfoFetcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetInfoFetcher.java
new file mode 100644
index 0000000..90d494e
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetInfoFetcher.java
@@ -0,0 +1,28 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.action;
+
+import android.view.View;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.flutter.api.FlutterAction;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/** A {@link FlutterAction} that retrieves the {@code WidgetInfo} of the matched Flutter widget. */
+public final class WidgetInfoFetcher implements FlutterAction<WidgetInfo> {
+
+ @Override
+ public Future<WidgetInfo> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController) {
+ return flutterTestingProtocol.matchWidget(targetWidget);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterAction.java
new file mode 100644
index 0000000..24b264c
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterAction.java
@@ -0,0 +1,30 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.api;
+
+import android.view.View;
+import androidx.test.espresso.UiController;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Represents a Flutter widget action.
+ *
+ * <p>This interface is part of Espresso-Flutter testing framework. Users should usually expect no
+ * return value for an action and use the {@code WidgetAction} for customizing an action on a
+ * Flutter widget.
+ *
+ * @param <R> The type of the action result.
+ */
+public interface FlutterAction<R> {
+
+ /** Performs an action on the given Flutter widget and gets its return value. */
+ Future<R> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController);
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterTestingProtocol.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterTestingProtocol.java
new file mode 100644
index 0000000..d01aaf5
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterTestingProtocol.java
@@ -0,0 +1,77 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.api;
+
+import android.graphics.Rect;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.common.annotations.Beta;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/** Defines the testing protocol/semantics between Espresso and Flutter. */
+@Beta
+public interface FlutterTestingProtocol {
+
+ /** Returns a future that waits until the Flutter testing protocol is in a usable state. */
+ public Future<Void> connect();
+
+ /**
+ * Performs a synthetic action on the Flutter widget that matches the given {@code widgetMatcher}.
+ *
+ * <p>If failed to perform the given {@code action}, returns a {@code Future} containing an {@code
+ * ExecutionException} that wraps the following exception:
+ *
+ * <ul>
+ * <li>{@code AmbiguousWidgetMatcherException} if the given {@code widgetMatcher} matched
+ * multiple widgets in the hierarchy when only one widget was expected.
+ * <li>{@code NoMatchingWidgetException} if the given {@code widgetMatcher} did not match any
+ * widget in the Flutter UI hierarchy.
+ * <li>{@code ConnectException} if connection error occurred.
+ * </ul>
+ *
+ * @param widgetMatcher the matcher to match a Flutter widget. If {@code null}, {@code action} is
+ * not performed on a specific widget.
+ * @param action the action to be performed on the widget.
+ * @return a {@code Future} representing pending completion of performing the action, or yields an
+ * exception if the action was failed to perform.
+ */
+ Future<Void> perform(@Nullable WidgetMatcher widgetMatcher, @Nonnull SyntheticAction action);
+
+ /**
+ * Returns a Java representation of the Flutter widget that matches the given widget matcher.
+ *
+ * <p>If failed to find a matching widget, returns a {@code Future} containing an {@code
+ * ExecutionException} that wraps the following exception:
+ *
+ * <ul>
+ * <li>{@code AmbiguousWidgetMatcherException} if the given {@code widgetMatcher} matched
+ * multiple widgets in the hierarchy when only one widget was expected.
+ * <li>{@code NoMatchingWidgetException} if the given {@code widgetMatcher} did not match any
+ * widget in the Flutter UI hierarchy.
+ * <li>{@code ConnectException} if connection error occurred.
+ * </ul>
+ *
+ * @param widgetMatcher the matcher to match a Flutter widget. Cannot be {@code null}.
+ * @return a {@code Future} representing pending completion of the matching operation.
+ */
+ Future<WidgetInfo> matchWidget(@Nonnull WidgetMatcher widgetMatcher);
+
+ /**
+ * Returns the local (as relative to its outer Flutter View) rectangle area of a widget that
+ * matches the given widget matcher.
+ *
+ * @param widgetMatcher the matcher to match a Flutter widget. Cannot be {@code null}.
+ * @return a rectangle area where the matched widget lives, in the unit of dp (Density-independent
+ * Pixel).
+ */
+ Future<Rect> getLocalRect(@Nonnull WidgetMatcher widgetMatcher);
+
+ /** Waits until the Flutter frame is in a stable state. */
+ Future<Void> waitUntilIdle();
+
+ /** Releases all the resources associated with this testing protocol connection. */
+ void close();
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/SyntheticAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/SyntheticAction.java
new file mode 100644
index 0000000..aed0c4b
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/SyntheticAction.java
@@ -0,0 +1,66 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.api;
+
+import static androidx.test.espresso.flutter.common.Constants.DEFAULT_INTERACTION_TIMEOUT;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.annotations.Beta;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+
+/**
+ * Base Flutter synthetic action.
+ *
+ * <p>A synthetic action is not a real gesture event issued to the Android system, rather it's an
+ * action that's performed via Flutter engine. It's supposed to be used for complex interactions or
+ * those that are brittle if performed through Android system. Most of the actions should be
+ * associated with a {@link WidgetMatcher}, but some may not, e.g. an action that checks the
+ * rendering status of the entire {@link io.flutter.view.FlutterView}.
+ */
+@Beta
+public abstract class SyntheticAction {
+
+ @Expose
+ @SerializedName("command")
+ protected String actionId;
+
+ @Expose
+ @SerializedName("timeout")
+ protected long timeOutInMillis;
+
+ protected SyntheticAction(@Nonnull String actionId) {
+ this(actionId, DEFAULT_INTERACTION_TIMEOUT.toMillis());
+ }
+
+ protected SyntheticAction(@Nonnull String actionId, long timeOutInMillis) {
+ this.actionId = checkNotNull(actionId);
+ this.timeOutInMillis = timeOutInMillis;
+ }
+
+ @Override
+ public String toString() {
+ return actionId;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ } else if (obj instanceof SyntheticAction) {
+ SyntheticAction otherAction = (SyntheticAction) obj;
+ return Objects.equals(actionId, otherAction.actionId);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(actionId);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAction.java
new file mode 100644
index 0000000..e49d3ef
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAction.java
@@ -0,0 +1,43 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.api;
+
+import android.view.View;
+import androidx.test.espresso.UiController;
+import com.google.common.annotations.Beta;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Responsible for performing an interaction on the given Flutter widget.
+ *
+ * <p>This is part of the Espresso-Flutter test framework public API - developers are free to write
+ * their own {@code WidgetAction} implementation when necessary.
+ */
+@Beta
+public interface WidgetAction extends FlutterAction<Void> {
+
+ /**
+ * Performs this action on the given Flutter widget.
+ *
+ * <p>If the given {@code targetWidget} is {@code null}, this action shall be performed on the
+ * entire {@code FlutterView} in context.
+ *
+ * @param targetWidget the matcher that uniquely identifies a Flutter widget on the given {@code
+ * FlutterView}. {@code Null} if it's a global action on the {@code FlutterView} in context.
+ * @param flutterView the Flutter view that this widget lives in.
+ * @param flutterTestingProtocol the channel for talking to Flutter app directly.
+ * @param androidUiController the interface for issuing UI operations to the Android system.
+ * @return a {@code Future} representing pending completion of performing the action, or yields an
+ * exception if the action failed to perform.
+ */
+ @Override
+ Future<Void> perform(
+ @Nullable WidgetMatcher targetWidget,
+ @Nonnull View flutterView,
+ @Nonnull FlutterTestingProtocol flutterTestingProtocol,
+ @Nonnull UiController androidUiController);
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAssertion.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAssertion.java
new file mode 100644
index 0000000..313dd26
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAssertion.java
@@ -0,0 +1,25 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.api;
+
+import android.view.View;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.common.annotations.Beta;
+
+/**
+ * Similar to a {@code ViewAssertion}, a {@link WidgetAssertion} is responsible for performing an
+ * assertion on a Flutter widget.
+ */
+@Beta
+public interface WidgetAssertion {
+
+ /**
+ * Checks the state of the Flutter widget.
+ *
+ * @param flutterView the Flutter view that this widget lives in.
+ * @param widgetInfo the instance that represents a Flutter widget.
+ */
+ void check(View flutterView, WidgetInfo widgetInfo);
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetMatcher.java
new file mode 100644
index 0000000..9f47e0b
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetMatcher.java
@@ -0,0 +1,41 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.api;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.common.annotations.Beta;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import org.hamcrest.TypeSafeMatcher;
+
+/**
+ * Base matcher for Flutter widgets.
+ *
+ * <p>A widget matcher's function is two-fold:
+ *
+ * <ul>
+ * <li>A matcher that can be passed into Flutter for selecting a Flutter widget.
+ * <li>Works with the {@code MatchesWidgetAssertion} to assert on a widget's properties.
+ * </ul>
+ */
+@Beta
+public abstract class WidgetMatcher extends TypeSafeMatcher<WidgetInfo> {
+
+ @Expose
+ @SerializedName("finderType")
+ protected String matcherId;
+
+ /**
+ * Constructs a {@code WidgetMatcher} instance with the given {@code matcherId}.
+ *
+ * @param matcherId the matcher id that represents this widget matcher.
+ */
+ public WidgetMatcher(@Nonnull String matcherId) {
+ this.matcherId = checkNotNull(matcherId);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterAssertions.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterAssertions.java
new file mode 100644
index 0000000..63ec0f6
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterAssertions.java
@@ -0,0 +1,41 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.assertion;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import android.view.View;
+import androidx.test.espresso.flutter.api.WidgetAssertion;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import javax.annotation.Nonnull;
+import org.hamcrest.Matcher;
+
+/** Collection of common {@link WidgetAssertion} instances. */
+public final class FlutterAssertions {
+
+ /**
+ * Returns a generic {@link WidgetAssertion} that asserts that a Flutter widget exists and is
+ * matched by the given widget matcher.
+ */
+ public static WidgetAssertion matches(@Nonnull Matcher<WidgetInfo> widgetMatcher) {
+ return new MatchesWidgetAssertion(checkNotNull(widgetMatcher, "Matcher cannot be null."));
+ }
+
+ /** A widget assertion that checks whether a widget is matched by the given matcher. */
+ static class MatchesWidgetAssertion implements WidgetAssertion {
+
+ private final Matcher<WidgetInfo> widgetMatcher;
+
+ private MatchesWidgetAssertion(Matcher<WidgetInfo> widgetMatcher) {
+ this.widgetMatcher = checkNotNull(widgetMatcher);
+ }
+
+ @Override
+ public void check(View flutterView, WidgetInfo widgetInfo) {
+ assertThat(widgetInfo, widgetMatcher);
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterViewAssertion.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterViewAssertion.java
new file mode 100644
index 0000000..5f4697d
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterViewAssertion.java
@@ -0,0 +1,45 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.assertion;
+
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.isFlutterView;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.view.View;
+import androidx.test.espresso.NoMatchingViewException;
+import androidx.test.espresso.ViewAssertion;
+import androidx.test.espresso.flutter.api.WidgetAssertion;
+import androidx.test.espresso.flutter.exception.InvalidFlutterViewException;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import androidx.test.espresso.util.HumanReadables;
+
+/**
+ * A {@code ViewAssertion} which performs an action on the given Flutter view.
+ *
+ * <p>This class acts as a bridge to perform {@code WidgetAssertion} on a Flutter widget on the
+ * given Flutter view.
+ */
+public final class FlutterViewAssertion implements ViewAssertion {
+
+ private final WidgetAssertion assertion;
+ private final WidgetInfo widgetInfo;
+
+ public FlutterViewAssertion(WidgetAssertion assertion, WidgetInfo widgetInfo) {
+ this.assertion = checkNotNull(assertion, "Widget assertion cannot be null.");
+ this.widgetInfo = checkNotNull(widgetInfo, "The widget info to be asserted on cannot be null.");
+ }
+
+ @Override
+ public void check(View view, NoMatchingViewException noViewFoundException) {
+ if (view == null) {
+ throw noViewFoundException;
+ } else if (!isFlutterView().matches(view)) {
+ throw new InvalidFlutterViewException(
+ String.format("Not a valid Flutter view:%s", HumanReadables.describe(view)));
+ } else {
+ assertion.check(view, widgetInfo);
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Constants.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Constants.java
new file mode 100644
index 0000000..c47f8df
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Constants.java
@@ -0,0 +1,17 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.common;
+
+import java.util.concurrent.TimeUnit;
+
+/** A utility class to hold various constants used by the Espresso-Flutter library. */
+public final class Constants {
+
+ // Do not initialize.
+ private Constants() {}
+
+ /** Default timeout for actions and asserts like {@code WidgetAction}. */
+ public static final Duration DEFAULT_INTERACTION_TIMEOUT = new Duration(10, TimeUnit.SECONDS);
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Duration.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Duration.java
new file mode 100644
index 0000000..d620153
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Duration.java
@@ -0,0 +1,61 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.common;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+
+/**
+ * A simple implementation of a time duration, supposed to be used within the Espresso-Flutter
+ * library.
+ *
+ * <p>This class is immutable.
+ */
+public final class Duration {
+
+ private final long quantity;
+ private final TimeUnit unit;
+
+ /**
+ * Initializes a Duration instance.
+ *
+ * @param quantity the amount of time in the given unit.
+ * @param unit the time unit. Cannot be null.
+ */
+ public Duration(long quantity, TimeUnit unit) {
+ this.quantity = quantity;
+ this.unit = checkNotNull(unit, "Time unit cannot be null.");
+ }
+
+ /** Returns the amount of time. */
+ public long getQuantity() {
+ return quantity;
+ }
+
+ /** Returns the time unit. */
+ public TimeUnit getUnit() {
+ return unit;
+ }
+
+ /** Returns the amount of time in milliseconds. */
+ public long toMillis() {
+ return TimeUnit.MILLISECONDS.convert(quantity, unit);
+ }
+
+ /**
+ * Returns a new Duration instance that adds this instance to the given {@code duration}. If the
+ * given {@code duration} is null, this method simply returns this instance.
+ */
+ public Duration plus(@Nullable Duration duration) {
+ if (duration == null) {
+ return this;
+ }
+ long add = unit.convert(duration.quantity, duration.unit);
+ long newQuantity = quantity + add;
+ return new Duration(newQuantity, unit);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/AmbiguousWidgetMatcherException.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/AmbiguousWidgetMatcherException.java
new file mode 100644
index 0000000..24d495f
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/AmbiguousWidgetMatcherException.java
@@ -0,0 +1,19 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.exception;
+
+import androidx.test.espresso.EspressoException;
+
+/**
+ * Indicates that a {@code WidgetMatcher} matched multiple widgets in the Flutter UI hierarchy when
+ * only one widget was expected.
+ */
+public final class AmbiguousWidgetMatcherException extends RuntimeException
+ implements EspressoException {
+
+ public AmbiguousWidgetMatcherException(String message) {
+ super(message);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/InvalidFlutterViewException.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/InvalidFlutterViewException.java
new file mode 100644
index 0000000..ca69e39
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/InvalidFlutterViewException.java
@@ -0,0 +1,17 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.exception;
+
+import androidx.test.espresso.EspressoException;
+
+/** Indicates that the {@code View} that Espresso operates on is not a valid Flutter View. */
+public final class InvalidFlutterViewException extends RuntimeException
+ implements EspressoException {
+
+ /** Constructs with an error message. */
+ public InvalidFlutterViewException(String message) {
+ super(message);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/NoMatchingWidgetException.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/NoMatchingWidgetException.java
new file mode 100644
index 0000000..49c949a
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/NoMatchingWidgetException.java
@@ -0,0 +1,18 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.exception;
+
+import androidx.test.espresso.EspressoException;
+
+/**
+ * Indicates that a given {@code WidgetMatcher} did not match any widgets in the Flutter UI
+ * hierarchy.
+ */
+public final class NoMatchingWidgetException extends RuntimeException implements EspressoException {
+
+ public NoMatchingWidgetException(String message) {
+ super(message);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdException.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdException.java
new file mode 100644
index 0000000..1a3666e
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdException.java
@@ -0,0 +1,27 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.idgenerator;
+
+/** Thrown if an ID cannot be generated. */
+public final class IdException extends RuntimeException {
+
+ private static final long serialVersionUID = 0L;
+
+ public IdException() {
+ super();
+ }
+
+ public IdException(String message) {
+ super(message);
+ }
+
+ public IdException(String message, Throwable throwable) {
+ super(message, throwable);
+ }
+
+ public IdException(Throwable throwable) {
+ super(throwable);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerator.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerator.java
new file mode 100644
index 0000000..b69d8f6
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerator.java
@@ -0,0 +1,19 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.idgenerator;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+/** Generates unique IDs of the parameterized type. */
+public interface IdGenerator<T> {
+
+ /**
+ * Returns a new, unique ID.
+ *
+ * @throws IdException if there were any errors in getting an ID.
+ */
+ @CanIgnoreReturnValue
+ T next();
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerators.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerators.java
new file mode 100644
index 0000000..f8f72dc
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerators.java
@@ -0,0 +1,65 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.idgenerator;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Some simple in-memory ID generators. */
+public final class IdGenerators {
+
+ private IdGenerators() {}
+
+ private static final IdGenerator<String> UUID_STRING_GENERATOR =
+ new IdGenerator<String>() {
+ @Override
+ public String next() {
+ return UUID.randomUUID().toString();
+ }
+ };
+
+ /**
+ * Returns a {@code Integer} ID generator whose next value is the value passed in. The value
+ * returned increases by one each time until {@code Integer.MAX_VALUE}. After that an {@code
+ * IdException} is thrown. This IdGenerator is threadsafe.
+ */
+ public static IdGenerator<Integer> newIntegerIdGenerator(int nextValue) {
+ checkArgument(nextValue >= 0, "ID values must be non-negative");
+ final AtomicInteger nextInt = new AtomicInteger(nextValue);
+ return new IdGenerator<Integer>() {
+ @Override
+ public Integer next() {
+ int value = nextInt.getAndIncrement();
+ if (value >= 0) {
+ return value;
+ }
+
+ // Make sure that all subsequent calls throw by setting to the most
+ // negative value possible.
+ nextInt.set(Integer.MIN_VALUE);
+ throw new IdException("Returned the last integer value available");
+ }
+ };
+ }
+
+ /**
+ * Returns a {@code Integer} ID generator whose next value is one. The value returned increases by
+ * one each time until {@code Integer.MAX_VALUE}. After that an {@code IdException} is thrown.
+ * This IdGenerator is threadsafe.
+ */
+ public static IdGenerator<Integer> newIntegerIdGenerator() {
+ return newIntegerIdGenerator(1);
+ }
+
+ /**
+ * Returns a {@code String} ID generator that passes ID requests to {@link UUID#randomUUID()},
+ * thereby generating type-4 (pseudo-randomly generated) UUIDs.
+ */
+ public static IdGenerator<String> randomUuidStringGenerator() {
+ return UUID_STRING_GENERATOR;
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/JsonRpcClient.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/JsonRpcClient.java
new file mode 100644
index 0000000..028a780
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/JsonRpcClient.java
@@ -0,0 +1,145 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.jsonrpc;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import android.util.Log;
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcRequest;
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse;
+import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import java.net.ConnectException;
+import java.net.URI;
+import java.util.concurrent.ConcurrentMap;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+
+/**
+ * A client that can be used to talk to a WebSocket-based JSON-RPC server.
+ *
+ * <p>One {@code JsonRpcClient} instance is not supposed to be shared between multiple threads.
+ * Always create a new instance of {@code JsonRpcClient} for connecting to a new JSON-RPC URI, but
+ * try to reuse the {@link OkHttpClient} instance, which is thread-safe and maintains a thread pool
+ * in handling requests and responses.
+ */
+public class JsonRpcClient {
+
+ private static final String TAG = JsonRpcClient.class.getSimpleName();
+ private static final int NORMAL_CLOSURE_STATUS = 1000;
+
+ private final URI webSocketUri;
+ private final ConcurrentMap<String, SettableFuture<JsonRpcResponse>> responseFutures;
+ private WebSocket webSocketConn;
+
+ /** {@code client} can be shared between multiple {@code JsonRpcClient}s. */
+ public JsonRpcClient(OkHttpClient client, URI webSocketUri) {
+ this.webSocketUri = checkNotNull(webSocketUri, "WebSocket URL can't be null.");
+ responseFutures = Maps.newConcurrentMap();
+ connect(checkNotNull(client, "OkHttpClient can't be null."), webSocketUri);
+ }
+
+ private void connect(OkHttpClient client, URI webSocketUri) {
+ Request request = new Request.Builder().url(webSocketUri.toString()).build();
+ WebSocketListener webSocketListener = new WebSocketListenerImpl();
+ webSocketConn = client.newWebSocket(request, webSocketListener);
+ }
+
+ /** Closes the web socket connection. Non-blocking, and will return immediately. */
+ public void disconnect() {
+ if (webSocketConn != null) {
+ webSocketConn.close(NORMAL_CLOSURE_STATUS, "Client request closing. All requests handled.");
+ }
+ }
+
+ /**
+ * Sends a JSON-RPC request and returns a {@link ListenableFuture} with which the client could
+ * wait on response. If the {@code request} is a JSON-RPC notification, this method returns
+ * immediately with a {@code null} response.
+ *
+ * @param request the JSON-RPC request to be sent.
+ * @return a {@code ListenableFuture} representing pending completion of the request, or yields an
+ * {@code ExecutionException}, which wraps a {@code ConnectException} if failed to send the
+ * request.
+ */
+ public ListenableFuture<JsonRpcResponse> request(JsonRpcRequest request) {
+ checkNotNull(request, "JSON-RPC request shouldn't be null.");
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(
+ TAG,
+ String.format("JSON-RPC Request sent to uri %s: %s.", webSocketUri, request.toJson()));
+ }
+ if (webSocketConn == null) {
+ ConnectException e =
+ new ConnectException("WebSocket connection was not initiated correctly.");
+ return immediateFailedFuture(e);
+ }
+ synchronized (responseFutures) {
+ // Holding the lock of responseFutures for send-and-add operations, so that we could make sure
+ // to add its ListenableFuture to the responseFutures map before the thread of
+ // {@code WebSocketListenerImpl#onMessage} method queries the map.
+ boolean succeeded = webSocketConn.send(request.toJson());
+ if (!succeeded) {
+ ConnectException e = new ConnectException("Failed to send request: " + request);
+ return immediateFailedFuture(e);
+ }
+ if (isNullOrEmpty(request.getId())) {
+ // Request id is null or empty. This is a notification request, so returns immediately.
+ return immediateFuture(null);
+ } else {
+ SettableFuture<JsonRpcResponse> responseFuture = SettableFuture.create();
+ responseFutures.put(request.getId(), responseFuture);
+ return responseFuture;
+ }
+ }
+ }
+
+ /** A callback listener that handles incoming web socket messages. */
+ private class WebSocketListenerImpl extends WebSocketListener {
+ @Override
+ public void onMessage(WebSocket webSocket, String response) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, String.format("JSON-RPC response received: %s.", response));
+ }
+ JsonRpcResponse responseObj = JsonRpcResponse.fromJson(response);
+ synchronized (responseFutures) {
+ if (isNullOrEmpty(responseObj.getId())
+ || !responseFutures.containsKey(responseObj.getId())) {
+ Log.w(
+ TAG,
+ String.format(
+ "Received a message with empty or unknown ID: %s. Drop the message.",
+ responseObj.getId()));
+ return;
+ }
+ SettableFuture<JsonRpcResponse> responseFuture =
+ responseFutures.remove(responseObj.getId());
+ responseFuture.set(responseObj);
+ }
+ }
+
+ @Override
+ public void onClosing(WebSocket webSocket, int code, String reason) {
+ Log.d(
+ TAG,
+ String.format(
+ "Server requested connection close with code %d, reason: %s", code, reason));
+ webSocket.close(NORMAL_CLOSURE_STATUS, "Server requested closing connection.");
+ }
+
+ @Override
+ public void onFailure(WebSocket webSocket, Throwable t, Response response) {
+ Log.w(TAG, String.format("Failed to deliver message with error: %s.", t.getMessage()));
+ throw new RuntimeException("WebSocket request failure.", t);
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/ErrorObject.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/ErrorObject.java
new file mode 100644
index 0000000..af5c68e
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/ErrorObject.java
@@ -0,0 +1,60 @@
+package androidx.test.espresso.flutter.internal.jsonrpc.message;
+
+import com.google.gson.JsonObject;
+import java.util.Objects;
+
+/**
+ * A class for holding the error object in {@code JsonRpcResponse}.
+ *
+ * <p>See https://www.jsonrpc.org/specification#error_object for detailed specification.
+ */
+public class ErrorObject {
+ private final int code;
+ private final String message;
+ private final JsonObject data;
+
+ public ErrorObject(int code, String message) {
+ this(code, message, null);
+ }
+
+ public ErrorObject(int code, String message, JsonObject data) {
+ this.code = code;
+ this.message = message;
+ this.data = data;
+ }
+
+ /** Gets the error code. */
+ public int getCode() {
+ return code;
+ }
+
+ /** Gets the error message. */
+ public String getMessage() {
+ return message;
+ }
+
+ /** Gets the additional information about the error. Could be null. */
+ public JsonObject getData() {
+ return data;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof ErrorObject) {
+ ErrorObject errorObject = (ErrorObject) obj;
+ return errorObject.code == this.code
+ && Objects.equals(errorObject.message, this.message)
+ && Objects.equals(errorObject.data, this.data);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = code;
+ hash = hash * 31 + Objects.hashCode(message);
+ hash = hash * 31 + Objects.hashCode(data);
+ return hash;
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcRequest.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcRequest.java
new file mode 100644
index 0000000..fa03340
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcRequest.java
@@ -0,0 +1,221 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.jsonrpc.message;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.annotations.SerializedName;
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * JSON-RPC 2.0 request object.
+ *
+ * <p>See https://www.jsonrpc.org/specification for detailed specification.
+ */
+public final class JsonRpcRequest {
+
+ private static final Gson gson = new Gson();
+
+ private static final String JSON_RPC_VERSION = "2.0";
+
+ /** Specifying the version of the JSON-RPC protocol. Must be "2.0". */
+ @SerializedName("jsonrpc")
+ private final String version;
+
+ /**
+ * An identifier of the request. Could be String, a number, or null. In this implementation, we
+ * always use String as the type. If null, this is a notification and no response is required.
+ */
+ @Nullable private final String id;
+
+ /** A String containing the name of the method to be invoked. */
+ private final String method;
+
+ /** Parameter values to be used during the invocation of the method. */
+ private JsonObject params;
+
+ /**
+ * Deserializes the given Json string to a {@code JsonRpcRequest} object.
+ *
+ * @param jsonString the string from which the object is to be deserialized.
+ * @return the deserialized object.
+ */
+ public static JsonRpcRequest fromJson(String jsonString) {
+ checkArgument(!isNullOrEmpty(jsonString), "Json string cannot be null or empty.");
+ JsonRpcRequest request = gson.fromJson(jsonString, JsonRpcRequest.class);
+ checkState(JSON_RPC_VERSION.equals(request.getVersion()), "JSON-RPC version must be 2.0.");
+ checkState(
+ !isNullOrEmpty(request.getMethod()), "JSON-RPC request must contain the method field.");
+ return request;
+ }
+
+ /**
+ * Constructs with the given method name. The JSON-RPC version will be defaulted to "2.0".
+ *
+ * @param method the method name of this request.
+ */
+ private JsonRpcRequest(String method) {
+ this(null, method);
+ }
+
+ /**
+ * Constructs with the given id and method name. The JSON-RPC version will be defaulted to "2.0".
+ *
+ * @param id the id of this request.
+ * @param method the method name of this request.
+ */
+ private JsonRpcRequest(@Nullable String id, String method) {
+ this.version = JSON_RPC_VERSION;
+ this.id = id;
+ this.method = checkNotNull(method, "JSON-RPC request method cannot be null.");
+ }
+
+ /**
+ * Gets the JSON-RPC version.
+ *
+ * @return the JSON-RPC version. Should always be "2.0".
+ */
+ public String getVersion() {
+ return version;
+ }
+
+ /**
+ * Gets the id of this JSON-RPC request.
+ *
+ * @return the id of this request. Returns null if this is a notification request.
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Gets the method name of this JSON-RPC request.
+ *
+ * @return the method name.
+ */
+ public String getMethod() {
+ return method;
+ }
+
+ /** Gets the params used in this request. */
+ public JsonObject getParams() {
+ return params;
+ }
+
+ /**
+ * Serializes this object to its equivalent Json representation.
+ *
+ * @return the Json representation of this object.
+ */
+ public String toJson() {
+ return gson.toJson(this);
+ }
+
+ /**
+ * Equivalent to {@link #toJson()}.
+ *
+ * @return the Json representation of this object.
+ */
+ @Override
+ public String toString() {
+ return toJson();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof JsonRpcRequest) {
+ JsonRpcRequest objRequest = (JsonRpcRequest) obj;
+ return Objects.equals(objRequest.id, this.id)
+ && Objects.equals(objRequest.method, this.method)
+ && Objects.equals(objRequest.params, this.params);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = Objects.hashCode(id);
+ hash = hash * 31 + Objects.hashCode(method);
+ hash = hash * 31 + Objects.hashCode(params);
+ return hash;
+ }
+
+ /** Builder for {@link JsonRpcRequest}. */
+ public static class Builder {
+
+ /** The request id. Could be null if the request is a notification. */
+ @Nullable private String id;
+
+ /** A String containing the name of the method to be invoked. */
+ private String method;
+
+ /** Parameter values to be used during the invocation of the method. */
+ private JsonObject params = new JsonObject();
+
+ /** Empty constructor. */
+ public Builder() {}
+
+ /**
+ * Constructs an instance with the given method name.
+ *
+ * @param method the method name of this request builder.
+ */
+ public Builder(String method) {
+ this.method = method;
+ }
+
+ /** Sets the id of this request builder. */
+ public Builder setId(@Nullable String id) {
+ this.id = id;
+ return this;
+ }
+
+ /** Sets the method name of this request builder. */
+ public Builder setMethod(String method) {
+ this.method = method;
+ return this;
+ }
+
+ /** Sets the params of this request builder. */
+ public Builder setParams(JsonObject params) {
+ this.params = params;
+ return this;
+ }
+
+ /** Sugar method to add a {@code String} param to this request builder. */
+ public Builder addParam(String tag, String value) {
+ params.addProperty(tag, value);
+ return this;
+ }
+
+ /** Sugar method to add an integer param to this request builder. */
+ public Builder addParam(String tag, int value) {
+ params.addProperty(tag, value);
+ return this;
+ }
+
+ /** Sugar method to add a {@code boolean} param to this request builder. */
+ public Builder addParam(String tag, boolean value) {
+ params.addProperty(tag, value);
+ return this;
+ }
+
+ /** Builds and returns a {@code JsonRpcRequest} instance out of this builder. */
+ public JsonRpcRequest build() {
+ JsonRpcRequest request = new JsonRpcRequest(id, method);
+ if (params != null && params.size() != 0) {
+ request.params = this.params;
+ }
+ return request;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcResponse.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcResponse.java
new file mode 100644
index 0000000..f845765
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcResponse.java
@@ -0,0 +1,156 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.jsonrpc.message;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.annotations.SerializedName;
+import java.util.Objects;
+
+/**
+ * JSON-RPC 2.0 response object.
+ *
+ * <p>See https://www.jsonrpc.org/specification for detailed specification.
+ */
+public final class JsonRpcResponse {
+
+ private static final Gson gson = new Gson();
+
+ private static final String JSON_RPC_VERSION = "2.0";
+
+ /** Specifying the version of the JSON-RPC protocol. Must be "2.0". */
+ @SerializedName("jsonrpc")
+ private final String version;
+
+ /**
+ * Required. Must be the same as the value of the id in the corresponding JsonRpcRequest object.
+ */
+ private String id;
+
+ /** The result of the JSON-RPC call. Required on success. */
+ private JsonObject result;
+
+ /** Error occurred in the JSON-RPC call. Required on error. */
+ private ErrorObject error;
+
+ /**
+ * Deserializes the given Json string to a {@code JsonRpcResponse} object.
+ *
+ * @param jsonString the string from which the object is to be deserialized.
+ * @return the deserialized object.
+ */
+ public static JsonRpcResponse fromJson(String jsonString) {
+ checkArgument(!isNullOrEmpty(jsonString), "Json string cannot be null or empty.");
+ JsonRpcResponse response = gson.fromJson(jsonString, JsonRpcResponse.class);
+ checkState(!isNullOrEmpty(response.getId()));
+ checkState(JSON_RPC_VERSION.equals(response.getVersion()), "JSON-RPC version must be 2.0.");
+ return response;
+ }
+
+ /**
+ * Constructs with the given id and. The JSON-RPC version will be defaulted to "2.0".
+ *
+ * @param id the id of this response. Should be the same as the corresponding request.
+ */
+ public JsonRpcResponse(String id) {
+ this.version = JSON_RPC_VERSION;
+ setId(id);
+ }
+
+ /**
+ * Gets the JSON-RPC version.
+ *
+ * @return the JSON-RPC version. Should always be "2.0".
+ */
+ public String getVersion() {
+ return version;
+ }
+
+ /** Gets the id of this JSON-RPC response. */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Sets the id of this JSON-RPC response.
+ *
+ * @param id the id to be set. Cannot be null.
+ */
+ public void setId(String id) {
+ this.id = checkNotNull(id);
+ }
+
+ /** Gets the result of this JSON-RPC response. Should be present on success. */
+ public JsonObject getResult() {
+ return result;
+ }
+
+ /**
+ * Sets the result of this JSON-RPC response.
+ *
+ * @param result
+ */
+ public void setResult(JsonObject result) {
+ this.result = result;
+ }
+
+ /** Gets the error object of this JSON-RPC response. Should be present on error. */
+ public ErrorObject getError() {
+ return error;
+ }
+
+ /**
+ * Sets the error object of this JSON-RPC response.
+ *
+ * @param error the error to be set.
+ */
+ public void setError(ErrorObject error) {
+ this.error = error;
+ }
+
+ /**
+ * Serializes this object to its equivalent Json representation.
+ *
+ * @return the Json representation of this object.
+ */
+ public String toJson() {
+ return gson.toJson(this);
+ }
+
+ /**
+ * Equivalent to {@link #toJson()}.
+ *
+ * @return the Json representation of this object.
+ */
+ @Override
+ public String toString() {
+ return toJson();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof JsonRpcResponse) {
+ JsonRpcResponse objResponse = (JsonRpcResponse) obj;
+ return Objects.equals(objResponse.id, this.id)
+ && Objects.equals(objResponse.result, this.result)
+ && Objects.equals(objResponse.error, this.error);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = Objects.hashCode(id);
+ hash = hash * 31 + Objects.hashCode(result);
+ hash = hash * 31 + Objects.hashCode(error);
+ return hash;
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmService.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmService.java
new file mode 100644
index 0000000..da11fcc
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmService.java
@@ -0,0 +1,377 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.util.concurrent.Futures.transform;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.graphics.Rect;
+import android.util.Log;
+import androidx.test.espresso.flutter.api.FlutterTestingProtocol;
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.internal.idgenerator.IdGenerator;
+import androidx.test.espresso.flutter.internal.jsonrpc.JsonRpcClient;
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcRequest;
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse;
+import androidx.test.espresso.flutter.internal.protocol.impl.GetOffsetAction.OffsetType;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * An implementation of the Espresso-Flutter testing protocol by using the testing APIs exposed by
+ * Dart VM service protocol.
+ *
+ * @see <a href="https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md">Dart VM
+ * Service Protocol</a>.
+ */
+public final class DartVmService implements FlutterTestingProtocol {
+
+ private static final String TAG = DartVmService.class.getSimpleName();
+
+ private static final Gson gson =
+ new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
+
+ /** Prefix to be attached to the JSON-RPC message id. */
+ private static final String MESSAGE_ID_PREFIX = "message-";
+
+ /** The JSON-RPC method for testing extension APIs. */
+ private static final String TESTING_EXTENSION_METHOD = "ext.flutter.driver";
+ /** The JSON-RPC method for retrieving Dart isolate info. */
+ private static final String GET_ISOLATE_METHOD = "getIsolate";
+ /** The JSON-RPC method for retrieving Dart VM info. */
+ private static final String GET_VM_METHOD = "getVM";
+
+ /** Json property name for the Dart VM isolate id. */
+ private static final String ISOLATE_ID_TAG = "isolateId";
+
+ private final JsonRpcClient client;
+ private final IdGenerator<Integer> messageIdGenerator;
+ private final String isolateId;
+ private final ListeningExecutorService taskExecutor;
+
+ /**
+ * Constructs a {@code DartVmService} instance that can be used to talk to the testing protocol
+ * exposed by Dart VM service extension protocol. It uses the given {@code isolateId} in all the
+ * JSON-RPC requests. It waits until the service extension protocol is in a usable state before
+ * returning.
+ *
+ * @param isolateId the Dart isolate ID to be used in the JSON-RPC requests sent to Dart VM
+ * service protocol.
+ * @param jsonRpcClient a JSON-RPC web socket connection to send requests to the Dart VM service
+ * protocol.
+ * @param messageIdGenerator an ID generator for generating the JSON-RPC request IDs.
+ * @param taskExecutor an executor for running async tasks.
+ */
+ public DartVmService(
+ String isolateId,
+ JsonRpcClient jsonRpcClient,
+ IdGenerator<Integer> messageIdGenerator,
+ ExecutorService taskExecutor) {
+ this.isolateId =
+ checkNotNull(
+ isolateId, "The ID of the Dart isolate that draws the Flutter UI shouldn't be null.");
+ this.client =
+ checkNotNull(
+ jsonRpcClient,
+ "The JsonRpcClient used to talk to Dart VM service protocol shouldn't be null.");
+ this.messageIdGenerator =
+ checkNotNull(
+ messageIdGenerator, "The id generator for generating request IDs shouldn't be null.");
+ this.taskExecutor = MoreExecutors.listeningDecorator(checkNotNull(taskExecutor));
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>This method ensures the Dart VM service is ready for use by checking:
+ *
+ * <ul>
+ * <li>Dart VM Observatory is up and running.
+ * <li>The Flutter testing API is registered with the running Dart VM service protocol.
+ * </ul>
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public Future<Void> connect() {
+ return (Future<Void>) taskExecutor.submit(new IsDartVmServiceReady(isolateId, this));
+ }
+
+ @Override
+ public Future<Void> perform(
+ @Nullable final WidgetMatcher widgetMatcher, final SyntheticAction action) {
+ // Assumes all the actions require a response.
+ ListenableFuture<JsonRpcResponse> responseFuture =
+ client.request(getActionRequest(widgetMatcher, action));
+ Function<JsonRpcResponse, Void> resultTransformFunc =
+ new Function<JsonRpcResponse, Void>() {
+ public Void apply(JsonRpcResponse response) {
+ if (response.getError() == null) {
+ return null;
+ } else {
+ // TODO(https://github.com/android/android-test/issues/251): Update error case handling
+ // like
+ // AmbiguousWidgetMatcherException, NoMatchingWidgetException after nailing down the
+ // design with
+ // Flutter team.
+ throw new RuntimeException(
+ String.format(
+ "Error occurred when performing the given action %s on widget matched %s",
+ action, widgetMatcher));
+ }
+ }
+ };
+ return transform(responseFuture, resultTransformFunc, directExecutor());
+ }
+
+ @Override
+ public Future<WidgetInfo> matchWidget(@Nonnull WidgetMatcher widgetMatcher) {
+ JsonRpcRequest request = getActionRequest(widgetMatcher, new GetWidgetDiagnosticsAction());
+ ListenableFuture<JsonRpcResponse> jsonResponseFuture = client.request(request);
+
+ Function<JsonRpcResponse, WidgetInfo> widgetInfoTransformer =
+ new Function<JsonRpcResponse, WidgetInfo>() {
+ public WidgetInfo apply(JsonRpcResponse jsonResponse) {
+ GetWidgetDiagnosticsResponse widgetDiagnostics =
+ GetWidgetDiagnosticsResponse.fromJsonRpcResponse(jsonResponse);
+ return WidgetInfoFactory.createWidgetInfo(widgetDiagnostics);
+ }
+ };
+ return transform(jsonResponseFuture, widgetInfoTransformer, directExecutor());
+ }
+
+ @Override
+ public Future<Rect> getLocalRect(@Nonnull WidgetMatcher widgetMatcher) {
+ ListenableFuture<JsonRpcResponse> topLeftFuture =
+ client.request(getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.TOP_LEFT)));
+ ListenableFuture<JsonRpcResponse> bottomRightFuture =
+ client.request(
+ getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.BOTTOM_RIGHT)));
+ ListenableFuture<List<JsonRpcResponse>> responses =
+ Futures.allAsList(topLeftFuture, bottomRightFuture);
+ Function<List<JsonRpcResponse>, Rect> rectTransformer =
+ new Function<List<JsonRpcResponse>, Rect>() {
+ public Rect apply(List<JsonRpcResponse> jsonResponses) {
+ GetOffsetResponse topLeft = GetOffsetResponse.fromJsonRpcResponse(jsonResponses.get(0));
+ GetOffsetResponse bottomRight =
+ GetOffsetResponse.fromJsonRpcResponse(jsonResponses.get(1));
+ checkState(
+ topLeft.getX() >= 0 && topLeft.getY() >= 0,
+ String.format(
+ "The relative coordinates [%.1f, %.1f] of a widget's top left vertex cannot be"
+ + " negative (negative means it's off the outer Flutter view)!",
+ topLeft.getX(), topLeft.getY()));
+ checkState(
+ bottomRight.getX() >= 0 && bottomRight.getY() >= 0,
+ String.format(
+ "The relative coordinates [%.1f, %.1f] of a widget's bottom right vertex cannot"
+ + " be negative (negative means it's off the outer Flutter view)!",
+ bottomRight.getX(), bottomRight.getY()));
+ checkState(
+ topLeft.getX() <= bottomRight.getX() && topLeft.getY() <= bottomRight.getY(),
+ String.format(
+ "The coordinates of the bottom right vertex [%.1f, %.1f] are not actually to the"
+ + " bottom right of the top left vertex [%.1f, %.1f]!",
+ topLeft.getX(), topLeft.getY(), bottomRight.getX(), bottomRight.getY()));
+ return new Rect(
+ (int) topLeft.getX(),
+ (int) topLeft.getY(),
+ (int) bottomRight.getX(),
+ (int) bottomRight.getY());
+ }
+ };
+ return transform(responses, rectTransformer, directExecutor());
+ }
+
+ @Override
+ public Future<Void> waitUntilIdle() {
+ return perform(
+ null,
+ new WaitForConditionAction(
+ new NoPendingPlatformMessagesCondition(),
+ new NoTransientCallbacksCondition(),
+ new NoPendingFrameCondition()));
+ }
+
+ @Override
+ public void close() {
+ if (client != null) {
+ client.disconnect();
+ }
+ }
+
+ /** Queries the Dart isolate information. */
+ public ListenableFuture<JsonRpcResponse> getIsolateInfo() {
+ JsonRpcRequest getIsolateReq =
+ new JsonRpcRequest.Builder(GET_ISOLATE_METHOD)
+ .setId(getNextMessageId())
+ .addParam(ISOLATE_ID_TAG, isolateId)
+ .build();
+ return client.request(getIsolateReq);
+ }
+
+ /** Queries the Dart VM information. */
+ public ListenableFuture<GetVmResponse> getVmInfo() {
+ JsonRpcRequest getVmReq =
+ new JsonRpcRequest.Builder(GET_VM_METHOD).setId(getNextMessageId()).build();
+ ListenableFuture<JsonRpcResponse> jsonGetVmResp = client.request(getVmReq);
+ Function<JsonRpcResponse, GetVmResponse> jsonToResponse =
+ new Function<JsonRpcResponse, GetVmResponse>() {
+ public GetVmResponse apply(JsonRpcResponse jsonResp) {
+ return GetVmResponse.fromJsonRpcResponse(jsonResp);
+ }
+ };
+ return transform(jsonGetVmResp, jsonToResponse, directExecutor());
+ }
+
+ /** Gets the next usable message id. */
+ private String getNextMessageId() {
+ return MESSAGE_ID_PREFIX + messageIdGenerator.next();
+ }
+
+ /** Constructs a {@code JsonRpcRequest} based on the given matcher and action. */
+ private JsonRpcRequest getActionRequest(WidgetMatcher widgetMatcher, SyntheticAction action) {
+ checkNotNull(action, "Action cannot be null.");
+ // Assumes all the actions require a response.
+ return new JsonRpcRequest.Builder(TESTING_EXTENSION_METHOD)
+ .setId(getNextMessageId())
+ .setParams(constructParams(isolateId, widgetMatcher, action))
+ .build();
+ }
+
+ /** Constructs the JSON-RPC request params. */
+ private static JsonObject constructParams(
+ String isolateId, WidgetMatcher widgetMatcher, SyntheticAction action) {
+ JsonObject paramObject = new JsonObject();
+ paramObject.addProperty(ISOLATE_ID_TAG, isolateId);
+ if (widgetMatcher != null) {
+ paramObject = merge(paramObject, (JsonObject) gson.toJsonTree(widgetMatcher));
+ }
+ paramObject = merge(paramObject, (JsonObject) gson.toJsonTree(action));
+ return paramObject;
+ }
+
+ /**
+ * Returns a merged {@code JsonObject} of the two given {@code JsonObject}s, or an empty {@code
+ * JsonObject} if both of the objects to be merged are null.
+ */
+ private static JsonObject merge(@Nullable JsonObject obj1, @Nullable JsonObject obj2) {
+ JsonObject result = new JsonObject();
+ mergeTo(result, obj1);
+ mergeTo(result, obj2);
+ return result;
+ }
+
+ private static void mergeTo(JsonObject obj, @Nullable JsonObject toBeMerged) {
+ if (toBeMerged != null) {
+ for (Map.Entry<String, JsonElement> entry : toBeMerged.entrySet()) {
+ obj.add(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ /** A {@link Runnable} that waits until the Dart VM testing extension is ready for use. */
+ static class IsDartVmServiceReady implements Runnable {
+
+ /** Maximum number of retries for checking extension APIs' availability. */
+ private static final int EXTENSION_API_CHECKING_RETRIES = 5;
+
+ /** Json param name for retrieving all the available extension APIs. */
+ private static final String EXTENSION_RPCS_TAG = "extensionRPCs";
+
+ private final String isolateId;
+ private final DartVmService dartVmService;
+
+ IsDartVmServiceReady(String isolateId, DartVmService dartVmService) {
+ this.isolateId = checkNotNull(isolateId);
+ this.dartVmService = checkNotNull(dartVmService);
+ }
+
+ @Override
+ public void run() {
+ waitForTestingApiRegistered();
+ }
+
+ /**
+ * Blocks until the Flutter testing/driver API is registered with the running Dart VM service
+ * protocol by querying whether it's listed in the isolate's 'extensionRPCs'.
+ */
+ @VisibleForTesting
+ void waitForTestingApiRegistered() {
+ int retries = EXTENSION_API_CHECKING_RETRIES;
+ boolean isApiRegistered = false;
+ do {
+ retries--;
+ try {
+ JsonRpcResponse isolateResp = dartVmService.getIsolateInfo().get();
+ isApiRegistered = isTestingApiRegistered(isolateResp);
+ } catch (ExecutionException e) {
+ Log.d(
+ TAG,
+ "Error occurred during retrieving Dart isolate information. Retry.",
+ e.getCause());
+ continue;
+ } catch (InterruptedException e) {
+ Log.d(
+ TAG,
+ "InterruptedException occurred during retrieving Dart isolate information. Retry.",
+ e);
+ Thread.currentThread().interrupt(); // Restores the interrupted status.
+ continue;
+ }
+ } while (!isApiRegistered && retries > 0);
+
+ if (!isApiRegistered) {
+ throw new FlutterProtocolException(
+ String.format("Flutter testing APIs not registered with Dart isolate %s.", isolateId));
+ }
+ }
+
+ @VisibleForTesting
+ boolean isTestingApiRegistered(JsonRpcResponse isolateInfoResp) {
+ if (isolateInfoResp == null
+ || isolateInfoResp.getError() != null
+ || isolateInfoResp.getResult() == null) {
+ Log.w(
+ TAG,
+ String.format(
+ "Error occurred in JSON-RPC response when querying isolate info for %s: %s.",
+ isolateId, isolateInfoResp.getError()));
+ return false;
+ }
+ Iterator<JsonElement> extensions =
+ isolateInfoResp.getResult().get(EXTENSION_RPCS_TAG).getAsJsonArray().iterator();
+ while (extensions.hasNext()) {
+ String extensionApi = extensions.next().getAsString();
+ if (TESTING_EXTENSION_METHOD.equals(extensionApi)) {
+ Log.d(
+ TAG,
+ String.format("Flutter testing API registered with Dart isolate %s.", isolateId));
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmServiceUtil.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmServiceUtil.java
new file mode 100644
index 0000000..2cf41f1
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmServiceUtil.java
@@ -0,0 +1,94 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import android.util.Log;
+import android.view.View;
+import io.flutter.embedding.engine.FlutterEngine;
+import io.flutter.embedding.engine.dart.DartExecutor;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+
+/** Util class for dealing with Dart VM service protocols. */
+public final class DartVmServiceUtil {
+ private static final String TAG = DartVmServiceUtil.class.getSimpleName();
+
+ /**
+ * Converts the Dart VM observatory http server URL to the service protocol WebSocket URL.
+ *
+ * @param observatoryUrl The Dart VM http server URL that can be converted to a service protocol
+ * URI.
+ */
+ public static URI getServiceProtocolUri(String observatoryUrl) {
+ if (isNullOrEmpty(observatoryUrl)) {
+ throw new RuntimeException(
+ "Dart VM Observatory is not enabled. "
+ + "Please make sure your Flutter app is running under debug mode.");
+ }
+
+ try {
+ new URL(observatoryUrl);
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(
+ String.format("Dart VM Observatory url %s is malformed.", observatoryUrl), e);
+ }
+
+ // Constructs the service protocol URL based on the Observatory http url.
+ // For example, http://127.0.0.1:39694/qsnVeidc78Y=/ -> ws://127.0.0.1:39694/qsnVeidc78Y=/ws.
+ int schemaIndex = observatoryUrl.indexOf(":");
+ String serviceProtocolUri = "ws" + observatoryUrl.substring(schemaIndex);
+ if (!observatoryUrl.endsWith("/")) {
+ serviceProtocolUri += "/";
+ }
+ serviceProtocolUri += "ws";
+
+ Log.i(TAG, "Dart VM service protocol runs at uri: " + serviceProtocolUri);
+ try {
+ return new URI(serviceProtocolUri);
+ } catch (URISyntaxException e) {
+ // Should never happen.
+ throw new RuntimeException("Illegal Dart VM service protocol URI: " + serviceProtocolUri, e);
+ }
+ }
+
+ /** Gets the Dart isolate ID for the given {@code flutterView}. */
+ public static String getDartIsolateId(View flutterView) {
+ checkNotNull(flutterView, "The Flutter View instance cannot be null.");
+ String uiIsolateId = getDartExecutor(flutterView).getIsolateServiceId();
+ Log.d(
+ TAG,
+ String.format(
+ "Dart isolate ID for the Flutter View [id: %d]: %s.",
+ flutterView.getId(), uiIsolateId));
+ return uiIsolateId;
+ }
+
+ /** Gets the Dart executor for the given {@code flutterView}. */
+ public static DartExecutor getDartExecutor(View flutterView) {
+ checkNotNull(flutterView, "The Flutter View instance cannot be null.");
+ // Flutter's embedding is in the phase of rewriting/refactoring. Let's be compatible with both
+ // the old and the new FlutterView classes.
+ if (flutterView instanceof io.flutter.view.FlutterView) {
+ return ((io.flutter.view.FlutterView) flutterView).getDartExecutor();
+ } else if (flutterView instanceof io.flutter.embedding.android.FlutterView) {
+ FlutterEngine flutterEngine =
+ ((io.flutter.embedding.android.FlutterView) flutterView).getAttachedFlutterEngine();
+ if (flutterEngine == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "No Flutter engine attached to the Flutter view [id: %d].", flutterView.getId()));
+ }
+ return flutterEngine.getDartExecutor();
+ } else {
+ throw new FlutterProtocolException(
+ String.format("This is not a Flutter View instance [id: %d].", flutterView.getId()));
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/FlutterProtocolException.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/FlutterProtocolException.java
new file mode 100644
index 0000000..71cdb26
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/FlutterProtocolException.java
@@ -0,0 +1,21 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+/** Represents an exception/error relevant to Dart VM service. */
+public final class FlutterProtocolException extends RuntimeException {
+
+ public FlutterProtocolException(String message) {
+ super(message);
+ }
+
+ public FlutterProtocolException(Throwable t) {
+ super(t);
+ }
+
+ public FlutterProtocolException(String message, Throwable t) {
+ super(message, t);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetAction.java
new file mode 100644
index 0000000..9b92f67
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetAction.java
@@ -0,0 +1,69 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import com.google.common.base.Ascii;
+import com.google.gson.annotations.Expose;
+
+/** An action that retrieves the widget offset coordinates to the outer Flutter view. */
+final class GetOffsetAction extends SyntheticAction {
+
+ /** The position of the offset coordinates. */
+ public enum OffsetType {
+ TOP_LEFT("topLeft"),
+ TOP_RIGHT("topRight"),
+ BOTTOM_LEFT("bottomLeft"),
+ BOTTOM_RIGHT("bottomRight");
+
+ private OffsetType(String type) {
+ this.type = type;
+ }
+
+ private final String type;
+
+ @Override
+ public String toString() {
+ return type;
+ }
+
+ public static OffsetType fromString(String typeString) {
+ if (typeString == null) {
+ return null;
+ }
+ for (OffsetType offsetType : OffsetType.values()) {
+ if (Ascii.equalsIgnoreCase(offsetType.type, typeString)) {
+ return offsetType;
+ }
+ }
+ return null;
+ }
+ }
+
+ @Expose private final String offsetType;
+
+ /**
+ * Constructor.
+ *
+ * @param type the vertex position.
+ */
+ public GetOffsetAction(OffsetType type) {
+ super("get_offset");
+ this.offsetType = checkNotNull(type).toString();
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param type the vertex position.
+ * @param timeOutInMillis action's timeout setting in milliseconds.
+ */
+ public GetOffsetAction(OffsetType type, long timeOutInMillis) {
+ super("get_offset", timeOutInMillis);
+ this.offsetType = checkNotNull(type).toString();
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetResponse.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetResponse.java
new file mode 100644
index 0000000..52fcd4c
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetResponse.java
@@ -0,0 +1,140 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse;
+import androidx.test.espresso.flutter.internal.protocol.impl.GetOffsetAction.OffsetType;
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.Expose;
+
+/**
+ * Represents the {@code result} section in a {@code JsonRpcResponse} that's the response of a
+ * {@code GetOffsetAction}.
+ */
+final class GetOffsetResponse {
+
+ private static final Gson gson = new Gson();
+
+ @Expose private boolean isError;
+ @Expose private Coordinates response;
+ @Expose private String type;
+
+ private GetOffsetResponse() {}
+
+ /**
+ * Builds the {@code GetOffsetResponse} out of the JSON-RPC response.
+ *
+ * @param jsonRpcResponse the JSON-RPC response. Cannot be {@code null}.
+ * @return a {@code GetOffsetResponse} instance that's parsed out from the JSON-RPC response.
+ */
+ public static GetOffsetResponse fromJsonRpcResponse(JsonRpcResponse jsonRpcResponse) {
+ checkNotNull(jsonRpcResponse, "The JSON-RPC response cannot be null.");
+ if (jsonRpcResponse.getResult() == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving a Flutter widget's geometry info. Response"
+ + " received: %s.",
+ jsonRpcResponse));
+ }
+ try {
+ return gson.fromJson(jsonRpcResponse.getResult(), GetOffsetResponse.class);
+ } catch (JsonSyntaxException e) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving a Flutter widget's geometry info. Response"
+ + " received: %s.",
+ jsonRpcResponse),
+ e);
+ }
+ }
+
+ /** Returns whether this is an error response. */
+ public boolean isError() {
+ return isError;
+ }
+
+ /** Returns the vertex position. */
+ public OffsetType getType() {
+ return OffsetType.fromString(type);
+ }
+
+ /** Returns the X-Coordinate. */
+ public float getX() {
+ if (response == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving a Flutter widget's geometry info. Response"
+ + " received: %s",
+ this));
+ } else {
+ return response.dx;
+ }
+ }
+
+ /** Returns the Y-Coordinate. */
+ public float getY() {
+ if (response == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving a Flutter widget's geometry info. Response"
+ + " received: %s",
+ this));
+ } else {
+ return response.dy;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return gson.toJson(this);
+ }
+
+ static class Coordinates {
+
+ @Expose private float dx;
+ @Expose private float dy;
+
+ Coordinates() {}
+
+ Coordinates(float dx, float dy) {
+ this.dx = dx;
+ this.dy = dy;
+ }
+ }
+
+ static class Builder {
+ private boolean isError;
+ private Coordinates coordinate;
+ private OffsetType type;
+
+ public Builder() {}
+
+ public Builder setIsError(boolean isError) {
+ this.isError = isError;
+ return this;
+ }
+
+ public Builder setCoordinates(float dx, float dy) {
+ this.coordinate = new Coordinates(dx, dy);
+ return this;
+ }
+
+ public Builder setType(OffsetType type) {
+ this.type = checkNotNull(type);
+ return this;
+ }
+
+ public GetOffsetResponse build() {
+ GetOffsetResponse response = new GetOffsetResponse();
+ response.isError = this.isError;
+ response.response = this.coordinate;
+ response.type = checkNotNull(type).toString();
+ return response;
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetVmResponse.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetVmResponse.java
new file mode 100644
index 0000000..2fe0d44
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetVmResponse.java
@@ -0,0 +1,127 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse;
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.Expose;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a response of a <a
+ * href="https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#getvm">getVM()</a>
+ * request.
+ */
+public class GetVmResponse {
+
+ private static final Gson gson = new Gson();
+
+ @Expose private List<Isolate> isolates;
+
+ private GetVmResponse() {}
+
+ /**
+ * Builds the {@code GetVmResponse} out of the JSON-RPC response.
+ *
+ * @param jsonRpcResponse the JSON-RPC response. Cannot be {@code null}.
+ * @return a {@code GetVmResponse} instance that's parsed out from the JSON-RPC response.
+ */
+ public static GetVmResponse fromJsonRpcResponse(JsonRpcResponse jsonRpcResponse) {
+ checkNotNull(jsonRpcResponse, "The JSON-RPC response cannot be null.");
+ if (jsonRpcResponse.getResult() == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving Dart VM info. Response received: %s.",
+ jsonRpcResponse));
+ }
+ try {
+ return gson.fromJson(jsonRpcResponse.getResult(), GetVmResponse.class);
+ } catch (JsonSyntaxException e) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving Dart VM info. Response received: %s.",
+ jsonRpcResponse),
+ e);
+ }
+ }
+
+ /** Returns the number of isolates living in the Dart VM. */
+ public int getIsolateNum() {
+ return isolates == null ? 0 : isolates.size();
+ }
+
+ /** Returns the Dart isolate listed at the given index. */
+ public Isolate getIsolate(int index) {
+ if (isolates == null) {
+ return null;
+ } else if (index < 0 || index >= isolates.size()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Illegal Dart isolate index: %d. Should be in the range [%d, %d]",
+ index, 0, isolates.size() - 1));
+ } else {
+ return isolates.get(index);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return gson.toJson(this);
+ }
+
+ /** Represents a Dart isolate. */
+ static class Isolate {
+
+ @Expose private String id;
+ @Expose private boolean runnable;
+ @Expose private List<String> extensionRpcList;
+
+ Isolate() {}
+
+ Isolate(String id, boolean runnable) {
+ this.id = id;
+ this.runnable = runnable;
+ }
+
+ /** Gets the Dart isolate ID. */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Checks whether the Dart isolate is in a runnable state. True if it's runnable, false
+ * otherwise.
+ */
+ public boolean isRunnable() {
+ return runnable;
+ }
+
+ /** Gets the list of extension RPCs registered at this Dart isolate. Could be {@code null}. */
+ public List<String> getExtensionRpcList() {
+ return extensionRpcList;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof Isolate) {
+ Isolate isolate = (Isolate) obj;
+ return Objects.equals(isolate.id, this.id)
+ && Objects.equals(isolate.runnable, this.runnable)
+ && Objects.equals(isolate.extensionRpcList, this.extensionRpcList);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, runnable, extensionRpcList);
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsAction.java
new file mode 100644
index 0000000..5982ee4
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsAction.java
@@ -0,0 +1,27 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import com.google.gson.annotations.Expose;
+
+/** Represents an action that retrieves the Flutter widget's diagnostics information. */
+final class GetWidgetDiagnosticsAction extends SyntheticAction {
+
+ @Expose private final String diagnosticsType = "widget";
+
+ /**
+ * Sets the depth of the retrieved diagnostics tree as 0. This means only the information of the
+ * root widget will be retrieved.
+ */
+ @Expose private final int subtreeDepth = 0;
+
+ /** Always includes the diagnostics properties of this widget. */
+ @Expose private final boolean includeProperties = true;
+
+ GetWidgetDiagnosticsAction() {
+ super("get_diagnostics_tree");
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsResponse.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsResponse.java
new file mode 100644
index 0000000..65a456c
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsResponse.java
@@ -0,0 +1,189 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.util.Log;
+import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Ascii;
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import java.util.List;
+import java.util.Objects;
+
+/** Represents a response of the {@code GetWidgetDiagnosticsAction}. */
+final class GetWidgetDiagnosticsResponse {
+
+ private static final String TAG = GetWidgetDiagnosticsResponse.class.getSimpleName();
+ private static final Gson gson = new Gson();
+
+ @Expose private boolean isError;
+
+ @Expose
+ @SerializedName("response")
+ private DiagnosticNodeInfo widgetInfo;
+
+ private GetWidgetDiagnosticsResponse() {}
+
+ /**
+ * Builds the {@code GetWidgetDiagnosticsResponse} out of the JSON-RPC response.
+ *
+ * @param jsonRpcResponse the JSON-RPC response. Cannot be {@code null}.
+ * @return a {@code GetWidgetDiagnosticsResponse} instance that's parsed out from the JSON-RPC
+ * response.
+ */
+ public static GetWidgetDiagnosticsResponse fromJsonRpcResponse(JsonRpcResponse jsonRpcResponse) {
+ checkNotNull(jsonRpcResponse, "The JSON-RPC response cannot be null.");
+ if (jsonRpcResponse.getResult() == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving widget's diagnostics info. Response received: %s.",
+ jsonRpcResponse));
+ }
+ try {
+ return gson.fromJson(jsonRpcResponse.getResult(), GetWidgetDiagnosticsResponse.class);
+ } catch (JsonSyntaxException e) {
+ throw new FlutterProtocolException(
+ String.format(
+ "Error occurred during retrieving widget's diagnostics info. Response received: %s.",
+ jsonRpcResponse),
+ e);
+ }
+ }
+
+ /** Returns whether this is an error response. */
+ public boolean isError() {
+ return isError;
+ }
+
+ /** Returns the runtime type of this widget, or {@code null} if the type info is not available. */
+ public String getRuntimeType() {
+ if (widgetInfo == null) {
+ Log.w(TAG, "Widget info is null.");
+ return null;
+ } else {
+ return widgetInfo.runtimeType;
+ }
+ }
+
+ /**
+ * Gets the widget property by its name, or null if the property doesn't exist.
+ *
+ * @param propertyName the property name. Cannot be {@code null}.
+ */
+ public WidgetProperty getPropertyByName(String propertyName) {
+ checkNotNull(propertyName, "Widget property name cannot be null.");
+ if (widgetInfo == null) {
+ Log.w(TAG, "Widget info is null.");
+ return null;
+ }
+ return widgetInfo.getPropertyByName(propertyName);
+ }
+
+ /**
+ * Returns the description of this widget, or {@code null} if the diagnostics info is not
+ * available.
+ */
+ public String getDescription() {
+ if (widgetInfo == null) {
+ Log.w(TAG, "Widget info is null.");
+ return null;
+ }
+ return widgetInfo.description;
+ }
+
+ /**
+ * Returns whether this widget has children, or {@code false} if the diagnostics info is not
+ * available.
+ */
+ public boolean isHasChildren() {
+ if (widgetInfo == null) {
+ Log.w(TAG, "Widget info is null.");
+ return false;
+ }
+ return widgetInfo.hasChildren;
+ }
+
+ @Override
+ public String toString() {
+ return gson.toJson(this);
+ }
+
+ /** A data structure that holds a widget's diagnostics info. */
+ static class DiagnosticNodeInfo {
+
+ @Expose
+ @SerializedName("widgetRuntimeType")
+ private String runtimeType;
+
+ @Expose private List<WidgetProperty> properties;
+ @Expose private String description;
+ @Expose private boolean hasChildren;
+
+ WidgetProperty getPropertyByName(String propertyName) {
+ checkNotNull(propertyName, "Widget property name cannot be null.");
+ if (properties == null) {
+ Log.w(TAG, "Widget property list is null.");
+ return null;
+ }
+ for (WidgetProperty property : properties) {
+ if (Ascii.equalsIgnoreCase(propertyName, property.getName())) {
+ return property;
+ }
+ }
+ return null;
+ }
+ }
+
+ /** Represents a widget property. */
+ static class WidgetProperty {
+ @Expose private final String name;
+ @Expose private final String value;
+ @Expose private final String description;
+
+ @VisibleForTesting
+ WidgetProperty(String name, String value, String description) {
+ this.name = name;
+ this.value = value;
+ this.description = description;
+ }
+
+ /** Returns the name of this widget property. */
+ public String getName() {
+ return name;
+ }
+
+ /** Returns the value of this widget property. */
+ public String getValue() {
+ return value;
+ }
+
+ /** Returns the description of this widget property. */
+ public String getDescription() {
+ return description;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof WidgetProperty)) {
+ return false;
+ } else {
+ WidgetProperty widgetProperty = (WidgetProperty) obj;
+ return Objects.equals(this.name, widgetProperty.name)
+ && Objects.equals(this.value, widgetProperty.value)
+ && Objects.equals(this.description, widgetProperty.description);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, value, description);
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingFrameCondition.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingFrameCondition.java
new file mode 100644
index 0000000..7e7739b
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingFrameCondition.java
@@ -0,0 +1,15 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+/**
+ * Represents a condition that waits until no pending frame is scheduled in the Flutter framework.
+ */
+class NoPendingFrameCondition extends WaitCondition {
+
+ public NoPendingFrameCondition() {
+ super("NoPendingFrameCondition");
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingPlatformMessagesCondition.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingPlatformMessagesCondition.java
new file mode 100644
index 0000000..8430ee2
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingPlatformMessagesCondition.java
@@ -0,0 +1,16 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+/**
+ * Represents a condition that waits until there are no pending platform messages in the Flutter's
+ * platform channels.
+ */
+class NoPendingPlatformMessagesCondition extends WaitCondition {
+
+ public NoPendingPlatformMessagesCondition() {
+ super("NoPendingPlatformMessagesCondition");
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoTransientCallbacksCondition.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoTransientCallbacksCondition.java
new file mode 100644
index 0000000..4548b28
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoTransientCallbacksCondition.java
@@ -0,0 +1,13 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+/** Represents a condition that waits until no transient callbacks in the Flutter framework. */
+class NoTransientCallbacksCondition extends WaitCondition {
+
+ public NoTransientCallbacksCondition() {
+ super("NoTransientCallbacksCondition");
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitCondition.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitCondition.java
new file mode 100644
index 0000000..7017e88
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitCondition.java
@@ -0,0 +1,18 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/** The base class that represents a wait condition in the Flutter app. */
+abstract class WaitCondition {
+ // Used in JSON serialization.
+ @SuppressWarnings("unused")
+ private final String conditionName;
+
+ public WaitCondition(String conditionName) {
+ this.conditionName = checkNotNull(conditionName);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitForConditionAction.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitForConditionAction.java
new file mode 100644
index 0000000..efbe588
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitForConditionAction.java
@@ -0,0 +1,33 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.SyntheticAction;
+import com.google.gson.Gson;
+import com.google.gson.annotations.Expose;
+
+/**
+ * Represents an action that waits until the specified conditions have been met in the Flutter app.
+ */
+final class WaitForConditionAction extends SyntheticAction {
+
+ private static final Gson gson = new Gson();
+
+ @Expose private final String conditionName = "CombinedCondition";
+
+ @Expose private final String conditions;
+
+ /**
+ * Creates with the given wait conditions.
+ *
+ * @param waitConditions the conditions that this action shall wait for. Cannot be null.
+ */
+ public WaitForConditionAction(WaitCondition... waitConditions) {
+ super("waitForCondition");
+ conditions = gson.toJson(checkNotNull(waitConditions));
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WidgetInfoFactory.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WidgetInfoFactory.java
new file mode 100644
index 0000000..2353577
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WidgetInfoFactory.java
@@ -0,0 +1,91 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.internal.protocol.impl;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import android.util.Log;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import androidx.test.espresso.flutter.model.WidgetInfoBuilder;
+
+/** A factory that creates {@link WidgetInfo} instances. */
+final class WidgetInfoFactory {
+
+ private static final String TAG = WidgetInfoFactory.class.getSimpleName();
+
+ private enum WidgetRuntimeType {
+ TEXT("Text"),
+ RICH_TEXT("RichText"),
+ UNKNOWN("Unknown");
+
+ private WidgetRuntimeType(String typeString) {
+ this.type = typeString;
+ }
+
+ private final String type;
+
+ @Override
+ public String toString() {
+ return type;
+ }
+
+ public static WidgetRuntimeType getType(String typeString) {
+ for (WidgetRuntimeType widgetType : WidgetRuntimeType.values()) {
+ if (widgetType.type.equals(typeString)) {
+ return widgetType;
+ }
+ }
+ return UNKNOWN;
+ }
+ }
+
+ /**
+ * Creates a {@code WidgetInfo} instance based on the given diagnostics info.
+ *
+ * <p>The current implementation is ugly. As the widget's properties are serialized out as JSON
+ * strings, we have to inspect the content based on the widget type.
+ *
+ * @throws FlutterProtocolException when the given {@code widgetDiagnostics} is invalid.
+ */
+ public static WidgetInfo createWidgetInfo(GetWidgetDiagnosticsResponse widgetDiagnostics) {
+ checkNotNull(widgetDiagnostics, "The widget diagnostics instance is null.");
+ WidgetInfoBuilder widgetInfo = new WidgetInfoBuilder();
+ if (widgetDiagnostics.getRuntimeType() == null) {
+ throw new FlutterProtocolException(
+ String.format(
+ "The widget diagnostics info must contain the runtime type of the widget. Illegal"
+ + " widget diagnostics info: %s.",
+ widgetDiagnostics));
+ }
+ widgetInfo.setRuntimeType(widgetDiagnostics.getRuntimeType());
+
+ // Ugly, but let's figure out a better way as this evolves.
+ switch (WidgetRuntimeType.getType(widgetDiagnostics.getRuntimeType())) {
+ case TEXT:
+ // Flutter Text Widget's "data" field stores the text info.
+ if (widgetDiagnostics.getPropertyByName("data") != null) {
+ String text = widgetDiagnostics.getPropertyByName("data").getValue();
+ widgetInfo.setText(text);
+ }
+ break;
+ case RICH_TEXT:
+ if (widgetDiagnostics.getPropertyByName("text") != null) {
+ String richText = widgetDiagnostics.getPropertyByName("text").getValue();
+ widgetInfo.setText(richText);
+ }
+ break;
+ default:
+ // Let's be silent when we know little about the widget's type.
+ // The widget's fields will be mostly empty but it can be used for checking the existence
+ // of the widget.
+ Log.i(
+ TAG,
+ String.format(
+ "Unknown widget type: %s. Widget diagnostics info: %s.",
+ widgetDiagnostics.getRuntimeType(), widgetDiagnostics));
+ }
+ return widgetInfo.build();
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/FlutterMatchers.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/FlutterMatchers.java
new file mode 100644
index 0000000..5a272f2
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/FlutterMatchers.java
@@ -0,0 +1,105 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import android.view.View;
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import io.flutter.embedding.android.FlutterView;
+import javax.annotation.Nonnull;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+
+/** A collection of matchers that match a Flutter view or Flutter widgets. */
+public final class FlutterMatchers {
+
+ /**
+ * Returns a matcher that matches a {@link FlutterView} or a legacy {@code
+ * io.flutter.view.FlutterView}.
+ */
+ public static Matcher<View> isFlutterView() {
+ return new IsFlutterViewMatcher();
+ }
+
+ /**
+ * Returns a matcher that matches a Flutter widget's tooltip.
+ *
+ * @param tooltip the tooltip String to match. Cannot be {@code null}.
+ */
+ public static WidgetMatcher withTooltip(@Nonnull String tooltip) {
+ return new WithTooltipMatcher(tooltip);
+ }
+
+ /**
+ * Returns a matcher that matches a Flutter widget's value key.
+ *
+ * @param valueKey the value key String to match. Cannot be {@code null}.
+ */
+ public static WidgetMatcher withValueKey(@Nonnull String valueKey) {
+ return new WithValueKeyMatcher(valueKey);
+ }
+
+ /**
+ * Returns a matcher that matches a Flutter widget's runtime type.
+ *
+ * <p>Usage:
+ *
+ * <p>{@code withType("TextField")} can be used to match a Flutter <a
+ * href="https://api.flutter.dev/flutter/material/TextField-class.html">TextField</a> widget.
+ *
+ * @param type the type String to match. Cannot be {@code null}.
+ */
+ public static WidgetMatcher withType(@Nonnull String type) {
+ return new WithTypeMatcher(type);
+ }
+
+ /**
+ * Returns a matcher that matches a Flutter widget's text.
+ *
+ * @param text the text String to match. Cannot be {@code null}.
+ */
+ public static WidgetMatcher withText(@Nonnull String text) {
+ return new WithTextMatcher(text);
+ }
+
+ /**
+ * Returns a matcher that matches a Flutter widget based on the given ancestor matcher.
+ *
+ * @param ancestorMatcher the ancestor to match on. Cannot be null.
+ * @param widgetMatcher the widget to match on. Cannot be null.
+ */
+ public static WidgetMatcher isDescendantOf(
+ @Nonnull WidgetMatcher ancestorMatcher, @Nonnull WidgetMatcher widgetMatcher) {
+ return new IsDescendantOfMatcher(ancestorMatcher, widgetMatcher);
+ }
+
+ /**
+ * Returns a matcher that checks the existence of a Flutter widget.
+ *
+ * <p>Note, this matcher only guarantees that the widget exists in Flutter's widget tree, but not
+ * necessarily displayed on screen, e.g. the widget is in the cache extend of a Scrollable, but
+ * not scrolled onto the screen.
+ */
+ public static Matcher<WidgetInfo> isExisting() {
+ return new IsExistingMatcher();
+ }
+
+ static final class IsFlutterViewMatcher extends TypeSafeMatcher<View> {
+
+ private IsFlutterViewMatcher() {}
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("is a FlutterView");
+ }
+
+ @Override
+ public boolean matchesSafely(View flutterView) {
+ return flutterView instanceof FlutterView
+ || (flutterView instanceof io.flutter.view.FlutterView);
+ }
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsDescendantOfMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsDescendantOfMatcher.java
new file mode 100644
index 0000000..24a4415
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsDescendantOfMatcher.java
@@ -0,0 +1,75 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import org.hamcrest.Description;
+
+/** A matcher that matches a Flutter widget with a given ancestor. */
+public final class IsDescendantOfMatcher extends WidgetMatcher {
+
+ private static final Gson gson =
+ new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
+
+ private final WidgetMatcher ancestorMatcher;
+ private final WidgetMatcher widgetMatcher;
+
+ // Flutter Driver extension APIs only support JSON strings, not other JSON structures.
+ // Thus, explicitly convert the matchers to JSON strings.
+ @SerializedName("of")
+ @Expose
+ private final String jsonAncestorMatcher;
+
+ @SerializedName("matching")
+ @Expose
+ private final String jsonWidgetMatcher;
+
+ IsDescendantOfMatcher(
+ @Nonnull WidgetMatcher ancestorMatcher, @Nonnull WidgetMatcher widgetMatcher) {
+ super("Descendant");
+ this.ancestorMatcher = checkNotNull(ancestorMatcher);
+ this.widgetMatcher = checkNotNull(widgetMatcher);
+ jsonAncestorMatcher = gson.toJson(ancestorMatcher);
+ jsonWidgetMatcher = gson.toJson(widgetMatcher);
+ }
+
+ /** Returns the matcher to match the widget's ancestor. */
+ public WidgetMatcher getAncestorMatcher() {
+ return ancestorMatcher;
+ }
+
+ /** Returns the matcher to match the widget itself. */
+ public WidgetMatcher getWidgetMatcher() {
+ return widgetMatcher;
+ }
+
+ @Override
+ public String toString() {
+ return "matched with " + widgetMatcher + " with ancestor: " + ancestorMatcher;
+ }
+
+ @Override
+ protected boolean matchesSafely(WidgetInfo widget) {
+ // TODO: Using this matcher in the assertion is not supported yet.
+ throw new UnsupportedOperationException("IsDescendantMatcher is not supported for assertion.");
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description
+ .appendText("matched with ")
+ .appendText(widgetMatcher.toString())
+ .appendText(" with ancestor: ")
+ .appendText(ancestorMatcher.toString());
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsExistingMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsExistingMatcher.java
new file mode 100644
index 0000000..3380d21
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsExistingMatcher.java
@@ -0,0 +1,31 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+
+/** A matcher that checks the existence of a Flutter widget. */
+public final class IsExistingMatcher extends TypeSafeMatcher<WidgetInfo> {
+
+ /** Constructs the matcher. */
+ IsExistingMatcher() {}
+
+ @Override
+ public String toString() {
+ return "is existing";
+ }
+
+ @Override
+ protected boolean matchesSafely(WidgetInfo widget) {
+ return widget != null;
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("should exist.");
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTextMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTextMatcher.java
new file mode 100644
index 0000000..4b86aed
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTextMatcher.java
@@ -0,0 +1,49 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.gson.annotations.Expose;
+import javax.annotation.Nonnull;
+import org.hamcrest.Description;
+
+/** A matcher that matches a Flutter widget with a given text. */
+public final class WithTextMatcher extends WidgetMatcher {
+
+ @Expose private final String text;
+
+ /**
+ * Constructs the matcher with the given text to be matched with.
+ *
+ * @param text the text to be matched with.
+ */
+ WithTextMatcher(@Nonnull String text) {
+ super("ByText");
+ this.text = checkNotNull(text);
+ }
+
+ /** Returns the text string that shall be matched for the widget. */
+ public String getText() {
+ return text;
+ }
+
+ @Override
+ public String toString() {
+ return "with text: " + text;
+ }
+
+ @Override
+ protected boolean matchesSafely(WidgetInfo widget) {
+ return text.equals(widget.getText());
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("with text: ").appendText(text);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTooltipMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTooltipMatcher.java
new file mode 100644
index 0000000..27d4314
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTooltipMatcher.java
@@ -0,0 +1,52 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import org.hamcrest.Description;
+
+/** A matcher that matches a Flutter widget with a given tooltip. */
+public final class WithTooltipMatcher extends WidgetMatcher {
+
+ @Expose
+ @SerializedName("text")
+ private final String tooltip;
+
+ /**
+ * Constructs the matcher with the given {@code tooltip} to be matched with.
+ *
+ * @param tooltip the tooltip to be matched with.
+ */
+ public WithTooltipMatcher(@Nonnull String tooltip) {
+ super("ByTooltipMessage");
+ this.tooltip = checkNotNull(tooltip);
+ }
+
+ /** Returns the tooltip string that shall be matched for the widget. */
+ public String getTooltip() {
+ return tooltip;
+ }
+
+ @Override
+ public String toString() {
+ return "with tooltip: " + tooltip;
+ }
+
+ @Override
+ protected boolean matchesSafely(WidgetInfo widget) {
+ return tooltip.equals(widget.getTooltip());
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("with tooltip: ").appendText(tooltip);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTypeMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTypeMatcher.java
new file mode 100644
index 0000000..84cf0e0
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTypeMatcher.java
@@ -0,0 +1,49 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.gson.annotations.Expose;
+import javax.annotation.Nonnull;
+import org.hamcrest.Description;
+
+/** A matcher that matches a Flutter widget with a given runtime type. */
+public final class WithTypeMatcher extends WidgetMatcher {
+
+ @Expose private final String type;
+
+ /**
+ * Constructs the matcher with the given runtime type to be matched with.
+ *
+ * @param type the runtime type to be matched with.
+ */
+ public WithTypeMatcher(@Nonnull String type) {
+ super("ByType");
+ this.type = checkNotNull(type);
+ }
+
+ /** Returns the type string that shall be matched for the widget. */
+ public String getType() {
+ return type;
+ }
+
+ @Override
+ public String toString() {
+ return "with runtime type: " + type;
+ }
+
+ @Override
+ protected boolean matchesSafely(WidgetInfo widget) {
+ return type.equals(widget.getType());
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("with runtime type: ").appendText(type);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithValueKeyMatcher.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithValueKeyMatcher.java
new file mode 100644
index 0000000..0e3df39
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithValueKeyMatcher.java
@@ -0,0 +1,54 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.matcher;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import androidx.test.espresso.flutter.api.WidgetMatcher;
+import androidx.test.espresso.flutter.model.WidgetInfo;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+import javax.annotation.Nonnull;
+import org.hamcrest.Description;
+
+/** A matcher that matches a Flutter widget with a given value key. */
+public final class WithValueKeyMatcher extends WidgetMatcher {
+
+ @Expose
+ @SerializedName("keyValueString")
+ private final String valueKey;
+
+ @Expose private final String keyValueType = "String";
+
+ /**
+ * Constructs the matcher with the given value key String to be matched with.
+ *
+ * @param valueKey the value key String to be matched with.
+ */
+ public WithValueKeyMatcher(@Nonnull String valueKey) {
+ super("ByValueKey");
+ this.valueKey = checkNotNull(valueKey);
+ }
+
+ /** Returns the value key string that shall be matched for the widget. */
+ public String getValueKey() {
+ return valueKey;
+ }
+
+ @Override
+ public String toString() {
+ return "with value key: " + valueKey;
+ }
+
+ @Override
+ protected boolean matchesSafely(WidgetInfo widget) {
+ return valueKey.equals(widget.getValueKey());
+ }
+
+ @Override
+ public void describeTo(Description description) {
+ description.appendText("with value key: ").appendText(valueKey);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfo.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfo.java
new file mode 100644
index 0000000..d6394d2
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfo.java
@@ -0,0 +1,109 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.model;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.annotations.Beta;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Represents a Flutter widget, containing all the properties that are accessible in Espresso.
+ *
+ * <p>Note, this class should typically be decoded from the Flutter testing protocol. Users of
+ * Espresso testing framework should rarely have the needs to build their own {@link WidgetInfo}
+ * instance.
+ *
+ * <p>Also, the current implementation is hard-coded and potentially only works with a limited set
+ * of {@code WidgetMatchers}. Later, we might consider codegen of representations for Flutter
+ * widgets for extensibility.
+ */
+@Beta
+public class WidgetInfo {
+
+ /** A String representation of a Flutter widget's ValueKey. */
+ @Nullable private final String valueKey;
+ /** A String representation of the runtime type of the widget. */
+ private final String runtimeType;
+ /** The widget's text property. */
+ @Nullable private final String text;
+ /** The widget's tooltip property. */
+ @Nullable private final String tooltip;
+
+ WidgetInfo(
+ @Nullable String valueKey,
+ String runtimeType,
+ @Nullable String text,
+ @Nullable String tooltip) {
+ this.valueKey = valueKey;
+ this.runtimeType = checkNotNull(runtimeType, "RuntimeType cannot be null.");
+ this.text = text;
+ this.tooltip = tooltip;
+ }
+
+ /** Returns a String representation of the Flutter widget's ValueKey. Could be null. */
+ @Nullable
+ public String getValueKey() {
+ return valueKey;
+ }
+
+ /** Returns a String representation of the runtime type of the Flutter widget. */
+ @Nonnull
+ public String getType() {
+ return runtimeType;
+ }
+
+ /** Returns the widget's 'text' property. Will be null for widgets without a 'text' property. */
+ @Nullable
+ public String getText() {
+ return text;
+ }
+
+ /**
+ * Returns the widget's 'tooltip' property. Will be null for widgets without a 'tooltip' property.
+ */
+ @Nullable
+ public String getTooltip() {
+ return tooltip;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof WidgetInfo) {
+ WidgetInfo widget = (WidgetInfo) obj;
+ return Objects.equals(widget.valueKey, this.valueKey)
+ && Objects.equals(widget.runtimeType, this.runtimeType)
+ && Objects.equals(widget.text, this.text)
+ && Objects.equals(widget.tooltip, this.tooltip);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(valueKey, runtimeType, text, tooltip);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("Widget [");
+ sb.append("runtimeType=").append(runtimeType).append(",");
+ if (valueKey != null) {
+ sb.append("valueKey=").append(valueKey).append(",");
+ }
+ if (text != null) {
+ sb.append("text=").append(text).append(",");
+ }
+ if (tooltip != null) {
+ sb.append("tooltip=").append(tooltip).append(",");
+ }
+ sb.append("]");
+ return sb.toString();
+ }
+}
diff --git a/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfoBuilder.java b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfoBuilder.java
new file mode 100644
index 0000000..53ea8a2
--- /dev/null
+++ b/packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfoBuilder.java
@@ -0,0 +1,81 @@
+// Copyright 2019 The Chromium 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 androidx.test.espresso.flutter.model;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Builder for {@link WidgetInfo}.
+ *
+ * <p>Internal only. Users of Espresso framework should rarely have the needs to build their own
+ * {@link WidgetInfo} instance.
+ */
+public class WidgetInfoBuilder {
+
+ @Nullable private String valueKey;
+ private String runtimeType;
+ @Nullable private String text;
+ @Nullable private String tooltip;
+
+ /** Empty constructor. */
+ public WidgetInfoBuilder() {}
+
+ /**
+ * Constructs the builder with the given {@code runtimeType}.
+ *
+ * @param runtimeType the runtime type of the widget. Cannot be null.
+ */
+ public WidgetInfoBuilder(@Nonnull String runtimeType) {
+ this.runtimeType = checkNotNull(runtimeType, "RuntimeType cannot be null.");
+ }
+
+ /**
+ * Sets the value key of the widget.
+ *
+ * @param valueKey the value key of the widget that shall be set. Could be null.
+ */
+ public WidgetInfoBuilder setValueKey(@Nullable String valueKey) {
+ this.valueKey = valueKey;
+ return this;
+ }
+
+ /**
+ * Sets the runtime type of the widget.
+ *
+ * @param runtimeType the runtime type of the widget that shall be set. Cannot be null.
+ */
+ public WidgetInfoBuilder setRuntimeType(@Nonnull String runtimeType) {
+ this.runtimeType = checkNotNull(runtimeType, "RuntimeType cannot be null.");
+ return this;
+ }
+
+ /**
+ * Sets the text of the widget.
+ *
+ * @param text the text of the widget that shall be set. Can be null.
+ */
+ public WidgetInfoBuilder setText(@Nullable String text) {
+ this.text = text;
+ return this;
+ }
+
+ /**
+ * Sets the tooltip of the widget.
+ *
+ * @param tooltip the tooltip of the widget that shall be set. Can be null.
+ */
+ public WidgetInfoBuilder setTooltip(@Nullable String tooltip) {
+ this.tooltip = tooltip;
+ return this;
+ }
+
+ /** Builds and returns the {@code WidgetInfo} instance. */
+ public WidgetInfo build() {
+ return new WidgetInfo(valueKey, runtimeType, text, tooltip);
+ }
+}
diff --git a/packages/espresso/android/src/main/java/com/example/espresso/EspressoPlugin.java b/packages/espresso/android/src/main/java/com/example/espresso/EspressoPlugin.java
new file mode 100644
index 0000000..966a7c1
--- /dev/null
+++ b/packages/espresso/android/src/main/java/com/example/espresso/EspressoPlugin.java
@@ -0,0 +1,45 @@
+package com.example.espresso;
+
+import androidx.annotation.NonNull;
+import io.flutter.embedding.engine.plugins.FlutterPlugin;
+import io.flutter.plugin.common.MethodCall;
+import io.flutter.plugin.common.MethodChannel;
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
+import io.flutter.plugin.common.MethodChannel.Result;
+import io.flutter.plugin.common.PluginRegistry.Registrar;
+
+/** EspressoPlugin */
+public class EspressoPlugin implements FlutterPlugin, MethodCallHandler {
+ @Override
+ public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
+ final MethodChannel channel =
+ new MethodChannel(flutterPluginBinding.getFlutterEngine().getDartExecutor(), "espresso");
+ channel.setMethodCallHandler(new EspressoPlugin());
+ }
+
+ // This static function is optional and equivalent to onAttachedToEngine. It supports the old
+ // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting
+ // plugin registration via this function while apps migrate to use the new Android APIs
+ // post-flutter-1.12 via https://flutter.dev/go/android-project-migration.
+ //
+ // It is encouraged to share logic between onAttachedToEngine and registerWith to keep
+ // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called
+ // depending on the user's project. onAttachedToEngine or registerWith must both be defined
+ // in the same class.
+ public static void registerWith(Registrar registrar) {
+ final MethodChannel channel = new MethodChannel(registrar.messenger(), "espresso");
+ channel.setMethodCallHandler(new EspressoPlugin());
+ }
+
+ @Override
+ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
+ if (call.method.equals("getPlatformVersion")) {
+ result.success("Android " + android.os.Build.VERSION.RELEASE);
+ } else {
+ result.notImplemented();
+ }
+ }
+
+ @Override
+ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {}
+}
diff --git a/packages/espresso/example/.gitignore b/packages/espresso/example/.gitignore
new file mode 100644
index 0000000..ae1f183
--- /dev/null
+++ b/packages/espresso/example/.gitignore
@@ -0,0 +1,37 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+/build/
+
+# Web related
+lib/generated_plugin_registrant.dart
+
+# Exceptions to above rules.
+!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
diff --git a/packages/espresso/example/.metadata b/packages/espresso/example/.metadata
new file mode 100644
index 0000000..e1188cd
--- /dev/null
+++ b/packages/espresso/example/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: 0190e40457d43e17bdfaf046dfa634cbc5bf28b9
+ channel: unknown
+
+project_type: app
diff --git a/packages/espresso/example/README.md b/packages/espresso/example/README.md
new file mode 100644
index 0000000..224544e
--- /dev/null
+++ b/packages/espresso/example/README.md
@@ -0,0 +1,14 @@
+# espresso_example
+
+Demonstrates how to use the espresso package.
+
+The espresso package only runs tests on Android. The example runs on iOS, but this is only to keep our continuous integration bots green.
+
+## Getting Started
+
+To run the Espresso tests:
+
+```
+flutter build apk --debug
+./gradlew app:connectedAndroidTest
+```
diff --git a/packages/espresso/example/android/.gitignore b/packages/espresso/example/android/.gitignore
new file mode 100644
index 0000000..bc2100d
--- /dev/null
+++ b/packages/espresso/example/android/.gitignore
@@ -0,0 +1,7 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
diff --git a/packages/espresso/example/android/app/build.gradle b/packages/espresso/example/android/app/build.gradle
new file mode 100644
index 0000000..0be4156
--- /dev/null
+++ b/packages/espresso/example/android/app/build.gradle
@@ -0,0 +1,88 @@
+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 "com.example.espresso_example"
+ minSdkVersion 16
+ targetSdkVersion 28
+ versionCode flutterVersionCode.toInteger()
+ versionName flutterVersionName
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ 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 '../..'
+}
+
+dependencies {
+ testImplementation 'junit:junit:4.12'
+ testImplementation "com.google.truth:truth:1.0"
+ androidTestImplementation 'androidx.test:runner:1.1.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
+
+ // Core library
+ api 'androidx.test:core:1.2.0'
+
+ // AndroidJUnitRunner and JUnit Rules
+ androidTestImplementation 'androidx.test:runner:1.1.0'
+ androidTestImplementation 'androidx.test:rules:1.1.0'
+
+ // Assertions
+ androidTestImplementation 'androidx.test.ext:junit:1.0.0'
+ androidTestImplementation 'androidx.test.ext:truth:1.0.0'
+ androidTestImplementation 'com.google.truth:truth:0.42'
+
+ // Espresso dependencies
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.1.0'
+ androidTestImplementation 'androidx.test.espresso:espresso-web:3.1.0'
+ androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.1.0'
+
+ // The following Espresso dependency can be either "implementation"
+ // or "androidTestImplementation", depending on whether you want the
+ // dependency to appear on your APK's compile classpath or the test APK
+ // classpath.
+ androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.1.0'
+}
diff --git a/packages/espresso/example/android/app/src/androidTest/java/com/example/MainActivityTest.java b/packages/espresso/example/android/app/src/androidTest/java/com/example/MainActivityTest.java
new file mode 100644
index 0000000..aaedd6c
--- /dev/null
+++ b/packages/espresso/example/android/app/src/androidTest/java/com/example/MainActivityTest.java
@@ -0,0 +1,76 @@
+// Copyright 2019 The Chromium 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 com.example.espresso_example;
+
+import static androidx.test.espresso.flutter.EspressoFlutter.onFlutterWidget;
+import static androidx.test.espresso.flutter.action.FlutterActions.click;
+import static androidx.test.espresso.flutter.action.FlutterActions.syntheticClick;
+import static androidx.test.espresso.flutter.assertion.FlutterAssertions.matches;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withText;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withTooltip;
+import static androidx.test.espresso.flutter.matcher.FlutterMatchers.withValueKey;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.espresso.flutter.EspressoFlutter.WidgetInteraction;
+import androidx.test.espresso.flutter.assertion.FlutterAssertions;
+import androidx.test.espresso.flutter.matcher.FlutterMatchers;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link EspressoFlutter}. */
+@RunWith(AndroidJUnit4.class)
+public class MainActivityTest {
+
+ @Before
+ public void setUp() throws Exception {
+ ActivityScenario.launch(MainActivity.class);
+ }
+
+ @Test
+ public void performTripleClick() {
+ WidgetInteraction interaction =
+ onFlutterWidget(withTooltip("Increment")).perform(click(), click()).perform(click());
+ assertThat(interaction).isNotNull();
+ onFlutterWidget(withValueKey("CountText")).check(matches(withText("Button tapped 3 times.")));
+ }
+
+ @Test
+ public void performClick() {
+ WidgetInteraction interaction = onFlutterWidget(withTooltip("Increment")).perform(click());
+ assertThat(interaction).isNotNull();
+ onFlutterWidget(withValueKey("CountText")).check(matches(withText("Button tapped 1 time.")));
+ }
+
+ @Test
+ public void performSyntheticClick() {
+ WidgetInteraction interaction =
+ onFlutterWidget(withTooltip("Increment")).perform(syntheticClick());
+ assertThat(interaction).isNotNull();
+ onFlutterWidget(withValueKey("CountText")).check(matches(withText("Button tapped 1 time.")));
+ }
+
+ @Test
+ public void performTwiceSyntheticClicks() {
+ WidgetInteraction interaction =
+ onFlutterWidget(withTooltip("Increment")).perform(syntheticClick(), syntheticClick());
+ assertThat(interaction).isNotNull();
+ onFlutterWidget(withValueKey("CountText")).check(matches(withText("Button tapped 2 times.")));
+ }
+
+ @Test
+ public void isIncrementButtonExists() {
+ onFlutterWidget(FlutterMatchers.withTooltip("Increment"))
+ .check(FlutterAssertions.matches(FlutterMatchers.isExisting()));
+ }
+
+ @Test
+ public void isAppBarExists() {
+ onFlutterWidget(FlutterMatchers.withType("AppBar"))
+ .check(FlutterAssertions.matches(FlutterMatchers.isExisting()));
+ }
+}
diff --git a/packages/espresso/example/android/app/src/debug/AndroidManifest.xml b/packages/espresso/example/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..fc8acdd
--- /dev/null
+++ b/packages/espresso/example/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,8 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.espresso_example">
+ <!-- Flutter needs it to communicate with the running application
+ to allow setting breakpoints, to provide hot reload, etc.
+ -->
+ <uses-permission android:name="android.permission.INTERNET"/>
+ <application android:usesCleartextTraffic="true"/>
+</manifest>
diff --git a/packages/espresso/example/android/app/src/main/AndroidManifest.xml b/packages/espresso/example/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b82df92
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.espresso_example">
+ <!-- io.flutter.app.FlutterApplication is an android.app.Application that
+ calls FlutterMain.startInitialization(this); in its onCreate method.
+ In most cases you can leave this as-is, but you if you want to provide
+ additional functionality it is fine to subclass or reimplement
+ FlutterApplication and put your custom class here. -->
+ <application
+ android:name="io.flutter.app.FlutterApplication"
+ android:label="espresso_example"
+ android:icon="@mipmap/ic_launcher">
+ <activity
+ android:name=".MainActivity"
+ android:launchMode="singleTop"
+ android:theme="@style/LaunchTheme"
+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+ android:hardwareAccelerated="true"
+ android:windowSoftInputMode="adjustResize">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+ <!-- Don't delete the meta-data below.
+ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+ <meta-data
+ android:name="flutterEmbedding"
+ android:value="2" />
+ </application>
+</manifest>
diff --git a/packages/espresso/example/android/app/src/main/java/com/example/espresso_example/MainActivity.java b/packages/espresso/example/android/app/src/main/java/com/example/espresso_example/MainActivity.java
new file mode 100644
index 0000000..413ef9e
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/java/com/example/espresso_example/MainActivity.java
@@ -0,0 +1,10 @@
+package com.example.espresso_example;
+
+import androidx.annotation.NonNull;
+import io.flutter.embedding.android.FlutterActivity;
+import io.flutter.embedding.engine.FlutterEngine;
+
+public class MainActivity extends FlutterActivity {
+ @Override
+ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {}
+}
diff --git a/packages/espresso/example/android/app/src/main/res/drawable/launch_background.xml b/packages/espresso/example/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:drawable="@android:color/white" />
+
+ <!-- You can insert your own image assets here -->
+ <!-- <item>
+ <bitmap
+ android:gravity="center"
+ android:src="@mipmap/launch_image" />
+ </item> -->
+</layer-list>
diff --git a/packages/espresso/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/espresso/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/packages/espresso/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/espresso/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/packages/espresso/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/espresso/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/espresso/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/espresso/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/espresso/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/espresso/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/packages/espresso/example/android/app/src/main/res/values/styles.xml b/packages/espresso/example/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..00fa441
--- /dev/null
+++ b/packages/espresso/example/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+ <!-- Show a splash screen on the activity. Automatically removed when
+ Flutter draws its first frame -->
+ <item name="android:windowBackground">@drawable/launch_background</item>
+ </style>
+</resources>
diff --git a/packages/espresso/example/android/app/src/profile/AndroidManifest.xml b/packages/espresso/example/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..bd9aec9
--- /dev/null
+++ b/packages/espresso/example/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.espresso_example">
+ <!-- Flutter needs it to communicate with the running application
+ to allow setting breakpoints, to provide hot reload, etc.
+ -->
+ <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>
diff --git a/packages/espresso/example/android/build.gradle b/packages/espresso/example/android/build.gradle
new file mode 100644
index 0000000..e0d7ae2
--- /dev/null
+++ b/packages/espresso/example/android/build.gradle
@@ -0,0 +1,29 @@
+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
+}
diff --git a/packages/espresso/example/android/gradle.properties b/packages/espresso/example/android/gradle.properties
new file mode 100644
index 0000000..38c8d45
--- /dev/null
+++ b/packages/espresso/example/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx1536M
+android.enableR8=true
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/packages/espresso/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/espresso/example/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..296b146
--- /dev/null
+++ b/packages/espresso/example/android/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/packages/espresso/example/android/settings.gradle b/packages/espresso/example/android/settings.gradle
new file mode 100644
index 0000000..5a2f14f
--- /dev/null
+++ b/packages/espresso/example/android/settings.gradle
@@ -0,0 +1,15 @@
+include ':app'
+
+def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
+
+def plugins = new Properties()
+def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
+if (pluginsFile.exists()) {
+ pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
+}
+
+plugins.each { name, path ->
+ def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
+ include ":$name"
+ project(":$name").projectDir = pluginDirectory
+}
diff --git a/packages/espresso/example/ios/.gitignore b/packages/espresso/example/ios/.gitignore
new file mode 100644
index 0000000..e96ef60
--- /dev/null
+++ b/packages/espresso/example/ios/.gitignore
@@ -0,0 +1,32 @@
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/packages/espresso/example/ios/Flutter/AppFrameworkInfo.plist b/packages/espresso/example/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..6b4c0f7
--- /dev/null
+++ b/packages/espresso/example/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>App</string>
+ <key>CFBundleIdentifier</key>
+ <string>io.flutter.flutter.app</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>App</string>
+ <key>CFBundlePackageType</key>
+ <string>FMWK</string>
+ <key>CFBundleShortVersionString</key>
+ <string>1.0</string>
+ <key>CFBundleSignature</key>
+ <string>????</string>
+ <key>CFBundleVersion</key>
+ <string>1.0</string>
+ <key>MinimumOSVersion</key>
+ <string>8.0</string>
+</dict>
+</plist>
diff --git a/packages/espresso/example/ios/Flutter/Debug.xcconfig b/packages/espresso/example/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..e8efba1
--- /dev/null
+++ b/packages/espresso/example/ios/Flutter/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "Generated.xcconfig"
diff --git a/packages/espresso/example/ios/Flutter/Release.xcconfig b/packages/espresso/example/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..399e934
--- /dev/null
+++ b/packages/espresso/example/ios/Flutter/Release.xcconfig
@@ -0,0 +1,2 @@
+#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "Generated.xcconfig"
diff --git a/packages/espresso/example/ios/Runner.xcodeproj/project.pbxproj b/packages/espresso/example/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..2209e01
--- /dev/null
+++ b/packages/espresso/example/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,584 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 46;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; };
+ 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; };
+ 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+ B4A70C1E3465B7A2E7ECD8F8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE5F32230E1B4F4C17EDB557 /* Pods_Runner.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */,
+ 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */,
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 02691CEFCB33C0B1CABE7A23 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
+ 09442C04D3DC0049E7725D93 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
+ 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = "<group>"; };
+ 3EF237100A0BFC444DE6BC97 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
+ 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = "<group>"; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+ AE5F32230E1B4F4C17EDB557 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */,
+ 3B80C3941E831B6300D905FE /* App.framework in Frameworks */,
+ B4A70C1E3465B7A2E7ECD8F8 /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 301432828879F7BDE0943C41 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ AE5F32230E1B4F4C17EDB557 /* Pods_Runner.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "<group>";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B80C3931E831B6300D905FE /* App.framework */,
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEBA1CF902C7004384FC /* Flutter.framework */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "<group>";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ E9E5CC94EC52B9D261A44A5E /* Pods */,
+ 301432828879F7BDE0943C41 /* Frameworks */,
+ );
+ sourceTree = "<group>";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 97C146F11CF9000F007C117D /* Supporting Files */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "<group>";
+ };
+ 97C146F11CF9000F007C117D /* Supporting Files */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = "Supporting Files";
+ sourceTree = "<group>";
+ };
+ E9E5CC94EC52B9D261A44A5E /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ 02691CEFCB33C0B1CABE7A23 /* Pods-Runner.debug.xcconfig */,
+ 3EF237100A0BFC444DE6BC97 /* Pods-Runner.release.xcconfig */,
+ 09442C04D3DC0049E7725D93 /* Pods-Runner.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 5D7E711796DC6F61E7F1A6AE /* [CP] Check Pods Manifest.lock */,
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ DC7821945A6EDE472DDF686F /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ LastUpgradeCheck = 1020;
+ ORGANIZATIONNAME = "The Chromium Authors";
+ TargetAttributes = {
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 3.2";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin";
+ };
+ 5D7E711796DC6F61E7F1A6AE /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+ DC7821945A6EDE472DDF686F /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "<group>";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "<group>";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 8.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/Flutter",
+ );
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/Flutter",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.espressoExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 8.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 8.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/Flutter",
+ );
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/Flutter",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.espressoExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/Flutter",
+ );
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(PROJECT_DIR)/Flutter",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.espressoExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/packages/espresso/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/espresso/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..a28140c
--- /dev/null
+++ b/packages/espresso/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+ LastUpgradeVersion = "1020"
+ version = "1.3">
+ <BuildAction
+ parallelizeBuildables = "YES"
+ buildImplicitDependencies = "YES">
+ <BuildActionEntries>
+ <BuildActionEntry
+ buildForTesting = "YES"
+ buildForRunning = "YES"
+ buildForProfiling = "YES"
+ buildForArchiving = "YES"
+ buildForAnalyzing = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </BuildActionEntry>
+ </BuildActionEntries>
+ </BuildAction>
+ <TestAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ shouldUseLaunchSchemeArgsEnv = "YES">
+ <Testables>
+ </Testables>
+ <MacroExpansion>
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </MacroExpansion>
+ <AdditionalOptions>
+ </AdditionalOptions>
+ </TestAction>
+ <LaunchAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ launchStyle = "0"
+ useCustomWorkingDirectory = "NO"
+ ignoresPersistentStateOnLaunch = "NO"
+ debugDocumentVersioning = "YES"
+ debugServiceExtension = "internal"
+ allowLocationSimulation = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ <AdditionalOptions>
+ </AdditionalOptions>
+ </LaunchAction>
+ <ProfileAction
+ buildConfiguration = "Profile"
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ savedToolIdentifier = ""
+ useCustomWorkingDirectory = "NO"
+ debugDocumentVersioning = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ </ProfileAction>
+ <AnalyzeAction
+ buildConfiguration = "Debug">
+ </AnalyzeAction>
+ <ArchiveAction
+ buildConfiguration = "Release"
+ revealArchiveInOrganizer = "YES">
+ </ArchiveAction>
+</Scheme>
diff --git a/packages/espresso/example/ios/Runner/AppDelegate.swift b/packages/espresso/example/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..70693e4
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import UIKit
+import Flutter
+
+@UIApplicationMain
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..dc9ada4
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..28c6bf0
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..2ccbfd9
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..f091b6b
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..4cde121
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..d0ef06e
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..dcdc230
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..2ccbfd9
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..c8f9ed8
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..a6d6b86
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..a6d6b86
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..75b2d16
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..c4df70d
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..6a84f41
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..d0e1f58
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
Binary files differ
diff --git a/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/packages/espresso/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/espresso/example/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
+ <dependencies>
+ <deployment identifier="iOS"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
+ </dependencies>
+ <scenes>
+ <!--View Controller-->
+ <scene sceneID="EHf-IW-A2E">
+ <objects>
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+ <layoutGuides>
+ <viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
+ <viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
+ </layoutGuides>
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ <subviews>
+ <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
+ </imageView>
+ </subviews>
+ <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
+ <constraints>
+ <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
+ <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
+ </constraints>
+ </view>
+ </viewController>
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+ </objects>
+ <point key="canvasLocation" x="53" y="375"/>
+ </scene>
+ </scenes>
+ <resources>
+ <image name="LaunchImage" width="168" height="185"/>
+ </resources>
+</document>
diff --git a/packages/espresso/example/ios/Runner/Base.lproj/Main.storyboard b/packages/espresso/example/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
+ <dependencies>
+ <deployment identifier="iOS"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
+ </dependencies>
+ <scenes>
+ <!--Flutter View Controller-->
+ <scene sceneID="tne-QT-ifu">
+ <objects>
+ <viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
+ <layoutGuides>
+ <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
+ <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
+ </layoutGuides>
+ <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
+ <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
+ </view>
+ </viewController>
+ <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
+ </objects>
+ </scene>
+ </scenes>
+</document>
diff --git a/packages/espresso/example/ios/Runner/Info.plist b/packages/espresso/example/ios/Runner/Info.plist
new file mode 100644
index 0000000..96cc992
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Info.plist
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
+ <key>CFBundleExecutable</key>
+ <string>$(EXECUTABLE_NAME)</string>
+ <key>CFBundleIdentifier</key>
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>espresso_example</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleShortVersionString</key>
+ <string>$(FLUTTER_BUILD_NAME)</string>
+ <key>CFBundleSignature</key>
+ <string>????</string>
+ <key>CFBundleVersion</key>
+ <string>$(FLUTTER_BUILD_NUMBER)</string>
+ <key>LSRequiresIPhoneOS</key>
+ <true/>
+ <key>UILaunchStoryboardName</key>
+ <string>LaunchScreen</string>
+ <key>UIMainStoryboardFile</key>
+ <string>Main</string>
+ <key>UISupportedInterfaceOrientations</key>
+ <array>
+ <string>UIInterfaceOrientationPortrait</string>
+ <string>UIInterfaceOrientationLandscapeLeft</string>
+ <string>UIInterfaceOrientationLandscapeRight</string>
+ </array>
+ <key>UISupportedInterfaceOrientations~ipad</key>
+ <array>
+ <string>UIInterfaceOrientationPortrait</string>
+ <string>UIInterfaceOrientationPortraitUpsideDown</string>
+ <string>UIInterfaceOrientationLandscapeLeft</string>
+ <string>UIInterfaceOrientationLandscapeRight</string>
+ </array>
+ <key>UIViewControllerBasedStatusBarAppearance</key>
+ <false/>
+</dict>
+</plist>
diff --git a/packages/espresso/example/ios/Runner/Runner-Bridging-Header.h b/packages/espresso/example/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..7335fdf
--- /dev/null
+++ b/packages/espresso/example/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
\ No newline at end of file
diff --git a/packages/espresso/example/lib/main.dart b/packages/espresso/example/lib/main.dart
new file mode 100644
index 0000000..4c9301b
--- /dev/null
+++ b/packages/espresso/example/lib/main.dart
@@ -0,0 +1,110 @@
+import 'package:flutter/material.dart';
+
+void main() => runApp(MyApp());
+
+/// Example app for Espresso plugin.
+class MyApp extends StatelessWidget {
+ // This widget is the root of your application.
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Flutter Demo',
+ theme: ThemeData(
+ // This is the theme of your application.
+ //
+ // Try running your application with "flutter run". You'll see the
+ // application has a blue toolbar. Then, without quitting the app, try
+ // changing the primarySwatch below to Colors.green and then invoke
+ // "hot reload" (press "r" in the console where you ran "flutter run",
+ // or simply save your changes to "hot reload" in a Flutter IDE).
+ // Notice that the counter didn't reset back to zero; the application
+ // is not restarted.
+ primarySwatch: Colors.blue,
+ ),
+ home: _MyHomePage(title: 'Flutter Demo Home Page'),
+ );
+ }
+}
+
+class _MyHomePage extends StatefulWidget {
+ _MyHomePage({Key key, this.title}) : super(key: key);
+
+ // This widget is the home page of your application. It is stateful, meaning
+ // that it has a State object (defined below) that contains fields that affect
+ // how it looks.
+
+ // This class is the configuration for the state. It holds the values (in this
+ // case the title) provided by the parent (in this case the App widget) and
+ // used by the build method of the State. Fields in a Widget subclass are
+ // always marked "final".
+
+ final String title;
+
+ @override
+ _MyHomePageState createState() => _MyHomePageState();
+}
+
+class _MyHomePageState extends State<_MyHomePage> {
+ int _counter = 0;
+
+ void _incrementCounter() {
+ setState(() {
+ // This call to setState tells the Flutter framework that something has
+ // changed in this State, which causes it to rerun the build method below
+ // so that the display can reflect the updated values. If we changed
+ // _counter without calling setState(), then the build method would not be
+ // called again, and so nothing would appear to happen.
+ _counter++;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // This method is rerun every time setState is called, for instance as done
+ // by the _incrementCounter method above.
+ //
+ // The Flutter framework has been optimized to make rerunning build methods
+ // fast, so that you can just rebuild anything that needs updating rather
+ // than having to individually change instances of widgets.
+ return Scaffold(
+ appBar: AppBar(
+ // Here we take the value from the MyHomePage object that was created by
+ // the App.build method, and use it to set our appbar title.
+ title: Text(widget.title),
+ ),
+ body: Center(
+ // Center is a layout widget. It takes a single child and positions it
+ // in the middle of the parent.
+ child: Column(
+ // Column is also a layout widget. It takes a list of children and
+ // arranges them vertically. By default, it sizes itself to fit its
+ // children horizontally, and tries to be as tall as its parent.
+ //
+ // Invoke "debug painting" (press "p" in the console, choose the
+ // "Toggle Debug Paint" action from the Flutter Inspector in Android
+ // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
+ // to see the wireframe for each widget.
+ //
+ // Column has various properties to control how it sizes itself and
+ // how it positions its children. Here we use mainAxisAlignment to
+ // center the children vertically; the main axis here is the vertical
+ // axis because Columns are vertical (the cross axis would be
+ // horizontal).
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ Text(
+ 'Button tapped $_counter time${_counter == 1 ? '' : 's'}.',
+ style: Theme.of(context).textTheme.display1,
+ key: ValueKey('CountText'),
+ ),
+ ],
+ ),
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: _incrementCounter,
+ tooltip: 'Increment',
+ child: Icon(Icons.add),
+ ), // This trailing comma makes auto-formatting nicer for build methods.
+ );
+ }
+}
diff --git a/packages/espresso/example/pubspec.yaml b/packages/espresso/example/pubspec.yaml
new file mode 100644
index 0000000..d285983
--- /dev/null
+++ b/packages/espresso/example/pubspec.yaml
@@ -0,0 +1,65 @@
+name: espresso_example
+description: Demonstrates how to use the espresso plugin.
+publish_to: 'none'
+
+environment:
+ sdk: ">=2.1.0 <3.0.0"
+
+dependencies:
+ flutter:
+ sdk: flutter
+
+ # The following adds the Cupertino Icons font to your application.
+ # Use with the CupertinoIcons class for iOS style icons.
+ cupertino_icons: ^0.1.2
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_driver:
+ sdk: flutter
+
+ espresso:
+ path: ../
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter.
+flutter:
+
+ # The following line ensures that the Material Icons font is
+ # included with your application, so that you can use the icons in
+ # the material Icons class.
+ uses-material-design: true
+
+ # To add assets to your application, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/assets-and-images/#resolution-aware.
+
+ # For details regarding adding assets from package dependencies, see
+ # https://flutter.dev/assets-and-images/#from-packages
+
+ # To add custom fonts to your application, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts from package dependencies,
+ # see https://flutter.dev/custom-fonts/#from-packages
diff --git a/packages/espresso/example/test_driver/example.dart b/packages/espresso/example/test_driver/example.dart
new file mode 100644
index 0000000..ab74ff5
--- /dev/null
+++ b/packages/espresso/example/test_driver/example.dart
@@ -0,0 +1,8 @@
+import 'package:flutter_driver/driver_extension.dart';
+
+import 'package:espresso_example/main.dart' as app;
+
+void main() {
+ enableFlutterDriverExtension();
+ app.main();
+}
diff --git a/packages/espresso/ios/.gitignore b/packages/espresso/ios/.gitignore
new file mode 100644
index 0000000..aa479fd
--- /dev/null
+++ b/packages/espresso/ios/.gitignore
@@ -0,0 +1,37 @@
+.idea/
+.vagrant/
+.sconsign.dblite
+.svn/
+
+.DS_Store
+*.swp
+profile
+
+DerivedData/
+build/
+GeneratedPluginRegistrant.h
+GeneratedPluginRegistrant.m
+
+.generated/
+
+*.pbxuser
+*.mode1v3
+*.mode2v3
+*.perspectivev3
+
+!default.pbxuser
+!default.mode1v3
+!default.mode2v3
+!default.perspectivev3
+
+xcuserdata
+
+*.moved-aside
+
+*.pyc
+*sync/
+Icon?
+.tags*
+
+/Flutter/Generated.xcconfig
+/Flutter/flutter_export_environment.sh
\ No newline at end of file
diff --git a/packages/espresso/ios/Assets/.gitkeep b/packages/espresso/ios/Assets/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/espresso/ios/Assets/.gitkeep
diff --git a/packages/espresso/ios/Classes/EspressoPlugin.h b/packages/espresso/ios/Classes/EspressoPlugin.h
new file mode 100644
index 0000000..5f97615
--- /dev/null
+++ b/packages/espresso/ios/Classes/EspressoPlugin.h
@@ -0,0 +1,4 @@
+#import <Flutter/Flutter.h>
+
+@interface EspressoPlugin : NSObject <FlutterPlugin>
+@end
diff --git a/packages/espresso/ios/Classes/EspressoPlugin.m b/packages/espresso/ios/Classes/EspressoPlugin.m
new file mode 100644
index 0000000..cb4ef80
--- /dev/null
+++ b/packages/espresso/ios/Classes/EspressoPlugin.m
@@ -0,0 +1,15 @@
+#import "EspressoPlugin.h"
+
+@implementation EspressoPlugin
++ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
+ FlutterMethodChannel* channel =
+ [FlutterMethodChannel methodChannelWithName:@"espresso"
+ binaryMessenger:[registrar messenger]];
+ EspressoPlugin* instance = [[EspressoPlugin alloc] init];
+ [registrar addMethodCallDelegate:instance channel:channel];
+}
+
+- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
+ result(FlutterMethodNotImplemented);
+}
+@end
diff --git a/packages/espresso/ios/Classes/SwiftEspressoPlugin.swift b/packages/espresso/ios/Classes/SwiftEspressoPlugin.swift
new file mode 100644
index 0000000..2ff3024
--- /dev/null
+++ b/packages/espresso/ios/Classes/SwiftEspressoPlugin.swift
@@ -0,0 +1,14 @@
+import Flutter
+import UIKit
+
+public class SwiftEspressoPlugin: NSObject, FlutterPlugin {
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(name: "espresso", binaryMessenger: registrar.messenger())
+ let instance = SwiftEspressoPlugin()
+ registrar.addMethodCallDelegate(instance, channel: channel)
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ result("iOS " + UIDevice.current.systemVersion)
+ }
+}
diff --git a/packages/espresso/ios/espresso.podspec b/packages/espresso/ios/espresso.podspec
new file mode 100644
index 0000000..cd64afa
--- /dev/null
+++ b/packages/espresso/ios/espresso.podspec
@@ -0,0 +1,23 @@
+#
+# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
+# Run `pod lib lint espresso.podspec' to validate before publishing.
+#
+Pod::Spec.new do |s|
+ s.name = 'espresso'
+ s.version = '0.0.1'
+ s.summary = 'A new flutter plugin project.'
+ s.description = <<-DESC
+A new flutter plugin project.
+ DESC
+ s.homepage = 'http://example.com'
+ s.license = { :file => '../LICENSE' }
+ s.author = { 'Your Company' => 'email@example.com' }
+ s.source = { :path => '.' }
+ s.source_files = 'Classes/**/*'
+ s.dependency 'Flutter'
+ s.platform = :ios, '8.0'
+
+ # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported.
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' }
+ s.swift_version = '5.0'
+end
diff --git a/packages/espresso/pubspec.yaml b/packages/espresso/pubspec.yaml
new file mode 100644
index 0000000..70a05ab
--- /dev/null
+++ b/packages/espresso/pubspec.yaml
@@ -0,0 +1,26 @@
+name: espresso
+description: Java classes for testing Flutter apps using Espresso.
+version: 0.0.1
+homepage: https://github.com/flutter/plugins/espresso
+
+environment:
+ sdk: ">=2.1.0 <3.0.0"
+ flutter: ">=1.10.0 <2.0.0"
+
+dependencies:
+ flutter:
+ sdk: flutter
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+# The following section is specific to Flutter.
+flutter:
+ plugin:
+ platforms:
+ android:
+ package: com.example.espresso
+ pluginClass: EspressoPlugin
+ ios:
+ pluginClass: EspressoPlugin