add Android instrumentation test (#11063)

* add Android instrumentation test

* add devicelab test

* add to manifest.yaml

* rename _smoke_test.dart to _smoketest.dart to prevent flutter test from picking it up

* volatile fields; style fixes

* use ConditionVariable; fix sh script
diff --git a/dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart b/dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart
new file mode 100644
index 0000000..4ae7cfa
--- /dev/null
+++ b/dev/devicelab/bin/tasks/flutter_gallery_instrumentation_test.dart
@@ -0,0 +1,26 @@
+// Copyright 2017 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.
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter_devicelab/framework/adb.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:flutter_devicelab/framework/utils.dart';
+
+Future<Null> main() async {
+  deviceOperatingSystem = DeviceOperatingSystem.android;
+
+  await task(() async {
+    final Directory galleryDirectory =
+      dir('${flutterDirectory.path}/examples/flutter_gallery');
+    await inDirectory(galleryDirectory, () async {
+      await flutter('packages', options: <String>['get']);
+      await flutter('build', options: <String>['clean']);  // to reset the Dart entry point
+      await exec('tool/run_instrumentation_test.sh', <String>[]);
+    });
+
+    return new TaskResult.success(null);
+  });
+}
diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml
index f63a5e4..c1432fa 100644
--- a/dev/devicelab/manifest.yaml
+++ b/dev/devicelab/manifest.yaml
@@ -133,6 +133,15 @@
     required_agent_capabilities: ["linux/android"]
     flaky: true
 
+  flutter_gallery_instrumentation_test:
+    description: >
+      Same as flutter_gallery__transition_perf but uses Android instrumentation
+      framework, and therefore does not require a host computer to run. This
+      test can run on off-the-shelf infrastructures, such as Firebase Test Lab.
+    stage: devicelab
+    required_agent_capabilities: ["linux/android"]
+    flaky: true
+
   # iOS on-device tests
 
   channels_integration_test_ios:
diff --git a/examples/flutter_gallery/android/app/build.gradle b/examples/flutter_gallery/android/app/build.gradle
index 893cbbd..2b4d1ee 100644
--- a/examples/flutter_gallery/android/app/build.gradle
+++ b/examples/flutter_gallery/android/app/build.gradle
@@ -56,4 +56,6 @@
     androidTestCompile 'com.android.support:support-annotations:25.4.0'
     androidTestCompile 'com.android.support.test:runner:0.5'
     androidTestCompile 'com.android.support.test:rules:0.5'
+    androidTestCompile 'org.hamcrest:hamcrest-library:1.3'
+    androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.2'
 }
diff --git a/examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java b/examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java
new file mode 100644
index 0000000..a2d19b5
--- /dev/null
+++ b/examples/flutter_gallery/android/app/src/androidTest/java/io/flutter/examples/gallery/FlutterGalleryInstrumentationTest.java
@@ -0,0 +1,38 @@
+// Copyright 2017 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 io.flutter.examples.gallery;
+
+import android.support.test.filters.LargeTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertThat;
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class FlutterGalleryInstrumentationTest {
+  @Rule
+  public ActivityTestRule<MainActivity> mActivityRule =
+      new ActivityTestRule<>(MainActivity.class);
+
+  private MainActivity activity;
+
+  @Before
+  public void setUp() {
+    activity = mActivityRule.getActivity();
+  }
+
+  @Test
+  public void activityLoaded() throws Exception {
+    FlutterGalleryInstrumentation instrumentation = activity.getInstrumentation();
+    instrumentation.waitForTestToFinish();
+    assertThat(instrumentation.isTestSuccessful(), is(true));
+  }
+}
diff --git a/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java
new file mode 100644
index 0000000..57c7035
--- /dev/null
+++ b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/FlutterGalleryInstrumentation.java
@@ -0,0 +1,39 @@
+// Copyright 2017 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 io.flutter.examples.gallery;
+
+import android.os.ConditionVariable;
+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.view.FlutterView;
+
+/** Instrumentation for testing using Android Espresso framework. */
+public class FlutterGalleryInstrumentation implements MethodCallHandler {
+
+  private final ConditionVariable testFinished = new ConditionVariable();
+  private volatile boolean testSuccessful;
+
+  FlutterGalleryInstrumentation(FlutterView view) {
+    new MethodChannel(view, "io.flutter.examples.gallery/TestLifecycleListener")
+        .setMethodCallHandler(this);
+  }
+
+  @Override
+  public void onMethodCall(MethodCall call, Result result) {
+    testSuccessful = call.method.equals("success");
+    testFinished.open();
+    result.success(null);
+  }
+
+  public boolean isTestSuccessful() {
+    return testSuccessful;
+  }
+
+  public void waitForTestToFinish() throws Exception {
+    testFinished.block();
+  }
+}
diff --git a/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java
index f0128fd..4e980b2 100644
--- a/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java
+++ b/examples/flutter_gallery/android/app/src/main/java/io/flutter/examples/gallery/MainActivity.java
@@ -1,3 +1,7 @@
+// Copyright 2017 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 io.flutter.examples.gallery;
 
 import android.os.Bundle;
@@ -7,9 +11,17 @@
 
 public class MainActivity extends FlutterActivity {
 
+    private FlutterGalleryInstrumentation instrumentation;
+
+    /** Instrumentation for testing. */
+    public FlutterGalleryInstrumentation getInstrumentation() {
+        return instrumentation;
+    }
+
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         GeneratedPluginRegistrant.registerWith(this);
+        instrumentation = new FlutterGalleryInstrumentation(this.getFlutterView());
     }
 }
