From 5cb791b6f63e895a2a19856af980746a06374397 Mon Sep 17 00:00:00 2001 From: Nick Chameyev Date: Fri, 5 Aug 2022 16:45:14 +0000 Subject: [PATCH] [Partial Screensharing] Add app selector activity screenshot test Adds a screenshot test for the app selector activity. It also contains changes to utility rules and classes to allow capturing the whole activity instead of a single view. Bug: 235465652 Test: atest com.android.systemui.mediaprojection.MediaProjectionAppSelectorScreenshotTest Change-Id: I558c3d0e8893091d42f90cb0358c9fd030cbc0dc --- .../android/internal/app/ChooserActivity.java | 17 +++- .../ExternalViewScreenshotTestRule.kt | 99 +++++++++++++++++++ .../screenshot/TestAppComponentFactory.kt | 60 +++++++++++ .../systemui/testing/screenshot/View.kt | 42 ++++++++ .../testing/screenshot/ViewCapture.kt | 48 +++++++++ .../screenshot/ViewScreenshotTestRule.kt | 52 +--------- 6 files changed, 266 insertions(+), 52 deletions(-) create mode 100644 packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ExternalViewScreenshotTestRule.kt create mode 100644 packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/TestAppComponentFactory.kt create mode 100644 packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/View.kt diff --git a/core/java/com/android/internal/app/ChooserActivity.java b/core/java/com/android/internal/app/ChooserActivity.java index ccffddaad26cd..a66b405844b8e 100644 --- a/core/java/com/android/internal/app/ChooserActivity.java +++ b/core/java/com/android/internal/app/ChooserActivity.java @@ -63,6 +63,7 @@ import android.database.DataSetObserver; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Insets; import android.graphics.Paint; import android.graphics.Path; import android.graphics.drawable.AnimatedVectorDrawable; @@ -146,6 +147,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Supplier; /** @@ -281,6 +283,7 @@ public class ChooserActivity extends ResolverActivity implements private long mQueriedSharingShortcutsTimeMs; private int mCurrAvailableWidth = 0; + private Insets mLastAppliedInsets = null; private int mLastNumberOfChildren = -1; private int mMaxTargetsPerRow = 1; @@ -2533,7 +2536,11 @@ public class ChooserActivity extends ResolverActivity implements || gridAdapter.calculateChooserTargetWidth(availableWidth) || recyclerView.getAdapter() == null || availableWidth != mCurrAvailableWidth; + + boolean insetsChanged = !Objects.equals(mLastAppliedInsets, mSystemWindowInsets); + if (isLayoutUpdated + || insetsChanged || mLastNumberOfChildren != recyclerView.getChildCount()) { mCurrAvailableWidth = availableWidth; if (isLayoutUpdated) { @@ -2554,7 +2561,7 @@ public class ChooserActivity extends ResolverActivity implements return; } - if (mLastNumberOfChildren == recyclerView.getChildCount()) { + if (mLastNumberOfChildren == recyclerView.getChildCount() && !insetsChanged) { return; } @@ -2565,6 +2572,7 @@ public class ChooserActivity extends ResolverActivity implements int offset = calculateDrawerOffset(top, bottom, recyclerView, gridAdapter); mResolverDrawerLayout.setCollapsibleHeightReserved(offset); mEnterTransitionAnimationDelegate.markOffsetCalculated(); + mLastAppliedInsets = mSystemWindowInsets; }); } } @@ -3057,7 +3065,12 @@ public class ChooserActivity extends ResolverActivity implements mChooserMultiProfilePagerAdapter.setupContainerPadding( getActiveEmptyStateView().findViewById(R.id.resolver_empty_state_container)); } - return super.onApplyWindowInsets(v, insets); + + WindowInsets result = super.onApplyWindowInsets(v, insets); + if (mResolverDrawerLayout != null) { + mResolverDrawerLayout.requestLayout(); + } + return result; } private void setHorizontalScrollingEnabled(boolean enabled) { diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ExternalViewScreenshotTestRule.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ExternalViewScreenshotTestRule.kt new file mode 100644 index 0000000000000..2e391c7aacbed --- /dev/null +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ExternalViewScreenshotTestRule.kt @@ -0,0 +1,99 @@ +/* + * 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.Activity +import android.graphics.Color +import android.view.View +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import platform.test.screenshot.* + +/** + * A rule that allows to run a screenshot diff test on a view that is hosted in another activity. + */ +class ExternalViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule { + + private val colorsRule = MaterialYouColorsRule() + private val deviceEmulationRule = DeviceEmulationRule(emulationSpec) + private val screenshotRule = + ScreenshotTestRule( + SystemUIGoldenImagePathManager(getEmulatedDevicePathConfig(emulationSpec)) + ) + private val delegateRule = + RuleChain.outerRule(colorsRule).around(deviceEmulationRule).around(screenshotRule) + private val matcher = UnitTestBitmapMatcher + + override fun apply(base: Statement, description: Description): Statement { + return delegateRule.apply(base, description) + } + + /** + * Compare the content of the [view] with the golden image identified by [goldenIdentifier] in + * the context of [emulationSpec]. + */ + fun screenshotTest(goldenIdentifier: String, view: View) { + view.removeElevationRecursively() + + ScreenshotRuleAsserter.Builder(screenshotRule) + .setScreenshotProvider { view.toBitmap() } + .withMatcher(matcher) + .build() + .assertGoldenImage(goldenIdentifier) + } + + /** + * Compare the content of the [activity] with the golden image identified by [goldenIdentifier] + * in the context of [emulationSpec]. + */ + fun activityScreenshotTest( + goldenIdentifier: String, + activity: Activity, + ) { + val rootView = activity.window.decorView + + // Hide system bars, remove insets, focus and make sure device-specific cutouts + // don't affect screenshots + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val window = activity.window + window.setDecorFitsSystemWindows(false) + WindowInsetsControllerCompat(window, rootView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + window.attributes = + window.attributes.apply { + layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + + rootView.removeInsetsRecursively() + activity.currentFocus?.clearFocus() + } + + screenshotTest(goldenIdentifier, rootView) + } +} diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/TestAppComponentFactory.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/TestAppComponentFactory.kt new file mode 100644 index 0000000000000..98e9aaf44ceb2 --- /dev/null +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/TestAppComponentFactory.kt @@ -0,0 +1,60 @@ +/* + * 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.Activity +import android.content.Intent +import androidx.core.app.AppComponentFactory + +class TestAppComponentFactory : AppComponentFactory() { + + init { + instance = this + } + + private val overrides: MutableMap Activity> = hashMapOf() + + fun clearOverrides() { + overrides.clear() + } + + fun registerActivityOverride(activity: Class, provider: () -> T) { + overrides[activity.name] = provider + } + + override fun instantiateActivityCompat( + cl: ClassLoader, + className: String, + intent: Intent? + ): Activity { + return overrides + .getOrDefault(className) { super.instantiateActivityCompat(cl, className, intent) } + .invoke() + } + + companion object { + + private var instance: TestAppComponentFactory? = null + + fun getInstance(): TestAppComponentFactory = + instance + ?: error( + "TestAppComponentFactory is not initialized, " + + "did you specify it in the manifest?" + ) + } +} diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/View.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/View.kt new file mode 100644 index 0000000000000..b84d26a7034a7 --- /dev/null +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/View.kt @@ -0,0 +1,42 @@ +/* + * 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.view.View +import android.view.ViewGroup +import com.android.systemui.util.children +import android.view.WindowInsets + +/** + * Elevation/shadows is not deterministic when doing hardware rendering, this exentsion allows to + * disable it for any view in the hierarchy. + */ +fun View.removeElevationRecursively() { + this.elevation = 0f + (this as? ViewGroup)?.children?.forEach(View::removeElevationRecursively) +} + +/** + * Different devices could have different insets (e.g. different height of the navigation bar or + * taskbar). This method dispatches empty insets to the whole view hierarchy and removes + * the original listener, so the views won't receive real insets. + */ +fun View.removeInsetsRecursively() { + this.dispatchApplyWindowInsets(WindowInsets.CONSUMED) + this.setOnApplyWindowInsetsListener(null) + (this as? ViewGroup)?.children?.forEach(View::removeInsetsRecursively) +} diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt index c609e6f8b4bf9..cdedc64ed0e9c 100644 --- a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewCapture.kt @@ -1,10 +1,12 @@ package com.android.systemui.testing.screenshot +import android.annotation.WorkerThread import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.HardwareRenderer import android.graphics.Rect import android.os.Build import android.os.Handler @@ -19,8 +21,13 @@ import androidx.annotation.RequiresApi import androidx.concurrent.futures.ResolvableFuture import androidx.test.annotation.ExperimentalTestApi import androidx.test.core.internal.os.HandlerExecutor +import androidx.test.espresso.Espresso import androidx.test.platform.graphics.HardwareRendererCompat +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.runBlocking /* * This file was forked from androidx/test/core/view/ViewCapture.kt to add [Window] parameter to @@ -61,6 +68,47 @@ fun View.captureToBitmap(window: Window? = null): ListenableFuture { return bitmapFuture } +/** + * Synchronously captures an image of the view into a [Bitmap]. Synchronous equivalent of + * [captureToBitmap]. + */ +@WorkerThread +@ExperimentalTestApi +@RequiresApi(Build.VERSION_CODES.JELLY_BEAN) +fun View.toBitmap(window: Window? = null): Bitmap { + if (Looper.getMainLooper() == Looper.myLooper()) { + error("toBitmap() can't be called from the main thread") + } + + if (!HardwareRenderer.isDrawingEnabled()) { + error("Hardware rendering is not enabled") + } + + // Make sure we are idle. + Espresso.onIdle() + + val mainExecutor = context.mainExecutor + return runBlocking { + suspendCoroutine { continuation -> + Futures.addCallback( + captureToBitmap(window), + object : FutureCallback { + override fun onSuccess(result: Bitmap) { + continuation.resumeWith(Result.success(result)) + } + + override fun onFailure(t: Throwable) { + continuation.resumeWith(Result.failure(t)) + } + }, + // We know that we are not on the main thread, so we can block the current + // thread and wait for the result in the main thread. + mainExecutor, + ) + } + } +} + /** * Trigger a redraw of the given view. * diff --git a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt index 60130e1086ef0..b3f10718525a2 100644 --- a/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt +++ b/packages/SystemUI/screenshot/src/com/android/systemui/testing/screenshot/ViewScreenshotTestRule.kt @@ -19,21 +19,13 @@ package com.android.systemui.testing.screenshot import android.app.Activity import android.app.Dialog import android.graphics.Bitmap -import android.graphics.HardwareRenderer -import android.os.Looper import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.view.Window import androidx.activity.ComponentActivity -import androidx.test.espresso.Espresso import androidx.test.ext.junit.rules.ActivityScenarioRule -import com.google.common.util.concurrent.FutureCallback -import com.google.common.util.concurrent.Futures -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.rules.RuleChain import org.junit.rules.TestRule @@ -86,6 +78,8 @@ class ViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule { // Elevation/shadows is not deterministic when doing hardware rendering, so we disable // it for any view in the hierarchy. window.decorView.removeElevationRecursively() + + activity.currentFocus?.clearFocus() } // We call onActivity again because it will make sure that our Activity is done measuring, @@ -147,53 +141,11 @@ class ViewScreenshotTestRule(emulationSpec: DeviceEmulationSpec) : TestRule { } } - private fun View.removeElevationRecursively() { - this.elevation = 0f - - if (this is ViewGroup) { - repeat(childCount) { i -> getChildAt(i).removeElevationRecursively() } - } - } - private fun Dialog.toBitmap(): Bitmap { val window = window return window.decorView.toBitmap(window) } - private fun View.toBitmap(window: Window? = null): Bitmap { - if (Looper.getMainLooper() == Looper.myLooper()) { - error("toBitmap() can't be called from the main thread") - } - - if (!HardwareRenderer.isDrawingEnabled()) { - error("Hardware rendering is not enabled") - } - - // Make sure we are idle. - Espresso.onIdle() - - val mainExecutor = context.mainExecutor - return runBlocking { - suspendCoroutine { continuation -> - Futures.addCallback( - captureToBitmap(window), - object : FutureCallback { - override fun onSuccess(result: Bitmap?) { - continuation.resumeWith(Result.success(result!!)) - } - - override fun onFailure(t: Throwable) { - continuation.resumeWith(Result.failure(t)) - } - }, - // We know that we are not on the main thread, so we can block the current - // thread and wait for the result in the main thread. - mainExecutor, - ) - } - } - } - enum class Mode(val layoutParams: LayoutParams) { WrapContent(LayoutParams(WRAP_CONTENT, WRAP_CONTENT)), MatchSize(LayoutParams(MATCH_PARENT, MATCH_PARENT)),