diff --git a/packages/espresso/.gitignore b/packages/espresso/.gitignore new file mode 100644 index 000000000000..e9dc58d3d6e2 --- /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 000000000000..e6c63f5f72d0 --- /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 000000000000..116f3aa29b11 --- /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 000000000000..0c382ce171cc --- /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 000000000000..c0678e9d984d --- /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 `````` 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=.dart +gcloud auth activate-service-account --key-file= +gcloud --quiet config set project +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-dir= +``` + diff --git a/packages/espresso/android/.gitignore b/packages/espresso/android/.gitignore new file mode 100644 index 000000000000..c6cbe562a427 --- /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 000000000000..4af1d3e8b67f --- /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 000000000000..38c8d4544ff1 --- /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 000000000000..4751774dd352 --- /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 000000000000..46643c1c5e02 --- /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 000000000000..a70b4d1cbea5 --- /dev/null +++ b/packages/espresso/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + 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 000000000000..106436f2b9ce --- /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 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 flutterViewMatcher; + private final WidgetMatcher widgetMatcher; + private final Duration timeout; + + private WidgetInteraction(Matcher flutterViewMatcher, WidgetMatcher widgetMatcher) { + this( + flutterViewMatcher, + widgetMatcher, + DEFAULT_INTERACTION_TIMEOUT.plus(INTERACTION_TIMEOUT_DELAY)); + } + + private WidgetInteraction( + Matcher 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. + * + *

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 performInternal(FlutterAction flutterAction) { + checkNotNull( + flutterAction, + "The action cannot be null. You must specify an action to perform on the matched" + + " Flutter widget."); + FlutterViewAction 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 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 000000000000..7dcb05b41724 --- /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. + * + *

Usage: + * + *

{@code
+   * Future fooFuture = flutterTestingProtocol.callFoo();
+   * T fooResult = loopUntilCompletion("fooTask", androidUiController, fooFuture, executor);
+   * // Then consumes the fooResult on main thread.
+   * }
+ * + * @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 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 loopUntilCompletion( + String taskName, + UiController androidUiController, + Future futureTask, + ExecutorService executor) + throws ExecutionException, InterruptedException { + + checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!"); + + FutureIdlingResource 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 the return value type of this {@code FutureTask}. + */ + private static class FutureIdlingResource extends FutureTask implements IdlingResource { + + private final String taskName; + // Written from main thread, read from any thread. + private volatile ResourceCallback resourceCallback; + + public FutureIdlingResource(String taskName, final Future future) { + super( + new Callable() { + @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 000000000000..5da56fd402ad --- /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 perform( + @Nullable WidgetMatcher targetWidget, + @Nonnull View flutterView, + @Nonnull FlutterTestingProtocol flutterTestingProtocol, + @Nonnull UiController androidUiController) { + + try { + Future 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 000000000000..258daf67a66e --- /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. + * + *

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. + * + *

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. + * + *

Note, this is not a real click gesture event issued from Android system. Espresso delegates + * to Flutter engine to perform the action. + * + *

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. + * + *

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. + * + *

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 000000000000..b97252a2306e --- /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 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 000000000000..bb62250eefcb --- /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 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 setTextEntryEmulationFuture = + JdkFutureAdapters.listenInPoolThread( + flutterTestingProtocol.perform(null, new SetTextEntryEmulationAction(false))); + ListenableFuture 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. + * + *

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. + * + *

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 000000000000..7864b43d9ec0 --- /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}. + * + *

This class acts as a bridge to perform {@code WidgetAction} on a Flutter widget on the given + * {@code FlutterView}. + */ +@Beta +public final class FlutterViewAction implements ViewAction { + + private static final String FLUTTER_IDLE_TASK_NAME = "flutterIdlingResource"; + + private final SettableFuture resultFuture = SettableFuture.create(); + private final WidgetMatcher widgetMatcher; + private final FlutterAction widgetAction; + private final OkHttpClient webSocketClient; + private final IdGenerator 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 widgetAction, + OkHttpClient webSocketClient, + IdGenerator 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 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 testingProtocolReadyFuture = + JdkFutureAdapters.listenInPoolThread(flutterTestingProtocol.connect()); + AsyncFunction flutterIdleFunc = + new AsyncFunction() { + public ListenableFuture apply(Void readyResult) { + return JdkFutureAdapters.listenInPoolThread(flutterTestingProtocol.waitUntilIdle()); + } + }; + ListenableFuture 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 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 000000000000..5036be1fd290 --- /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. + * + *

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 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 000000000000..b83e29b7e582 --- /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 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 000000000000..8d541ae823ee --- /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 000000000000..90d494e0b8ea --- /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 { + + @Override + public Future 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 000000000000..24b264c00a27 --- /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. + * + *

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 The type of the action result. + */ +public interface FlutterAction { + + /** Performs an action on the given Flutter widget and gets its return value. */ + Future 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 000000000000..d01aaf5fdc09 --- /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 connect(); + + /** + * Performs a synthetic action on the Flutter widget that matches the given {@code widgetMatcher}. + * + *

If failed to perform the given {@code action}, returns a {@code Future} containing an {@code + * ExecutionException} that wraps the following exception: + * + *

    + *
  • {@code AmbiguousWidgetMatcherException} if the given {@code widgetMatcher} matched + * multiple widgets in the hierarchy when only one widget was expected. + *
  • {@code NoMatchingWidgetException} if the given {@code widgetMatcher} did not match any + * widget in the Flutter UI hierarchy. + *
  • {@code ConnectException} if connection error occurred. + *
+ * + * @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 perform(@Nullable WidgetMatcher widgetMatcher, @Nonnull SyntheticAction action); + + /** + * Returns a Java representation of the Flutter widget that matches the given widget matcher. + * + *

If failed to find a matching widget, returns a {@code Future} containing an {@code + * ExecutionException} that wraps the following exception: + * + *

    + *
  • {@code AmbiguousWidgetMatcherException} if the given {@code widgetMatcher} matched + * multiple widgets in the hierarchy when only one widget was expected. + *
  • {@code NoMatchingWidgetException} if the given {@code widgetMatcher} did not match any + * widget in the Flutter UI hierarchy. + *
  • {@code ConnectException} if connection error occurred. + *
+ * + * @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 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 getLocalRect(@Nonnull WidgetMatcher widgetMatcher); + + /** Waits until the Flutter frame is in a stable state. */ + Future 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 000000000000..aed0c4bc7570 --- /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. + * + *

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 000000000000..e49d3ef2bb0f --- /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. + * + *

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 { + + /** + * Performs this action on the given Flutter widget. + * + *

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 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 000000000000..313dd2672336 --- /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 000000000000..9f47e0bbeee6 --- /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. + * + *

A widget matcher's function is two-fold: + * + *

    + *
  • A matcher that can be passed into Flutter for selecting a Flutter widget. + *
  • Works with the {@code MatchesWidgetAssertion} to assert on a widget's properties. + *
+ */ +@Beta +public abstract class WidgetMatcher extends TypeSafeMatcher { + + @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 000000000000..63ec0f6f6fdc --- /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 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 widgetMatcher; + + private MatchesWidgetAssertion(Matcher 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 000000000000..5f4697de947a --- /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. + * + *

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 000000000000..c47f8df1e34d --- /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 000000000000..d620153fc2f5 --- /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. + * + *

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 000000000000..24d495f74945 --- /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 000000000000..ca69e39802d0 --- /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 000000000000..49c949a07c8a --- /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 000000000000..1a3666ec24e1 --- /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 000000000000..b69d8f61aa4f --- /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 { + + /** + * 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 000000000000..f8f72dc2a37d --- /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 UUID_STRING_GENERATOR = + new IdGenerator() { + @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 newIntegerIdGenerator(int nextValue) { + checkArgument(nextValue >= 0, "ID values must be non-negative"); + final AtomicInteger nextInt = new AtomicInteger(nextValue); + return new IdGenerator() { + @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 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 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 000000000000..028a78028406 --- /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. + * + *

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> 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 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 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 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 000000000000..af5c68e574aa --- /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}. + * + *

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 000000000000..fa033407eabf --- /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. + * + *

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 000000000000..f845765a98e5 --- /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. + * + *

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 000000000000..da11fcc8c8b6 --- /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 Dart VM + * Service Protocol. + */ +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 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 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} + * + *

This method ensures the Dart VM service is ready for use by checking: + * + *

    + *
  • Dart VM Observatory is up and running. + *
  • The Flutter testing API is registered with the running Dart VM service protocol. + *
+ */ + @Override + @SuppressWarnings("unchecked") + public Future connect() { + return (Future) taskExecutor.submit(new IsDartVmServiceReady(isolateId, this)); + } + + @Override + public Future perform( + @Nullable final WidgetMatcher widgetMatcher, final SyntheticAction action) { + // Assumes all the actions require a response. + ListenableFuture responseFuture = + client.request(getActionRequest(widgetMatcher, action)); + Function resultTransformFunc = + new Function() { + 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 matchWidget(@Nonnull WidgetMatcher widgetMatcher) { + JsonRpcRequest request = getActionRequest(widgetMatcher, new GetWidgetDiagnosticsAction()); + ListenableFuture jsonResponseFuture = client.request(request); + + Function widgetInfoTransformer = + new Function() { + public WidgetInfo apply(JsonRpcResponse jsonResponse) { + GetWidgetDiagnosticsResponse widgetDiagnostics = + GetWidgetDiagnosticsResponse.fromJsonRpcResponse(jsonResponse); + return WidgetInfoFactory.createWidgetInfo(widgetDiagnostics); + } + }; + return transform(jsonResponseFuture, widgetInfoTransformer, directExecutor()); + } + + @Override + public Future getLocalRect(@Nonnull WidgetMatcher widgetMatcher) { + ListenableFuture topLeftFuture = + client.request(getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.TOP_LEFT))); + ListenableFuture bottomRightFuture = + client.request( + getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.BOTTOM_RIGHT))); + ListenableFuture> responses = + Futures.allAsList(topLeftFuture, bottomRightFuture); + Function, Rect> rectTransformer = + new Function, Rect>() { + public Rect apply(List 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 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 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 getVmInfo() { + JsonRpcRequest getVmReq = + new JsonRpcRequest.Builder(GET_VM_METHOD).setId(getNextMessageId()).build(); + ListenableFuture jsonGetVmResp = client.request(getVmReq); + Function jsonToResponse = + new Function() { + 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 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 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 000000000000..2cf41f1f87a7 --- /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 000000000000..71cdb26ebf5c --- /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 000000000000..9b92f672f356 --- /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 000000000000..52fcd4ce45ab --- /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 000000000000..2fe0d44bfcda --- /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 getVM() + * request. + */ +public class GetVmResponse { + + private static final Gson gson = new Gson(); + + @Expose private List 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 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 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 000000000000..5982ee481ed8 --- /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 000000000000..65a456c0939a --- /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 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 000000000000..7e7739b6a1a0 --- /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 000000000000..8430ee23f92d --- /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 000000000000..4548b28b66bd --- /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 000000000000..7017e88765f3 --- /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 000000000000..efbe588828c3 --- /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 000000000000..2353577e5f4b --- /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. + * + *

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 000000000000..5a272f24bdc0 --- /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 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. + * + *

Usage: + * + *

{@code withType("TextField")} can be used to match a Flutter TextField 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. + * + *

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 isExisting() { + return new IsExistingMatcher(); + } + + static final class IsFlutterViewMatcher extends TypeSafeMatcher { + + 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 000000000000..24a441549624 --- /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 000000000000..3380d2146b87 --- /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 { + + /** 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 000000000000..4b86aed03216 --- /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 000000000000..27d4314b3039 --- /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 000000000000..84cf0e03feae --- /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 000000000000..0e3df39be9b8 --- /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 000000000000..d6394d2052f3 --- /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. + * + *

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. + * + *

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 000000000000..53ea8a27cddc --- /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}. + * + *

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 000000000000..966a7c164080 --- /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 000000000000..ae1f1838ee7e --- /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 000000000000..e1188cda3dd8 --- /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 000000000000..224544e9f83f --- /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 000000000000..bc2100d8f75e --- /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 000000000000..0be415652fdc --- /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 000000000000..aaedd6cbd7cb --- /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 000000000000..fc8acdd61de5 --- /dev/null +++ b/packages/espresso/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + 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 000000000000..b82df920d3bc --- /dev/null +++ b/packages/espresso/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + 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 000000000000..413ef9e50448 --- /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 000000000000..304732f88420 --- /dev/null +++ b/packages/espresso/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + 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 000000000000..db77bb4b7b09 Binary files /dev/null and b/packages/espresso/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png 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 000000000000..17987b79bb8a Binary files /dev/null and b/packages/espresso/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png 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 000000000000..09d4391482be Binary files /dev/null and b/packages/espresso/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png 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 000000000000..d5f1c8d34e7a Binary files /dev/null and b/packages/espresso/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png 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 000000000000..4d6372eebdb2 Binary files /dev/null and b/packages/espresso/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png 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 000000000000..00fa4417cfbe --- /dev/null +++ b/packages/espresso/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + 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 000000000000..bd9aec960687 --- /dev/null +++ b/packages/espresso/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/espresso/example/android/build.gradle b/packages/espresso/example/android/build.gradle new file mode 100644 index 000000000000..e0d7ae2c11af --- /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 000000000000..38c8d4544ff1 --- /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 000000000000..296b146b7318 --- /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 000000000000..5a2f14fb18f6 --- /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 000000000000..e96ef602b8d1 --- /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 000000000000..6b4c0f78a785 --- /dev/null +++ b/packages/espresso/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/packages/espresso/example/ios/Flutter/Debug.xcconfig b/packages/espresso/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000000..e8efba114687 --- /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 000000000000..399e9340e6f6 --- /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 000000000000..2209e01dfcd6 --- /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 = ""; }; + 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 = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; + }; + 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 = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + E9E5CC94EC52B9D261A44A5E /* Pods */, + 301432828879F7BDE0943C41 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + ); + name = "Supporting Files"; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 000000000000..a28140cfdb3f --- /dev/null +++ b/packages/espresso/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/espresso/example/ios/Runner/AppDelegate.swift b/packages/espresso/example/ios/Runner/AppDelegate.swift new file mode 100644 index 000000000000..70693e4a8c12 --- /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 000000000000..d36b1fab2d9d --- /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 000000000000..dc9ada4725e9 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png 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 000000000000..28c6bf03016f Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png 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 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png 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 000000000000..f091b6b0bca8 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png 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 000000000000..4cde12118dda Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png 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 000000000000..d0ef06e7edb8 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png 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 000000000000..dcdc2306c285 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png 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 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png 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 000000000000..c8f9ed8f5cee Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png 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 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png 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 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png 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 000000000000..75b2d164a5a9 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png 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 000000000000..c4df70d39da7 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png 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 000000000000..6a84f41e14e2 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png 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 000000000000..d0e1f5853602 Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png 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 000000000000..0bedcf2fd467 --- /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 000000000000..9da19eacad3b Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png 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 000000000000..9da19eacad3b Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png 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 000000000000..9da19eacad3b Binary files /dev/null and b/packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png 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 000000000000..89c2725b70f1 --- /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 000000000000..f2e259c7c939 --- /dev/null +++ b/packages/espresso/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 000000000000..f3c28516fb38 --- /dev/null +++ b/packages/espresso/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/espresso/example/ios/Runner/Info.plist b/packages/espresso/example/ios/Runner/Info.plist new file mode 100644 index 000000000000..96cc992ec974 --- /dev/null +++ b/packages/espresso/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + espresso_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + 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 000000000000..7335fdf9000c --- /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 000000000000..4c9301b93460 --- /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: [ + 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 000000000000..d2859839b1f2 --- /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 000000000000..ab74ff550930 --- /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 000000000000..aa479fd3ce8a --- /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 000000000000..e69de29bb2d1 diff --git a/packages/espresso/ios/Classes/EspressoPlugin.h b/packages/espresso/ios/Classes/EspressoPlugin.h new file mode 100644 index 000000000000..5f9761591f72 --- /dev/null +++ b/packages/espresso/ios/Classes/EspressoPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface EspressoPlugin : NSObject +@end diff --git a/packages/espresso/ios/Classes/EspressoPlugin.m b/packages/espresso/ios/Classes/EspressoPlugin.m new file mode 100644 index 000000000000..cb4ef8072cae --- /dev/null +++ b/packages/espresso/ios/Classes/EspressoPlugin.m @@ -0,0 +1,15 @@ +#import "EspressoPlugin.h" + +@implementation EspressoPlugin ++ (void)registerWithRegistrar:(NSObject*)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 000000000000..2ff3024ce33a --- /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 000000000000..cd64afa1d3c5 --- /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 000000000000..70a05abed2d1 --- /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