diff --git a/examples/flutter_gallery/test/live_smoketest.dart b/examples/flutter_gallery/test/live_smoketest.dart
new file mode 100644
index 0000000..0bb1e34
--- /dev/null
+++ b/examples/flutter_gallery/test/live_smoketest.dart
@@ -0,0 +1,150 @@
+// Copyright 2017 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.
+
+import 'dart:async';
+
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:flutter_gallery/gallery/app.dart';
+
+/// Reports success or failure to the native code.
+const MethodChannel _kTestChannel = const MethodChannel('io.flutter.examples.gallery/TestLifecycleListener');
+
+Future<Null> main() async {
+  try {
+    runApp(const GalleryApp());
+
+    const Duration kWaitBetweenActions = const Duration(milliseconds: 250);
+    final _LiveWidgetController controller = new _LiveWidgetController();
+
+    for (Demo demo in demos) {
+      print('Testing "${demo.title}" demo');
+      final Finder menuItem = find.text(demo.title);
+      await controller.scrollIntoView(menuItem, alignment: 0.5);
+      await new Future<Null>.delayed(kWaitBetweenActions);
+
+      for (int i = 0; i < 2; i += 1) {
+        await controller.tap(menuItem); // Launch the demo
+        await new Future<Null>.delayed(kWaitBetweenActions);
+        controller.frameSync = demo.synchronized;
+        await controller.tap(find.byTooltip('Back'));
+        controller.frameSync = true;
+        await new Future<Null>.delayed(kWaitBetweenActions);
+      }
+      print('Success');
+    }
+
+    _kTestChannel.invokeMethod('success');
+  } catch (error) {
+    _kTestChannel.invokeMethod('failure');
+  }
+}
+
+class Demo {
+  const Demo(this.title, {this.synchronized = true});
+
+  /// The title of the demo.
+  final String title;
+
+  /// True if frameSync should be enabled for this test.
+  final bool synchronized;
+}
+
+// Warning: this list must be kept in sync with the value of
+// kAllGalleryItems.map((GalleryItem item) => item.title).toList();
+const List<Demo> demos = const <Demo>[
+  // Demos
+  const Demo('Shrine'),
+  const Demo('Contact profile'),
+  const Demo('Animation'),
+
+  // Material Components
+  const Demo('Bottom navigation'),
+  const Demo('Buttons'),
+  const Demo('Cards'),
+  const Demo('Chips'),
+  const Demo('Date and time pickers'),
+  const Demo('Dialog'),
+  const Demo('Drawer'),
+  const Demo('Expand/collapse list control'),
+  const Demo('Expansion panels'),
+  const Demo('Floating action button'),
+  const Demo('Grid'),
+  const Demo('Icons'),
+  const Demo('Leave-behind list items'),
+  const Demo('List'),
+  const Demo('Menus'),
+  const Demo('Modal bottom sheet'),
+  const Demo('Page selector'),
+  const Demo('Persistent bottom sheet'),
+  const Demo('Progress indicators', synchronized: false),
+  const Demo('Pull to refresh'),
+  const Demo('Scrollable tabs'),
+  const Demo('Selection controls'),
+  const Demo('Sliders'),
+  const Demo('Snackbar'),
+  const Demo('Tabs'),
+  const Demo('Text fields'),
+  const Demo('Tooltips'),
+
+  // Cupertino Components
+  const Demo('Activity Indicator', synchronized: false),
+  const Demo('Buttons'),
+  const Demo('Dialogs'),
+  const Demo('Sliders'),
+  const Demo('Switches'),
+
+  // Style
+  const Demo('Colors'),
+  const Demo('Typography'),
+];
+
+
+class _LiveWidgetController {
+
+  final WidgetController _controller = new WidgetController(WidgetsBinding.instance);
+
+  /// With [frameSync] enabled, Flutter Driver will wait to perform an action
+  /// until there are no pending frames in the app under test.
+  bool frameSync = true;
+
+  /// Waits until at the end of a frame the provided [condition] is [true].
+  Future<Null> _waitUntilFrame(bool condition(), [Completer<Null> completer]) {
+    completer ??= new Completer<Null>();
+    if (!condition()) {
+      SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
+        _waitUntilFrame(condition, completer);
+      });
+    } else {
+      completer.complete();
+    }
+    return completer.future;
+  }
+
+  /// Runs `finder` repeatedly until it finds one or more [Element]s.
+  Future<Finder> _waitForElement(Finder finder) async {
+    if (frameSync)
+      await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0);
+
+    await _waitUntilFrame(() => finder.precache());
+
+    if (frameSync)
+      await _waitUntilFrame(() => SchedulerBinding.instance.transientCallbackCount == 0);
+
+    return finder;
+  }
+
+  Future<Null> tap(Finder finder) async {
+    await _controller.tap(await _waitForElement(finder));
+  }
+
+  Future<Null> scrollIntoView(Finder finder, {double alignment}) async {
+    final Finder target = await _waitForElement(finder);
+    await Scrollable.ensureVisible(target.evaluate().single, duration: const Duration(milliseconds: 100), alignment: alignment);
+  }
+}
diff --git a/examples/flutter_gallery/tool/run_instrumentation_test.sh b/examples/flutter_gallery/tool/run_instrumentation_test.sh
new file mode 100755
index 0000000..3c9a0c1
--- /dev/null
+++ b/examples/flutter_gallery/tool/run_instrumentation_test.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -e
+
+if [ ! -f "./pubspec.yaml" ]; then
+  echo "ERROR: current directory must be the root of flutter_gallery package"
+  exit 1
+fi
+
+cd android
+
+# Currently there's no non-hacky way to pass a device ID to gradlew, but it's
+# OK as in the devicelab we have one device per host.
+#
+# See also: https://goo.gl/oe5aUW
+./gradlew connectedAndroidTest -Ptarget=test/live_smoketest.dart