Skip to content

Commit

Permalink
Add UiAutomator screenshot strategy (#5621)
Browse files Browse the repository at this point in the history
Refactors `Screengrab` into more collaborators, while keeping the existing `Screengrab` interface working.

The goal is to introduce a new strategy for screenshot capture based on [UiAutomation](#2080 (comment)). It allows capture on Android N, and more correctly captures multi-layer/window situations (dialogs, etc.) Some verification is needed to determine if it also helps Google Maps & video player use-cases.

The new strategy depends on `UiAutomation` which has an API >= 18 requirement. We enforce that in code, and override the manifest merger checks that would force the (optional) API level requirement onto consuming apps. So, our `minSdkVersion` remains **8** for the library.

The `JUnit3StyleTests` are removed, as they are not a recommended configuration.

The example application and tests have been slightly expanded to include a button that opens a dialog to verify multi-window capture by `UiAutomatorScreenshotStrategy`
  • Loading branch information
mfurtak committed Aug 8, 2016
1 parent cdd244a commit 82cda46
Show file tree
Hide file tree
Showing 20 changed files with 444 additions and 231 deletions.
16 changes: 15 additions & 1 deletion screengrab/README.md
Expand Up @@ -84,7 +84,21 @@ Ensure that the following permissions exist in your **src/debug/AndroidManifest.
- You can create your APKs with `./gradlew assembleDebug assembleAndroidTest`
- Once complete run `screengrab` in your app project directory to generate screenshots
- You will be prompted to provide any required parameters which are not in your **Screengrabfile** or provided as command line arguments
- Your screenshots will be saved in a /screenshots directory in the directory where you ran `screengrab`
- Your screenshots will be saved to `fastlane/metadata/android` in the directory where you ran `screengrab`

## Improved screenshot capture with UI Automator

As of `screengrab` 0.5.0, you can specify different strategies to control the way `screengrab` captures screenshots. The newer strategy delegates to [UI Automator](https://developer.android.com/topic/libraries/testing-support-library/index.html#UIAutomator) which fixes a number of problems compared to the original strategy:

* Shadows/elevation are correctly captured for Material UI
* Multi-window situations are correctly captured (dialogs, etc.)
* Works on Android N

However, UI Automator requires a device with **API level >= 18**, so it is not yet the default strategy. To enable it for all screenshots by default, make the following call before your tests run:

```java
Screengrab.setDefaultScreenshotStrategy(new UiAutomatorScreenshotStrategy());
```

## Advanced Screengrabfile Configuration

Expand Down
2 changes: 1 addition & 1 deletion screengrab/build.gradle
Expand Up @@ -5,7 +5,7 @@ buildscript {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:1.5.0'
classpath 'com.android.tools.build:gradle:2.1.2'
}
}

Expand Down

This file was deleted.

Expand Up @@ -16,6 +16,7 @@
import static android.support.test.espresso.assertion.ViewAssertions.matches;
import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
import static android.support.test.espresso.matcher.ViewMatchers.withId;
import static android.support.test.espresso.matcher.ViewMatchers.withText;

@RunWith(JUnit4.class)
public class JUnit4StyleTests {
Expand All @@ -27,21 +28,25 @@ public class JUnit4StyleTests {

@Test
public void testTakeScreenshot() {
onView(withId(R.id.greeting)).check(matches(isDisplayed()));

Screengrab.screenshot("beforeFabClick");

onView(withId(tools.fastlane.localetester.R.id.fab)).perform(click());
onView(withId(R.id.fab)).perform(click());

Screengrab.screenshot("afterFabClick");
}

@Test
public void testTakeMoreScreenshots() {
Screengrab.screenshot("mainActivity");

onView(withId(tools.fastlane.localetester.R.id.nav_button)).perform(click());
onView(withId(R.id.nav_button)).perform(click());

Screengrab.screenshot("anotherActivity");

onView(withId(R.id.hello)).check(matches(isDisplayed()));
onView(withId(R.id.show_dialog_button)).perform(click());

Screengrab.screenshot("anotherActivity-dialog");

onView(withText(android.R.string.ok)).perform(click());
}
}
@@ -1,13 +1,39 @@
package tools.fastlane.localetester;

import android.content.DialogInterface;
import android.os.Bundle;
import android.support.v7.app.ActionBarActivity;
import android.support.v7.app.AlertDialog;
import android.view.View;
import android.widget.Button;

public class AnotherActivity extends ActionBarActivity {

private Button showDialogButton;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(tools.fastlane.localetester.R.layout.activity_another);

showDialogButton = (Button)findViewById(R.id.show_dialog_button);
showDialogButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showDialog();
}
});
}

private void showDialog() {
new AlertDialog.Builder(this)
.setMessage(R.string.hello)
.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
}
})
.show();
}
}
9 changes: 9 additions & 0 deletions screengrab/example/src/main/res/layout/activity_another.xml
Expand Up @@ -13,4 +13,13 @@
android:layout_height="wrap_content"
android:gravity="center"/>

<Button
android:id="@+id/show_dialog_button"
android:layout_marginTop="16dp"
android:layout_below="@+id/hello"
android:layout_centerHorizontal="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/show_dialog"/>

