From a1a4f2148c24bd02e37fd69f8f4a4fb7d6eb71b1 Mon Sep 17 00:00:00 2001 From: Collin Jackson Date: Fri, 10 Jan 2020 10:29:01 -0800 Subject: [PATCH] [espresso] Adds EspressoFlutter as a first-party plugin (#2369) * Initial open source release of Espresso bindings for Flutter as a new first-party plugin, espresso. --- packages/espresso/.gitignore | 7 + packages/espresso/.metadata | 10 + packages/espresso/CHANGELOG.md | 3 + packages/espresso/LICENSE | 27 + packages/espresso/README.md | 125 ++++ packages/espresso/android/.gitignore | 8 + packages/espresso/android/build.gradle | 74 +++ packages/espresso/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + packages/espresso/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../espresso/flutter/EspressoFlutter.java | 198 ++++++ .../espresso/flutter/action/ActionUtil.java | 114 ++++ .../espresso/flutter/action/ClickAction.java | 83 +++ .../flutter/action/FlutterActions.java | 74 +++ .../flutter/action/FlutterScrollToAction.java | 51 ++ .../flutter/action/FlutterTypeTextAction.java | 185 ++++++ .../flutter/action/FlutterViewAction.java | 224 +++++++ .../flutter/action/SyntheticClickAction.java | 47 ++ .../flutter/action/WaitUntilIdleAction.java | 32 + .../action/WidgetCoordinatesCalculator.java | 68 ++ .../flutter/action/WidgetInfoFetcher.java | 28 + .../espresso/flutter/api/FlutterAction.java | 30 + .../flutter/api/FlutterTestingProtocol.java | 77 +++ .../espresso/flutter/api/SyntheticAction.java | 66 ++ .../espresso/flutter/api/WidgetAction.java | 43 ++ .../espresso/flutter/api/WidgetAssertion.java | 25 + .../espresso/flutter/api/WidgetMatcher.java | 41 ++ .../flutter/assertion/FlutterAssertions.java | 41 ++ .../assertion/FlutterViewAssertion.java | 45 ++ .../espresso/flutter/common/Constants.java | 17 + .../espresso/flutter/common/Duration.java | 61 ++ .../AmbiguousWidgetMatcherException.java | 19 + .../InvalidFlutterViewException.java | 17 + .../exception/NoMatchingWidgetException.java | 18 + .../internal/idgenerator/IdException.java | 27 + .../internal/idgenerator/IdGenerator.java | 19 + .../internal/idgenerator/IdGenerators.java | 65 ++ .../internal/jsonrpc/JsonRpcClient.java | 145 +++++ .../internal/jsonrpc/message/ErrorObject.java | 60 ++ .../jsonrpc/message/JsonRpcRequest.java | 221 +++++++ .../jsonrpc/message/JsonRpcResponse.java | 156 +++++ .../internal/protocol/impl/DartVmService.java | 377 +++++++++++ .../protocol/impl/DartVmServiceUtil.java | 94 +++ .../impl/FlutterProtocolException.java | 21 + .../protocol/impl/GetOffsetAction.java | 69 +++ .../protocol/impl/GetOffsetResponse.java | 140 +++++ .../internal/protocol/impl/GetVmResponse.java | 127 ++++ .../impl/GetWidgetDiagnosticsAction.java | 27 + .../impl/GetWidgetDiagnosticsResponse.java | 189 ++++++ .../impl/NoPendingFrameCondition.java | 15 + .../NoPendingPlatformMessagesCondition.java | 16 + .../impl/NoTransientCallbacksCondition.java | 13 + .../internal/protocol/impl/WaitCondition.java | 18 + .../protocol/impl/WaitForConditionAction.java | 33 + .../protocol/impl/WidgetInfoFactory.java | 91 +++ .../flutter/matcher/FlutterMatchers.java | 105 ++++ .../matcher/IsDescendantOfMatcher.java | 75 +++ .../flutter/matcher/IsExistingMatcher.java | 31 + .../flutter/matcher/WithTextMatcher.java | 49 ++ .../flutter/matcher/WithTooltipMatcher.java | 52 ++ .../flutter/matcher/WithTypeMatcher.java | 49 ++ .../flutter/matcher/WithValueKeyMatcher.java | 54 ++ .../espresso/flutter/model/WidgetInfo.java | 109 ++++ .../flutter/model/WidgetInfoBuilder.java | 81 +++ .../com/example/espresso/EspressoPlugin.java | 45 ++ packages/espresso/example/.gitignore | 37 ++ packages/espresso/example/.metadata | 10 + packages/espresso/example/README.md | 14 + packages/espresso/example/android/.gitignore | 7 + .../espresso/example/android/app/build.gradle | 88 +++ .../java/com/example/MainActivityTest.java | 76 +++ .../android/app/src/debug/AndroidManifest.xml | 8 + .../android/app/src/main/AndroidManifest.xml | 30 + .../espresso_example/MainActivity.java | 10 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values/styles.xml | 8 + .../app/src/profile/AndroidManifest.xml | 7 + .../espresso/example/android/build.gradle | 29 + .../example/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + .../espresso/example/android/settings.gradle | 15 + packages/espresso/example/ios/.gitignore | 32 + .../ios/Flutter/AppFrameworkInfo.plist | 26 + .../example/ios/Flutter/Debug.xcconfig | 2 + .../example/ios/Flutter/Release.xcconfig | 2 + .../ios/Runner.xcodeproj/project.pbxproj | 584 ++++++++++++++++++ .../xcshareddata/xcschemes/Runner.xcscheme | 91 +++ .../example/ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 ++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 564 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1588 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 1025 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 1716 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 1920 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1895 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 3831 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 1888 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 3294 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 3612 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 ++ .../ios/Runner/Base.lproj/Main.storyboard | 26 + .../espresso/example/ios/Runner/Info.plist | 45 ++ .../ios/Runner/Runner-Bridging-Header.h | 1 + packages/espresso/example/lib/main.dart | 110 ++++ packages/espresso/example/pubspec.yaml | 65 ++ .../espresso/example/test_driver/example.dart | 8 + packages/espresso/ios/.gitignore | 37 ++ packages/espresso/ios/Assets/.gitkeep | 0 .../espresso/ios/Classes/EspressoPlugin.h | 4 + .../espresso/ios/Classes/EspressoPlugin.m | 15 + .../ios/Classes/SwiftEspressoPlugin.swift | 14 + packages/espresso/ios/espresso.podspec | 23 + packages/espresso/pubspec.yaml | 26 + 129 files changed, 6117 insertions(+) create mode 100644 packages/espresso/.gitignore create mode 100644 packages/espresso/.metadata create mode 100644 packages/espresso/CHANGELOG.md create mode 100644 packages/espresso/LICENSE create mode 100644 packages/espresso/README.md create mode 100644 packages/espresso/android/.gitignore create mode 100644 packages/espresso/android/build.gradle create mode 100644 packages/espresso/android/gradle.properties create mode 100644 packages/espresso/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/espresso/android/settings.gradle create mode 100644 packages/espresso/android/src/main/AndroidManifest.xml create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/EspressoFlutter.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ActionUtil.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/ClickAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterActions.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterScrollToAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterTypeTextAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/FlutterViewAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/SyntheticClickAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WaitUntilIdleAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetCoordinatesCalculator.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/action/WidgetInfoFetcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/FlutterTestingProtocol.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/SyntheticAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetAssertion.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/api/WidgetMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterAssertions.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/assertion/FlutterViewAssertion.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Constants.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/common/Duration.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/AmbiguousWidgetMatcherException.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/InvalidFlutterViewException.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/exception/NoMatchingWidgetException.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdException.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerator.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/idgenerator/IdGenerators.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/JsonRpcClient.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/ErrorObject.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcRequest.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/jsonrpc/message/JsonRpcResponse.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmService.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/DartVmServiceUtil.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/FlutterProtocolException.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetOffsetResponse.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetVmResponse.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/GetWidgetDiagnosticsResponse.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingFrameCondition.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoPendingPlatformMessagesCondition.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/NoTransientCallbacksCondition.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitCondition.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WaitForConditionAction.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/internal/protocol/impl/WidgetInfoFactory.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/FlutterMatchers.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsDescendantOfMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/IsExistingMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTextMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTooltipMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithTypeMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/matcher/WithValueKeyMatcher.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfo.java create mode 100644 packages/espresso/android/src/main/java/androidx/test/espresso/flutter/model/WidgetInfoBuilder.java create mode 100644 packages/espresso/android/src/main/java/com/example/espresso/EspressoPlugin.java create mode 100644 packages/espresso/example/.gitignore create mode 100644 packages/espresso/example/.metadata create mode 100644 packages/espresso/example/README.md create mode 100644 packages/espresso/example/android/.gitignore create mode 100644 packages/espresso/example/android/app/build.gradle create mode 100644 packages/espresso/example/android/app/src/androidTest/java/com/example/MainActivityTest.java create mode 100644 packages/espresso/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/espresso/example/android/app/src/main/AndroidManifest.xml create mode 100644 packages/espresso/example/android/app/src/main/java/com/example/espresso_example/MainActivity.java create mode 100644 packages/espresso/example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 packages/espresso/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/espresso/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/espresso/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/espresso/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/espresso/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/espresso/example/android/app/src/main/res/values/styles.xml create mode 100644 packages/espresso/example/android/app/src/profile/AndroidManifest.xml create mode 100644 packages/espresso/example/android/build.gradle create mode 100644 packages/espresso/example/android/gradle.properties create mode 100644 packages/espresso/example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/espresso/example/android/settings.gradle create mode 100644 packages/espresso/example/ios/.gitignore create mode 100644 packages/espresso/example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 packages/espresso/example/ios/Flutter/Debug.xcconfig create mode 100644 packages/espresso/example/ios/Flutter/Release.xcconfig create mode 100644 packages/espresso/example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 packages/espresso/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/espresso/example/ios/Runner/AppDelegate.swift create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 packages/espresso/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 packages/espresso/example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/espresso/example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 packages/espresso/example/ios/Runner/Info.plist create mode 100644 packages/espresso/example/ios/Runner/Runner-Bridging-Header.h create mode 100644 packages/espresso/example/lib/main.dart create mode 100644 packages/espresso/example/pubspec.yaml create mode 100644 packages/espresso/example/test_driver/example.dart create mode 100644 packages/espresso/ios/.gitignore create mode 100644 packages/espresso/ios/Assets/.gitkeep create mode 100644 packages/espresso/ios/Classes/EspressoPlugin.h create mode 100644 packages/espresso/ios/Classes/EspressoPlugin.m create mode 100644 packages/espresso/ios/Classes/SwiftEspressoPlugin.swift create mode 100644 packages/espresso/ios/espresso.podspec create mode 100644 packages/espresso/pubspec.yaml 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f091b6b0bca859a3f474b03065bef75ba58a9e4c GIT binary patch literal 1588 zcmV-42Fv-0P)C1SqPt}wig>|5Crh^=oyX$BK<}M8eLU3e2hGT;=G|!_SP)7zNI6fqUMB=)y zRAZ>eDe#*r`yDAVgB_R*LB*MAc)8(b{g{9McCXW!lq7r(btRoB9!8B-#AI6JMb~YFBEvdsV)`mEQO^&#eRKx@b&x- z5lZm*!WfD8oCLzfHGz#u7sT0^VLMI1MqGxF^v+`4YYnVYgk*=kU?HsSz{v({E3lb9 z>+xILjBN)t6`=g~IBOelGQ(O990@BfXf(DRI5I$qN$0Gkz-FSc$3a+2fX$AedL4u{ z4V+5Ong(9LiGcIKW?_352sR;LtDPmPJXI{YtT=O8=76o9;*n%_m|xo!i>7$IrZ-{l z-x3`7M}qzHsPV@$v#>H-TpjDh2UE$9g6sysUREDy_R(a)>=eHw-WAyfIN z*qb!_hW>G)Tu8nSw9yn#3wFMiLcfc4pY0ek1}8(NqkBR@t4{~oC>ryc-h_ByH(Cg5 z>ao-}771+xE3um9lWAY1FeQFxowa1(!J(;Jg*wrg!=6FdRX+t_<%z&d&?|Bn){>zm zZQj(aA_HeBY&OC^jj*)N`8fa^ePOU72VpInJoI1?`ty#lvlNzs(&MZX+R%2xS~5Kh zX*|AU4QE#~SgPzOXe9>tRj>hjU@c1k5Y_mW*Jp3fI;)1&g3j|zDgC+}2Q_v%YfDax z!?umcN^n}KYQ|a$Lr+51Nf9dkkYFSjZZjkma$0KOj+;aQ&721~t7QUKx61J3(P4P1 zstI~7-wOACnWP4=8oGOwz%vNDqD8w&Q`qcNGGrbbf&0s9L0De{4{mRS?o0MU+nR_! zrvshUau0G^DeMhM_v{5BuLjb#Hh@r23lDAk8oF(C+P0rsBpv85EP>4CVMx#04MOfG z;P%vktHcXwTj~+IE(~px)3*MY77e}p#|c>TD?sMatC0Tu4iKKJ0(X8jxQY*gYtxsC z(zYC$g|@+I+kY;dg_dE>scBf&bP1Nc@Hz<3R)V`=AGkc;8CXqdi=B4l2k|g;2%#m& z*jfX^%b!A8#bI!j9-0Fi0bOXl(-c^AB9|nQaE`*)Hw+o&jS9@7&Gov#HbD~#d{twV zXd^Tr^mWLfFh$@Dr$e;PBEz4(-2q1FF0}c;~B5sA}+Q>TOoP+t>wf)V9Iy=5ruQa;z)y zI9C9*oUga6=hxw6QasLPnee@3^Rr*M{CdaL5=R41nLs(AHk_=Y+A9$2&H(B7!_pURs&8aNw7?`&Z&xY_Ye z)~D5Bog^td-^QbUtkTirdyK^mTHAOuptDflut!#^lnKqU md>ggs(5nOWAqO?umG&QVYK#ibz}*4>0000U6E9hRK9^#O7(mu>ETqrXGsduA8$)?`v2seloOCza43C{NQ$$gAOH**MCn0Q?+L7dl7qnbRdqZ8LSVp1ItDxhxD?t@5_yHg6A8yI zC*%Wgg22K|8E#!~cTNYR~@Y9KepMPrrB8cABapAFa=`H+UGhkXUZV1GnwR1*lPyZ;*K(i~2gp|@bzp8}og7e*#% zEnr|^CWdVV!-4*Y_7rFvlww2Ze+>j*!Z!pQ?2l->4q#nqRu9`ELo6RMS5=br47g_X zRw}P9a7RRYQ%2Vsd0Me{_(EggTnuN6j=-?uFS6j^u69elMypu?t>op*wBx<=Wx8?( ztpe^(fwM6jJX7M-l*k3kEpWOl_Vk3@(_w4oc}4YF4|Rt=2V^XU?#Yz`8(e?aZ@#li0n*=g^qOcVpd-Wbok=@b#Yw zqn8u9a)z>l(1kEaPYZ6hwubN6i<8QHgsu0oE) ziJ(p;Wxm>sf!K+cw>R-(^Y2_bahB+&KI9y^);#0qt}t-$C|Bo71lHi{_+lg#f%RFy z0um=e3$K3i6K{U_4K!EX?F&rExl^W|G8Z8;`5z-k}OGNZ0#WVb$WCpQu-_YsiqKP?BB# vzVHS-CTUF4Ozn5G+mq_~Qqto~ahA+K`|lyv3(-e}00000NkvXXu0mjfd`9t{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d0ef06e7edb86cdfe0d15b4b0d98334a86163658 GIT binary patch literal 1716 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c8f9ed8f5cee1c98386d13b17e89f719e83555b2 GIT binary patch literal 1895 zcmV-t2blPYP)FQtfgmafE#=YDCq`qUBt#QpG%*H6QHY765~R=q zZ6iudfM}q!Pz#~9JgOi8QJ|DSu?1-*(kSi1K4#~5?#|rh?sS)(-JQqX*}ciXJ56_H zdw=^s_srbAdqxlvGyrgGet#6T7_|j;95sL%MtM;q86vOxKM$f#puR)Bjv9Zvz9-di zXOTSsZkM83)E9PYBXC<$6(|>lNLVBb&&6y{NByFCp%6+^ALR@NCTse_wqvNmSWI-m z!$%KlHFH2omF!>#%1l3LTZg(s7eof$7*xB)ZQ0h?ejh?Ta9fDv59+u#MokW+1t8Zb zgHv%K(u9G^Lv`lh#f3<6!JVTL3(dCpxHbnbA;kKqQyd1~^Xe0VIaYBSWm6nsr;dFj z4;G-RyL?cYgsN1{L4ZFFNa;8)Rv0fM0C(~Tkit94 zz#~A)59?QjD&pAPSEQ)p8gP|DS{ng)j=2ux)_EzzJ773GmQ_Cic%3JJhC0t2cx>|v zJcVusIB!%F90{+}8hG3QU4KNeKmK%T>mN57NnCZ^56=0?&3@!j>a>B43pi{!u z7JyDj7`6d)qVp^R=%j>UIY6f+3`+qzIc!Y_=+uN^3BYV|o+$vGo-j-Wm<10%A=(Yk^beI{t%ld@yhKjq0iNjqN4XMGgQtbKubPM$JWBz}YA65k%dm*awtC^+f;a-x4+ddbH^7iDWGg&N0n#MW{kA|=8iMUiFYvMoDY@sPC#t$55gn6ykUTPAr`a@!(;np824>2xJthS z*ZdmT`g5-`BuJs`0LVhz+D9NNa3<=6m;cQLaF?tCv8)zcRSh66*Z|vXhG@$I%U~2l z?`Q zykI#*+rQ=z6Jm=Bui-SfpDYLA=|vzGE(dYm=OC8XM&MDo7ux4UF1~0J1+i%aCUpRe zt3L_uNyQ*cE(38Uy03H%I*)*Bh=Lb^Xj3?I^Hnbeq72(EOK^Y93CNp*uAA{5Lc=ky zx=~RKa4{iTm{_>_vSCm?$Ej=i6@=m%@VvAITnigVg{&@!7CDgs908761meDK5azA} z4?=NOH|PdvabgJ&fW2{Mo$Q0CcD8Qc84%{JPYt5EiG{MdLIAeX%T=D7NIP4%Hw}p9 zg)==!2Lbp#j{u_}hMiao9=!VSyx0gHbeCS`;q&vzeq|fs`y&^X-lso(Ls@-706qmA z7u*T5PMo_w3{se1t2`zWeO^hOvTsohG_;>J0wVqVe+n)AbQCx)yh9;w+J6?NF5Lmo zecS@ieAKL8%bVd@+-KT{yI|S}O>pYckUFs;ry9Ow$CD@ztz5K-*D$^{i(_1llhSh^ zEkL$}tsQt5>QA^;QgjgIfBDmcOgi5YDyu?t6vSnbp=1+@6D& z5MJ}B8q;bRlVoxasyhcUF1+)o`&3r0colr}QJ3hcSdLu;9;td>kf@Tcn<@9sIx&=m z;AD;SCh95=&p;$r{Xz3iWCO^MX83AGJ(yH&eTXgv|0=34#-&WAmw{)U7OU9!Wz^!7 zZ%jZFi@JR;>Mhi7S>V7wQ176|FdW2m?&`qa(ScO^CFPR80HucLHOTy%5s*HR0^8)i h0WYBP*#0Ks^FNSabJA*5${_#%002ovPDHLkV1oKhTl@e3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..75b2d164a5a98e212cca15ea7bf2ab5de5108680 GIT binary patch literal 3831 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4df70d39da7941ef3f6dcb7f06a192d8dcb308d GIT binary patch literal 1888 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 iW8Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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