Merge changes from topic "sysui-screenshot" into tm-qpr-dev

* changes:
  Introduce SystemUIScreenshotLib for screenshot testing
  Increase the max size of emulated displays
This commit is contained in:
Jordan Demeulenaere
2022-06-25 07:19:45 +00:00
committed by Android (Google) Code Review
9 changed files with 655 additions and 3 deletions

View File

@@ -0,0 +1,48 @@
// Copyright (C) 2022 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package {
// See: http://go/android-license-faq
// A large-scale-change added 'default_applicable_licenses' to import
// all of the 'license_kinds' from "frameworks_base_packages_SystemUI_license"
// to get the below license kinds:
// SPDX-license-identifier-Apache-2.0
default_applicable_licenses: ["frameworks_base_packages_SystemUI_license"],
}
android_library {
name: "SystemUIScreenshotLib",
manifest: "AndroidManifest.xml",
srcs: [
// All files in this library should be in Kotlin besides some exceptions.
"src/**/*.kt",
// This file was forked from google3, so exceptionally it can be in Java.
"src/com/android/systemui/testing/screenshot/DynamicColorsTestUtils.java",
],
resource_dirs: [
"res",
],
static_libs: [
"SystemUI-core",
"androidx.test.espresso.core",
"androidx.appcompat_appcompat",
"platform-screenshot-diff-core",
],
kotlincflags: ["-Xjvm-default=all"],
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.systemui.testing.screenshot">
<application>
<activity
android:name="com.android.systemui.testing.screenshot.ScreenshotActivity"
android:exported="true"
android:theme="@style/Theme.SystemUI.Screenshot" />
</application>
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
</manifest>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2022 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<style name="Theme.SystemUI.Screenshot" parent="Theme.SystemUI">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<!-- Make sure that device specific cutouts don't impact the outcome of screenshot tests -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

View File

@@ -0,0 +1,193 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.testing.screenshot;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
import android.app.UiAutomation;
import android.content.Context;
import android.provider.Settings;
import android.util.Log;
import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import androidx.test.espresso.Espresso;
import androidx.test.espresso.IdlingRegistry;
import androidx.test.espresso.IdlingResource;
import org.json.JSONObject;
import org.junit.function.ThrowingRunnable;
import java.util.HashMap;
import java.util.Map;
/*
* Note: This file was forked from
* google3/third_party/java_src/android_libs/material_components/screenshot_tests/java/android/
* support/design/scuba/color/DynamicColorsTestUtils.java.
*/
/** Utility that helps change the dynamic system colors for testing. */
@RequiresApi(32)
public class DynamicColorsTestUtils {
private static final String TAG = DynamicColorsTestUtils.class.getSimpleName();
private static final String THEME_CUSTOMIZATION_KEY = "theme_customization_overlay_packages";
private static final String THEME_CUSTOMIZATION_SYSTEM_PALETTE_KEY =
"android.theme.customization.system_palette";
private static final int ORANGE_SYSTEM_SEED_COLOR = 0xA66800;
private static final int ORANGE_EXPECTED_SYSTEM_ACCENT1_600_COLOR = -8235756;
private DynamicColorsTestUtils() {
}
/**
* Update system dynamic colors (e.g., android.R.color.system_accent1_600) based on an orange
* seed color, and then wait for the change to propagate to the app by comparing
* android.R.color.system_accent1_600 to the expected orange value.
*/
public static void updateSystemColorsToOrange() {
updateSystemColors(ORANGE_SYSTEM_SEED_COLOR, ORANGE_EXPECTED_SYSTEM_ACCENT1_600_COLOR);
}
/**
* Update system dynamic colors (e.g., android.R.color.system_accent1_600) based on the provided
* {@code seedColor}, and then wait for the change to propagate to the app by comparing
* android.R.color.system_accent1_600 to {@code expectedSystemAccent1600}.
*/
public static void updateSystemColors(
@ColorInt int seedColor, @ColorInt int expectedSystemAccent1600) {
Context context = getInstrumentation().getTargetContext();
int actualSystemAccent1600 =
ContextCompat.getColor(context, android.R.color.system_accent1_600);
if (expectedSystemAccent1600 == actualSystemAccent1600) {
String expectedColorString = Integer.toHexString(expectedSystemAccent1600);
Log.d(
TAG,
"Skipped updating system colors since system_accent1_600 is already equal to "
+ "expected: "
+ expectedColorString);
return;
}
updateSystemColors(seedColor);
}
/**
* Update system dynamic colors (e.g., android.R.color.system_accent1_600) based on the provided
* {@code seedColor}, and then wait for the change to propagate to the app by checking
* android.R.color.system_accent1_600 for any change.
*/
public static void updateSystemColors(@ColorInt int seedColor) {
Context context = getInstrumentation().getTargetContext();
// Initialize system color idling resource with original system_accent1_600 value.
ColorChangeIdlingResource systemColorIdlingResource =
new ColorChangeIdlingResource(context, android.R.color.system_accent1_600);
// Update system theme color setting to trigger fabricated resource overlay.
runWithShellPermissionIdentity(
() ->
Settings.Secure.putString(
context.getContentResolver(),
THEME_CUSTOMIZATION_KEY,
buildThemeCustomizationString(seedColor)));
// Wait for system color update to propagate to app.
IdlingRegistry idlingRegistry = IdlingRegistry.getInstance();
idlingRegistry.register(systemColorIdlingResource);
Espresso.onIdle();
idlingRegistry.unregister(systemColorIdlingResource);
Log.d(TAG,
Settings.Secure.getString(context.getContentResolver(), THEME_CUSTOMIZATION_KEY));
}
private static String buildThemeCustomizationString(@ColorInt int seedColor) {
String seedColorHex = Integer.toHexString(seedColor);
Map<String, String> themeCustomizationMap = new HashMap<>();
themeCustomizationMap.put(THEME_CUSTOMIZATION_SYSTEM_PALETTE_KEY, seedColorHex);
return new JSONObject(themeCustomizationMap).toString();
}
private static void runWithShellPermissionIdentity(@NonNull ThrowingRunnable runnable) {
UiAutomation uiAutomation = getInstrumentation().getUiAutomation();
uiAutomation.adoptShellPermissionIdentity();
try {
runnable.run();
} catch (Throwable e) {
throw new RuntimeException(e);
} finally {
uiAutomation.dropShellPermissionIdentity();
}
}
private static class ColorChangeIdlingResource implements IdlingResource {
private final Context mContext;
private final int mColorResId;
private final int mInitialColorInt;
private ResourceCallback mResourceCallback;
private boolean mIdleNow;
ColorChangeIdlingResource(Context context, @ColorRes int colorResId) {
this.mContext = context;
this.mColorResId = colorResId;
this.mInitialColorInt = ContextCompat.getColor(context, colorResId);
}
@Override
public String getName() {
return ColorChangeIdlingResource.class.getName();
}
@Override
public boolean isIdleNow() {
if (mIdleNow) {
return true;
}
int currentColorInt = ContextCompat.getColor(mContext, mColorResId);
String initialColorString = Integer.toHexString(mInitialColorInt);
String currentColorString = Integer.toHexString(currentColorInt);
Log.d(TAG, String.format("Initial=%s, Current=%s", initialColorString,
currentColorString));
mIdleNow = currentColorInt != mInitialColorInt;
Log.d(TAG, String.format("idleNow=%b", mIdleNow));
if (mIdleNow) {
mResourceCallback.onTransitionToIdle();
}
return mIdleNow;
}
@Override
public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
this.mResourceCallback = resourceCallback;
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.testing.screenshot
import androidx.activity.ComponentActivity
/** The Activity that is launched and whose content is set for screenshot tests. */
class ScreenshotActivity : ComponentActivity()

View File

@@ -0,0 +1,206 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.testing.screenshot
import android.app.UiModeManager
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.UserHandle
import android.view.Display
import android.view.View
import android.view.WindowManagerGlobal
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import platform.test.screenshot.GoldenImagePathManager
import platform.test.screenshot.PathConfig
import platform.test.screenshot.PathElementNoContext
import platform.test.screenshot.ScreenshotTestRule
import platform.test.screenshot.matchers.PixelPerfectMatcher
/**
* A base rule for screenshot diff tests.
*
* This rules takes care of setting up the activity according to [testSpec] by:
* - emulating the display size and density.
* - setting the dark/light mode.
* - setting the system (Material You) colors to a fixed value.
*
* @see ComposeScreenshotTestRule
* @see ViewScreenshotTestRule
*/
class ScreenshotTestRule(private val testSpec: ScreenshotTestSpec) : TestRule {
private var currentDisplay: DisplaySpec? = null
private var currentGoldenIdentifier: String? = null
private val pathConfig =
PathConfig(
PathElementNoContext("model", isDir = true) {
currentDisplay?.name ?: error("currentDisplay is null")
},
)
private val defaultMatcher = PixelPerfectMatcher()
private val screenshotRule =
ScreenshotTestRule(
SystemUIGoldenImagePathManager(
pathConfig,
currentGoldenIdentifier = {
currentGoldenIdentifier ?: error("currentGoldenIdentifier is null")
},
)
)
override fun apply(base: Statement, description: Description): Statement {
// The statement which call beforeTest() before running the test and afterTest() afterwards.
val statement =
object : Statement() {
override fun evaluate() {
try {
beforeTest()
base.evaluate()
} finally {
afterTest()
}
}
}
return screenshotRule.apply(statement, description)
}
private fun beforeTest() {
// Update the system colors to a fixed color, so that tests don't depend on the host device
// extracted colors. Note that we don't restore the default device colors at the end of the
// test because changing the colors (and waiting for them to be applied) is costly and makes
// the screenshot tests noticeably slower.
DynamicColorsTestUtils.updateSystemColorsToOrange()
// Emulate the display size and density.
val display = testSpec.display
val density = display.densityDpi
val wm = WindowManagerGlobal.getWindowManagerService()
val (width, height) = getEmulatedDisplaySize()
wm.setForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, density, UserHandle.myUserId())
wm.setForcedDisplaySize(Display.DEFAULT_DISPLAY, width, height)
// Force the dark/light theme.
val uiModeManager =
InstrumentationRegistry.getInstrumentation()
.targetContext
.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
uiModeManager.setApplicationNightMode(
if (testSpec.isDarkTheme) {
UiModeManager.MODE_NIGHT_YES
} else {
UiModeManager.MODE_NIGHT_NO
}
)
}
private fun afterTest() {
// Reset the density and display size.
val wm = WindowManagerGlobal.getWindowManagerService()
wm.clearForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, UserHandle.myUserId())
wm.clearForcedDisplaySize(Display.DEFAULT_DISPLAY)
// Reset the dark/light theme.
val uiModeManager =
InstrumentationRegistry.getInstrumentation()
.targetContext
.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
uiModeManager.setApplicationNightMode(UiModeManager.MODE_NIGHT_AUTO)
}
/**
* Compare the content of [view] with the golden image identified by [goldenIdentifier] in the
* context of [testSpec].
*/
fun screenshotTest(goldenIdentifier: String, view: View) {
val bitmap = drawIntoBitmap(view)
// Compare bitmap against golden asset.
val isDarkTheme = testSpec.isDarkTheme
val isLandscape = testSpec.isLandscape
val identifierWithSpec = buildString {
append(goldenIdentifier)
if (isDarkTheme) append("_dark")
if (isLandscape) append("_landscape")
}
// TODO(b/230832101): Provide a way to pass a PathConfig and override the file name on
// device to assertBitmapAgainstGolden instead?
currentDisplay = testSpec.display
currentGoldenIdentifier = goldenIdentifier
screenshotRule.assertBitmapAgainstGolden(bitmap, identifierWithSpec, defaultMatcher)
currentDisplay = null
currentGoldenIdentifier = goldenIdentifier
}
/** Draw [view] into a [Bitmap]. */
private fun drawIntoBitmap(view: View): Bitmap {
val bitmap =
Bitmap.createBitmap(
view.measuredWidth,
view.measuredHeight,
Bitmap.Config.ARGB_8888,
)
val canvas = Canvas(bitmap)
view.draw(canvas)
return bitmap
}
/** Get the emulated display size for [testSpec]. */
private fun getEmulatedDisplaySize(): Pair<Int, Int> {
val display = testSpec.display
val isPortraitNaturalPosition = display.width < display.height
return if (testSpec.isLandscape) {
if (isPortraitNaturalPosition) {
display.height to display.width
} else {
display.width to display.height
}
} else {
if (isPortraitNaturalPosition) {
display.width to display.height
} else {
display.height to display.width
}
}
}
}
private class SystemUIGoldenImagePathManager(
pathConfig: PathConfig,
private val currentGoldenIdentifier: () -> String,
) :
GoldenImagePathManager(
appContext = InstrumentationRegistry.getInstrumentation().context,
deviceLocalPath =
InstrumentationRegistry.getInstrumentation()
.targetContext
.filesDir
.absolutePath
.toString() + "/sysui_screenshots",
pathConfig = pathConfig,
) {
// This string is appended to all actual/expected screenshots on the device. We append the
// golden identifier so that our pull_golden.py scripts can map a screenshot on device to its
// asset (and automatically update it, if necessary).
override fun toString() = currentGoldenIdentifier()
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.systemui.testing.screenshot
/** The specification of a device display to be used in a screenshot test. */
data class DisplaySpec(
val name: String,
val width: Int,
val height: Int,
val densityDpi: Int,
)
/** The specification of a screenshot diff test. */
class ScreenshotTestSpec(
val display: DisplaySpec,
val isDarkTheme: Boolean = false,
val isLandscape: Boolean = false,
) {
companion object {
/**
* Return a list of [ScreenshotTestSpec] for each of the [displays].
*
* If [isDarkTheme] is null, this will create a spec for both light and dark themes, for
* each of the orientation.
*
* If [isLandscape] is null, this will create a spec for both portrait and landscape, for
* each of the light/dark themes.
*/
fun forDisplays(
vararg displays: DisplaySpec,
isDarkTheme: Boolean? = null,
isLandscape: Boolean? = null,
): List<ScreenshotTestSpec> {
return displays.flatMap { display ->
buildList {
fun addDisplay(isLandscape: Boolean) {
if (isDarkTheme != true) {
add(ScreenshotTestSpec(display, isDarkTheme = false, isLandscape))
}
if (isDarkTheme != false) {
add(ScreenshotTestSpec(display, isDarkTheme = true, isLandscape))
}
}
if (isLandscape != true) {
addDisplay(isLandscape = false)
}
if (isLandscape != false) {
addDisplay(isLandscape = true)
}
}
}
}
}
override fun toString(): String = buildString {
// This string is appended to PNGs stored in the device, so let's keep it simple.
append(display.name)
if (isDarkTheme) append("_dark")
if (isLandscape) append("_landscape")
}
}

View File

@@ -0,0 +1,51 @@
package com.android.systemui.testing.screenshot
import android.app.Activity
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.test.ext.junit.rules.ActivityScenarioRule
import org.junit.Assert.assertEquals
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/** A rule for View screenshot diff tests. */
class ViewScreenshotTestRule(testSpec: ScreenshotTestSpec) : TestRule {
private val activityRule = ActivityScenarioRule(ScreenshotActivity::class.java)
private val screenshotRule = ScreenshotTestRule(testSpec)
private val delegate = RuleChain.outerRule(screenshotRule).around(activityRule)
override fun apply(base: Statement, description: Description): Statement {
return delegate.apply(base, description)
}
/**
* Compare the content of [view] with the golden image identified by [goldenIdentifier] in the
* context of [testSpec].
*/
fun screenshotTest(
goldenIdentifier: String,
layoutParams: LayoutParams =
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT),
view: (Activity) -> View,
) {
activityRule.scenario.onActivity { activity ->
// Make sure that the activity draws full screen and fits the whole display instead of
// the system bars.
activity.window.setDecorFitsSystemWindows(false)
activity.setContentView(view(activity), layoutParams)
}
// We call onActivity again because it will make sure that our Activity is done measuring,
// laying out and drawing its content (that we set in the previous onActivity lambda).
activityRule.scenario.onActivity { activity ->
// Check that the content is what we expected.
val content = activity.requireViewById<ViewGroup>(android.R.id.content)
assertEquals(1, content.childCount)
screenshotRule.screenshotTest(goldenIdentifier, content.getChildAt(0))
}
}
}

View File

@@ -2941,9 +2941,10 @@ class DisplayContent extends RootDisplayArea implements WindowManagerPolicy.Disp
// Set some sort of reasonable bounds on the size of the display that we will try // Set some sort of reasonable bounds on the size of the display that we will try
// to emulate. // to emulate.
final int minSize = 200; final int minSize = 200;
final int maxScale = 2; final int maxScale = 3;
width = Math.min(Math.max(width, minSize), mInitialDisplayWidth * maxScale); final int maxSize = Math.max(mInitialDisplayWidth, mInitialDisplayHeight) * maxScale;
height = Math.min(Math.max(height, minSize), mInitialDisplayHeight * maxScale); width = Math.min(Math.max(width, minSize), maxSize);
height = Math.min(Math.max(height, minSize), maxSize);
} }
Slog.i(TAG_WM, "Using new display size: " + width + "x" + height); Slog.i(TAG_WM, "Using new display size: " + width + "x" + height);