</RelativeLayout>
1 change: 1 addition & 0 deletions screengrab/example/src/main/res/values/strings.xml
Expand Up @@ -4,4 +4,5 @@
<string name="hello">Hello</string>
<string name="title_activity_another">AnotherActivity</string>
<string name="navigate">Navigate</string>
<string name="show_dialog">Show Dialog</string>
</resources>
3 changes: 2 additions & 1 deletion screengrab/gradle.properties
Expand Up @@ -17,4 +17,5 @@
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true

org.gradle.daemon=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx2048M
4 changes: 2 additions & 2 deletions screengrab/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Wed Oct 21 11:34:03 PDT 2015
#Wed Aug 03 15:15:30 EDT 2016
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-2.8-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip
5 changes: 4 additions & 1 deletion screengrab/lib/screengrab/runner.rb
Expand Up @@ -220,7 +220,7 @@ def run_tests(device_serial, test_classes_to_use, test_packages_to_use)
print_all: true,
print_command: true)

UI.user_error! "Tests failed" if test_output.include?("FAILURES!!!")
UI.user_error!("Tests failed", show_github_issues: false) if test_output.include?("FAILURES!!!")
end
end

Expand Down Expand Up @@ -303,6 +303,9 @@ def if_device_path_exists(device_serial, device_path)
print_command: false).include?('No such file')

yield device_path
rescue
# Some versions of ADB will have a non-zero exit status for this, which will cause the executor to raise.
# We can safely ignore that and treat it as if it returned 'No such file'
end

def open_screenshots_summary(device_type_dir_name)
Expand Down
1 change: 1 addition & 0 deletions screengrab/screengrab-lib/build.gradle
Expand Up @@ -50,4 +50,5 @@ dependencies {
compile 'com.android.support.test:runner:0.4.1'
compile 'com.android.support.test:rules:0.4.1'
compile 'com.android.support.test.espresso:espresso-core:2.2.1'
compile 'com.android.support.test.uiautomator:uiautomator-v18:2.1.2'
}
4 changes: 3 additions & 1 deletion screengrab/screengrab-lib/src/main/AndroidManifest.xml
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:tools="http://schemas.android.com/tools"
package="tools.fastlane.screengrab">

<!-- We will manually check for API level >= 18 before using UiAutomator -->
<uses-sdk tools:overrideLibrary="android.support.test.uiautomator.v18" />
</manifest>
@@ -0,0 +1,127 @@
package tools.fastlane.screengrab;

import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Looper;
import android.support.test.espresso.Espresso;
import android.support.test.espresso.UiController;
import android.support.test.espresso.ViewAction;
import android.support.test.espresso.matcher.ViewMatchers;
import android.view.View;

import org.hamcrest.Matcher;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;

/**
* <p>Screenshot strategy that captures the contents of a Window's decor view.</p>
*
* <p>Advantages compared to {@link UiAutomatorScreenshotStrategy}:</p>
*
* <ul>
* <li>Works down to API level 8</li>
* <li>Uses Espresso for action synchronization internally, so requires less matching
* setup in your tests</li>
* </ul>
*
* Known limitations:
* <ul>
* <li>Does not work on Android N</li>
* <li>Does not correctly capture depth/shadows in Material UI</li>
* <li>Does not correctly capture multi-window situations (dialogs, etc.)</li>
* <li>Does not correctly capture specialized surface views (Google Maps, video players, etc.)</li>
* </ul>
*/
public class DecorViewScreenshotStrategy implements ScreenshotStrategy {
@Override
public void takeScreenshot(String screenshotName, ScreenshotCallback callback) {
Espresso.onView(ViewMatchers.isRoot()).perform(new ScreenshotViewAction(screenshotName, callback));
}

static class ScreenshotViewAction implements ViewAction {
private final String screenshotName;
private final ScreenshotCallback callback;

public ScreenshotViewAction(String screenshotName, ScreenshotCallback callback) {
this.screenshotName = screenshotName;
this.callback = callback;
}

@Override
public Matcher<View> getConstraints() {
return ViewMatchers.isDisplayed();
}

@Override
public String getDescription() {
return "taking screenshot of the Activity";
}

@Override
public void perform(UiController uiController, View view) {
final Activity activity = scanForActivity(view.getContext());

if (activity == null) {
throw new IllegalStateException("Couldn't get the activity from the view context");
}

try {
callback.screenshotCaptured(screenshotName, takeScreenshot(activity));
} catch (Exception e) {
throw new RuntimeException("Unable to capture screenshot.", e);
}
}

private Activity scanForActivity(Context context) {
if (context == null) {
return null;

} else if (context instanceof Activity) {
return (Activity) context;

} else if (context instanceof ContextWrapper) {
return scanForActivity(((ContextWrapper) context).getBaseContext());
}

return null;
}

private static Bitmap takeScreenshot(final Activity activity) throws IOException {
View view = activity.getWindow().getDecorView();
final Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);

if (Looper.myLooper() == Looper.getMainLooper()) {
// On main thread already, Just Do It™.
drawDecorViewToBitmap(activity, bitmap);
} else {
// On a background thread, post to main.
final CountDownLatch latch = new CountDownLatch(1);
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
try {
drawDecorViewToBitmap(activity, bitmap);
} finally {
latch.countDown();
}
}
});
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException("Unable to capture screenshot", e);
}
}

return bitmap;
}

private static void drawDecorViewToBitmap(Activity activity, Bitmap bitmap) {
activity.getWindow().getDecorView().draw(new Canvas(bitmap));
}
}
}

0 comments on commit 82cda46

Please sign in to comment.