Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 369daee9 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Remove SystemUI ScreenshotTestRule (1/2)

This CL removes SystemUI ScreenshotTestRule now that most of what it
does was contributed back to the platform screenshot library in
ag/19135707.

Bug: 230832101
Test: atest SystemUIGoogleScreenshotTests
Change-Id: I38d01bcd35e6263752c69539dd65950d898f53de
Merged-In: I38d01bcd35e6263752c69539dd65950d898f53de
parent 30ee980e
Loading
Loading
Loading
Loading
+0 −4
Original line number Diff line number Diff line
@@ -26,11 +26,7 @@ android_library {
    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: [
+0 −2
Original line number Diff line number Diff line
@@ -23,6 +23,4 @@
            android:exported="true"
            android:theme="@style/Theme.SystemUI.Screenshot" />
    </application>

    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
</manifest>
+64 −0
Original line number Diff line number Diff line
/*
 * 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.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.view.View
import platform.test.screenshot.matchers.MSSIMMatcher
import platform.test.screenshot.matchers.PixelPerfectMatcher

/** Draw this [View] into a [Bitmap]. */
fun View.drawIntoBitmap(): Bitmap {
    val bitmap =
        Bitmap.createBitmap(
            measuredWidth,
            measuredHeight,
            Bitmap.Config.ARGB_8888,
        )
    val canvas = Canvas(bitmap)
    draw(canvas)
    return bitmap
}

/**
 * The [BitmapMatcher][platform.test.screenshot.matchers.BitmapMatcher] that should be used for
 * screenshot *unit* tests.
 */
val UnitTestBitmapMatcher =
    if (Build.CPU_ABI == "x86_64") {
        // Different CPU architectures can sometimes end up rendering differently, so we can't do
        // pixel-perfect matching on different architectures using the same golden. Given that our
        // presubmits are run on cf_x86_64_phone, our goldens should be perfectly matched on the
        // x86_64 architecture and use the Structural Similarity Index on others.
        // TODO(b/237511747): Run our screenshot presubmit tests on arm64 instead so that we can
        // do pixel perfect matching both at presubmit time and at development time with actual
        // devices.
        PixelPerfectMatcher()
    } else {
        MSSIMMatcher()
    }

/**
 * The [BitmapMatcher][platform.test.screenshot.matchers.BitmapMatcher] that should be used for
 * screenshot *unit* tests.
 *
 * We use the Structural Similarity Index for integration tests because they usually contain
 * additional information and noise that shouldn't break the test.
 */
val IntegrationTestBitmapMatcher = MSSIMMatcher()
+0 −193
Original line number Diff line number Diff line
/*
 * 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;
        }
    }
}
+0 −223
Original line number Diff line number Diff line
/*
 * 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.Build
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.MSSIMMatcher
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 matcher = if (shouldUsePerfectMatching()) {
        PixelPerfectMatcher()
    } else {
        MSSIMMatcher()
    }

    private val screenshotRule =
        ScreenshotTestRule(
            SystemUIGoldenImagePathManager(
                pathConfig,
                currentGoldenIdentifier = {
                    currentGoldenIdentifier ?: error("currentGoldenIdentifier is null")
                },
            )
        )

    private fun shouldUsePerfectMatching(): Boolean {
        // Different CPU architectures can sometimes end up rendering differently, so we can't do
        // pixel-perfect matching on different architectures using the same golden. Given that our
        // presubmits are run on cf_x86_64_phone, our goldens should be perfectly matched on the
        // x86_64 architecture and use the Structural Similarity Index on others.
        // TODO(b/237511747): Run our screenshot presubmit tests on arm64 instead so that we can
        // do pixel perfect matching both at presubmit time and at development time with actual
        // devices.
        return Build.CPU_ABI == "x86_64"
    }

    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, matcher)
        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()
}
Loading