From 3fd034c932b21d7fccab87cae35965a7935b0fca Mon Sep 17 00:00:00 2001 From: Sebastian Franco Date: Fri, 25 Apr 2025 15:56:47 -0700 Subject: [PATCH] Making the Launcher Customizar available to Launcher3 and not only Nexus Bug: 390496167 Bug: 411322054 Test: All Image tests Flag: EXEMPT test only Change-Id: I5e6cd806036bbc548ba9526efd619eec7ab9facd --- tests/Android.bp | 1 + .../util/launcheremulator/Android.bp | 11 + .../util/launcheremulator/DensityPicker.java | 88 ++++ .../TestWindowManagerProxy.java | 139 +++++++ .../models/DeviceEmulationData.kt | 156 +++++++ .../models/EmulationParams.kt | 155 +++++++ .../models/EmulatorDisplay.kt | 50 +++ .../models/LauncherDeviceList.kt | 391 ++++++++++++++++++ .../util/launcheremulator/Android.bp | 6 + .../launcheremulator/LauncherCustomizer.kt | 290 +++++++++++++ .../launcheremulator/LauncherCustomizer.kt | 132 ++++++ 11 files changed, 1419 insertions(+) create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/Android.bp create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/DensityPicker.java create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/TestWindowManagerProxy.java create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/DeviceEmulationData.kt create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulationParams.kt create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulatorDisplay.kt create mode 100644 tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/LauncherDeviceList.kt create mode 100644 tests/src/com/android/launcher3/util/launcheremulator/Android.bp create mode 100644 tests/src/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt create mode 100644 tests/src_deviceless/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt diff --git a/tests/Android.bp b/tests/Android.bp index fc08e86284..3ac6ad5a12 100644 --- a/tests/Android.bp +++ b/tests/Android.bp @@ -169,6 +169,7 @@ filegroup { "src/**/*Test.kt", "src/**/RoboApiWrapper.kt", "src/**/EventsRule.kt", + "src/**/LauncherCustomizer.kt", "multivalentTests/src/**/*Test.java", "multivalentTests/src/**/*Test.kt", ], diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/Android.bp b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/Android.bp new file mode 100644 index 0000000000..bc68168922 --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/Android.bp @@ -0,0 +1,11 @@ +filegroup { + name: "launcher-emulator-util", + srcs: [ + "models/DeviceEmulationData.kt", + "models/EmulationParams.kt", + "models/EmulatorDisplay.kt", + "models/LauncherDeviceList.kt", + "DensityPicker.java", + "TestWindowManagerProxy.java", + ], +} diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/DensityPicker.java b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/DensityPicker.java new file mode 100644 index 0000000000..4536d43050 --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/DensityPicker.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator; + +import com.android.launcher3.util.launcheremulator.models.DeviceEmulationData; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Utility class to get the different densities used when going to setting and changing the density + * of the display + */ +public class DensityPicker { + + private static final Density[] SUMMARIES_SMALLER = new Density[] { Density.SMALL }; + private static final Density[] SUMMARIES_LARGER = new Density[] { + Density.LARGE, Density.LARGER, Density.LARGEST}; + + /** + * Defines the available densities to pick from + */ + public enum Density { + SMALL, NORMAL, LARGE, LARGER, LARGEST; + + @Override + public String toString() { + return name().toLowerCase(Locale.ENGLISH); + } + } + + /** + * @return returns a map defining the different density for a given device, the map entries are + * defined in {@code Densities} + */ + public static Map getDisplayEntries(DeviceEmulationData device) { + // Display logic copied from + // packages/SettingsLib/src/com/android/settingslib/display/DisplayDensityUtils.java + // Compute number of "larger" and "smaller" scales for this display. + final int minDimensionPx = Math.min(device.width, device.height); + final int maxDensity = minDimensionPx / 2; + final float maxScale = Math.min(device.densityMaxScale, + maxDensity / (float) device.density); + final float minScale = device.densityMinScale; + final float minScaleInterval = device.densityMinScaleInterval; + final int defaultDensity = device.density; + + final int numLarger = (int) Math.max(0, Math.min((maxScale - 1) / minScaleInterval, + SUMMARIES_LARGER.length)); + final int numSmaller = (int) Math.max(0, Math.min((1 - minScale) / minScaleInterval, + SUMMARIES_SMALLER.length)); + + Map displayEntries = new HashMap<>(); + displayEntries.put(Density.NORMAL, defaultDensity); + if (numSmaller > 0) { + final float interval = (1 - minScale) / numSmaller; + for (int i = numSmaller - 1; i >= 0; i--) { + // Round down to a multiple of 2 by truncating the low bit. + final int density = ((int) (defaultDensity * (1 - (i + 1) * interval))) & ~1; + displayEntries.put(SUMMARIES_SMALLER[i], density); + } + } + + if (numLarger > 0) { + final float interval = (maxScale - 1) / numLarger; + for (int i = 0; i < numLarger; i++) { + // Round down to a multiple of 2 by truncating the low bit. + final int density = ((int) (defaultDensity * (1 + (i + 1) * interval))) & ~1; + displayEntries.put(SUMMARIES_LARGER[i], density); + } + } + return displayEntries; + } +} diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/TestWindowManagerProxy.java b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/TestWindowManagerProxy.java new file mode 100644 index 0000000000..14743e2b95 --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/TestWindowManagerProxy.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator; + +import static android.view.Surface.ROTATION_0; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Insets; +import android.graphics.Rect; +import android.util.ArrayMap; +import android.view.WindowInsets; + +import com.android.launcher3.util.DisplayController.Info; +import com.android.launcher3.util.WindowBounds; +import com.android.launcher3.util.launcheremulator.models.DeviceEmulationData; +import com.android.launcher3.util.window.CachedDisplayInfo; +import com.android.launcher3.util.window.WindowManagerProxy; + +import java.util.List; + +/** + * Testing class that overrides some values of the Launcher in order to be able to emulate it. + * This class overrides the singleton of {@code WindowManagerProxy}. + */ +public class TestWindowManagerProxy extends WindowManagerProxy { + + private DeviceEmulationData mDevice; + + private boolean mIsInDesktopMode; + + /** + * Constructor to be used when initiating using xml overrides. + * + * Use a new DisplayController.Info object to avoid circular dependency when initiating + * DisplayController + */ + public TestWindowManagerProxy(Context context) { + this(DeviceEmulationData.Companion.getCurrentDeviceData(context, new Info(context))); + } + + public TestWindowManagerProxy(DeviceEmulationData device) { + super(true); + mDevice = device; + } + + @Override + protected int getDimenByName(Resources res, String resName) { + Integer mock = mDevice.resourceOverrides.get(resName); + return mock != null ? mock : super.getDimenByName(res, resName); + } + + @Override + protected int getDimenByName(Resources res, String resName, String fallback) { + return getDimenByName(res, resName); + } + + @Override + public CachedDisplayInfo getDisplayInfo(Context displayInfoContext) { + return mDevice.toCachedDisplayInfo(getRotation(displayInfoContext)); + } + + @Override + public WindowBounds getRealBounds(Context displayInfoContext, CachedDisplayInfo info) { + List windowBounds = estimateInternalDisplayBounds(displayInfoContext).get( + getDisplayInfo(displayInfoContext).normalize(this)); + return windowBounds.get(getDisplay(displayInfoContext).getRotation()); + } + + @Override + public WindowInsets normalizeWindowInsets(Context context, WindowInsets oldInsets, + Rect outInsets) { + + boolean isGesture = isGestureNav(context); + outInsets.set(getRealBounds(context, getDisplayInfo(context)).insets); + + WindowInsets.Builder insetsBuilder = new WindowInsets.Builder(oldInsets); + + // This is the same implementation used in WindowManagerProxy to prevent the taskbar + // size to be count in the inset. It overrides the tappable bottom inset to be 0 + // for gesture nav (otherwise taskbar would count towards it). + // This is used for the bottom protection in All Apps for example. + if (isGesture) { + Insets oldTappableInsets = oldInsets.getInsets(WindowInsets.Type.tappableElement()); + Insets newTappableInsets = Insets.of(oldTappableInsets.left, oldTappableInsets.top, + oldTappableInsets.right, 0); + insetsBuilder.setInsets(WindowInsets.Type.tappableElement(), newTappableInsets); + } + + return insetsBuilder.build(); + } + + protected CachedDisplayInfo getSecondaryDisplayInfo(int rotation) { + return mDevice.secondDisplay.toCachedDisplayInfo(rotation); + } + + /** + * Returns a map of normalized info of internal displays to estimated window bounds + * for that display + */ + @Override + public ArrayMap> estimateInternalDisplayBounds( + Context displayInfoContext) { + ArrayMap> result = + super.estimateInternalDisplayBounds(displayInfoContext); + if (mDevice.secondDisplay == null) { + return result; + } + CachedDisplayInfo info = getSecondaryDisplayInfo(ROTATION_0).normalize(this); + result.put(info, estimateWindowBounds(displayInfoContext, info)); + return result; + } + + @Override + public boolean isInDesktopMode(int displayId) { + return mIsInDesktopMode; + } + + public void setInDesktopMode(boolean isInDesktopMode) { + mIsInDesktopMode = isInDesktopMode; + } + + public void setDevice(DeviceEmulationData device) { + mDevice = device; + } +} diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/DeviceEmulationData.kt b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/DeviceEmulationData.kt new file mode 100644 index 0000000000..0abd3faf3f --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/DeviceEmulationData.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator.models + +import android.content.Context +import android.content.res.Resources +import android.graphics.Rect +import android.os.Build +import android.util.ArrayMap +import com.android.launcher3.InvariantDeviceProfile +import com.android.launcher3.InvariantDeviceProfile.DeviceType +import com.android.launcher3.testing.shared.ResourceUtils +import com.android.launcher3.util.DisplayController +import com.android.launcher3.util.window.WindowManagerProxy.MIN_TABLET_WIDTH +import java.util.Locale +import kotlin.math.min + +/** Model class that holds the data needed to emulate a device display. */ +class DeviceEmulationData( + val name: String, + width: Int, + height: Int, + density: Int, + @JvmField val densityMaxScale: Float, + @JvmField val densityMinScale: Float, + @JvmField val densityMinScaleInterval: Float, + cutout: Rect, + var defaultGrid: String, + @JvmField val resourceOverrides: Map, + @JvmField val secondDisplay: EmulatorDisplay?, + val supportsFixedLandscape: Boolean, +) : EmulatorDisplay(width, height, density, cutout) { + + override fun toString(): String { + val secondDisplayString = + secondDisplay?.let { "EmulatorDisplay(${it.propString("\n\t\t")})" } ?: "null" + val resourcesString = + resourceOverrides.entries.joinToString { "\n\t\t\"${it.key}\" to ${it.value}" } + return "DeviceEmulationData(" + + "\n\tname = \"$name\"," + + propString("\n\t") + + "," + + "\n\tdensityMaxScale = ${densityMaxScale}f," + + "\n\tdensityMinScale = ${densityMinScale}f," + + "\n\tdensityMinScaleInterval = ${densityMinScaleInterval}f," + + "\n\tdefaultGrid = \"${defaultGrid}\"," + + "\n\tsecondDisplay = $secondDisplayString," + + "\n\tresourceOverrides = mapOf($resourcesString)" + + "\n)" + } + + /** Returns if the device is in tablet dimension */ + fun isTablet() = (min(width, height).toFloat() / (density / 160)) > MIN_TABLET_WIDTH + + companion object { + private const val DENSITY_MIN_SCALE = 0.85f + private const val DENSITY_MAX_SCALE = 1.5f + private const val DENSITY_SCALE_INTERVAL = 0.09f + private val EMULATED_SYSTEM_RESOURCES = + arrayOf( + ResourceUtils.NAVBAR_HEIGHT, + ResourceUtils.NAVBAR_HEIGHT_LANDSCAPE, + ResourceUtils.NAVBAR_LANDSCAPE_LEFT_RIGHT_SIZE, + ResourceUtils.STATUS_BAR_HEIGHT, + ResourceUtils.STATUS_BAR_HEIGHT_LANDSCAPE, + ResourceUtils.STATUS_BAR_HEIGHT_PORTRAIT, + ) + + const val FIXED_LANDSCAPE_GRID = "fixed_landscape_mode" + + private fun getFraction(c: Context, resName: String, fallback: Float): Float { + val resId = c.resources.getIdentifier(resName, "fraction", c.packageName) + return if (resId != Resources.ID_NULL) c.resources.getFraction(resId, 1, 1) + else fallback + } + + /** + * Returns a `DeviceEmulationData` representing the current device. + * + * @param context Launcher context. + * @return `DeviceEmulationData` + */ + @JvmStatic + @JvmOverloads + fun getCurrentDeviceData( + context: Context, + info: DisplayController.Info = DisplayController.INSTANCE[context].info, + ): DeviceEmulationData { + val code = Build.MODEL.replace("\\s".toRegex(), "").lowercase(Locale.getDefault()) + val resourceOverrides: MutableMap = ArrayMap() + for (s in EMULATED_SYSTEM_RESOURCES) { + resourceOverrides[s] = ResourceUtils.getDimenByName(s, context.resources, 0) + } + + // Print overridden resources + val settingsCtx = + try { + context.createPackageContext("com.android.settings", 0) + } catch (e: Exception) { + context + } + val displayDensityMaxScale = + getFraction(settingsCtx, "display_density_max_scale", DENSITY_MAX_SCALE) + val displayDensityMinScale = + getFraction(settingsCtx, "display_density_min_scale", DENSITY_MIN_SCALE) + val displayDensityMinScaleInterval = + getFraction( + settingsCtx, + "display_density_min_scale_interval", + DENSITY_SCALE_INTERVAL, + ) + + @DeviceType val deviceType = info.deviceType + val defaultGrid = + InvariantDeviceProfile.parseAllDefinedGridOptions(context, info) + .stream() + .filter { it.isEnabled(deviceType) } + .map { it.name } + .findFirst() + .get() + + return DeviceEmulationData( + name = code, + width = info.currentSize.x, + height = info.currentSize.y, + density = info.densityDpi, + densityMaxScale = displayDensityMaxScale, + densityMinScale = displayDensityMinScale, + densityMinScaleInterval = displayDensityMinScaleInterval, + cutout = info.cutout, + defaultGrid = defaultGrid, + resourceOverrides = resourceOverrides, + secondDisplay = null, + supportsFixedLandscape = deviceType == InvariantDeviceProfile.TYPE_PHONE, + ) + } + + /** Returns a stored `DeviceEmulationData` */ + @JvmStatic + fun getDevice(deviceCode: String) = + LauncherDeviceList.ALL_DEVICES.find { it.name == deviceCode }!! + } +} diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulationParams.kt b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulationParams.kt new file mode 100644 index 0000000000..575b4971b4 --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulationParams.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2023 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.launcher3.util.launcheremulator.models + +import com.android.launcher3.util.launcheremulator.DensityPicker.Density +import com.android.launcher3.util.launcheremulator.models.DeviceEmulationData.Companion.FIXED_LANDSCAPE_GRID +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.getDensityShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.getDeviceShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.getFontScaleShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.getGridShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.getOrientationShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.getRtlShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.isDarkThemeShort +import com.android.launcher3.util.launcheremulator.models.EmulationParamsUtils.isUsingThemeIconsShort +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation.LANDSCAPE +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation.PORTRAIT +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation.SEASCAPE +import java.util.Locale + +/** Class to hold all parameters used for device and launcher config emulation */ +data class EmulationParams +@JvmOverloads +constructor( + val device: DeviceEmulationData, + val density: Density = Density.NORMAL, + val grid: String = device.defaultGrid, + val orientation: LauncherOrientation = PORTRAIT, + val fontScale: FontScale = FontScale.DEFAULT, + val isRtl: Boolean = false, + val isDarkTheme: Boolean = false, + val isUsingThemeIcons: Boolean = false, +) { + + init { + validateParameters() + } + + val deviceType: DeviceType = + when { + device.secondDisplay != null -> DeviceType.Foldable + device.isTablet() -> DeviceType.Tablet + else -> DeviceType.Phone + } + + val isFixedLandscape = grid == FIXED_LANDSCAPE_GRID && orientation == LANDSCAPE + + override fun toString(): String = + listOf( + getDeviceShort(device), + getGridShort(grid), + getDensityShort(density), + getOrientationShort(orientation), + getFontScaleShort(fontScale), + getRtlShort(isRtl), + isDarkThemeShort(isDarkTheme), + isUsingThemeIconsShort(isUsingThemeIcons), + ) + .filter { it.isNotBlank() } + .joinToString("_") + + enum class DeviceType { + Phone, + Tablet, + Foldable, + } + + private fun validateParameters() { + if (isFixedLandscape && !device.supportsFixedLandscape) { + throw Exception( + "Device ${device.name} doesn't support fixed landscape, update " + + "EmulationParams or update the DeviceList.kt" + ) + } + } +} + +object EmulationParamsUtils { + + fun getDeviceShort(device: DeviceEmulationData): String = + when (device.name) { + "pixel5" -> "p5" + "pixel6" -> "p6" + "pixel6pro" -> "p6pro" + "pixel7pro" -> "p7pro" + "pixelFoldable2023" -> "fold" + "pixelFoldable2023_frontDisplay" -> "foldFront" + "pixelTablet2023" -> "tab" + else -> device.name + } + + fun getGridShort(grid: String) = + when (grid) { + "normal" -> "5x5" + "practical" -> "4x5" + "reasonable" -> "4x4" + "big" -> "3x3" + "crazy_big" -> "2x2" + "tablet_normal" -> "6x5" + else -> grid + } + + fun getDensityShort(density: Density) = + "den" + + when (density) { + Density.SMALL -> "S" + Density.NORMAL -> "N" + Density.LARGE -> "L" + Density.LARGER -> "L2" + Density.LARGEST -> "L3" + } + + fun getOrientationShort(orientation: LauncherOrientation) = + when (orientation) { + PORTRAIT -> "port" + LANDSCAPE -> "land" + SEASCAPE -> "seas" + } + + fun getFontScaleShort(fontScale: FontScale) = + if (fontScale != FontScale.DEFAULT) "font${fontScale.value}" else "" + + fun getRtlShort(isRtl: Boolean) = if (isRtl) "rtl" else "" + + fun isDarkThemeShort(isDarkTheme: Boolean) = if (isDarkTheme) "dark" else "" + + fun isUsingThemeIconsShort(isUsingThemeIcons: Boolean) = + if (isUsingThemeIcons) "thIcons" else "" +} + +enum class LauncherOrientation { + PORTRAIT, + LANDSCAPE, + SEASCAPE, +} + +enum class FontScale(val value: Float) { + SMALL(0.85f), + DEFAULT(1f), + LARGEST(2f); + + override fun toString() = name.lowercase(Locale.getDefault()) +} diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulatorDisplay.kt b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulatorDisplay.kt new file mode 100644 index 0000000000..a08429ec69 --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/EmulatorDisplay.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator.models + +import android.graphics.Insets +import android.graphics.Point +import android.graphics.Rect +import android.view.DisplayCutout +import com.android.launcher3.util.RotationUtils +import com.android.launcher3.util.window.CachedDisplayInfo + +/** Represents the model for the display in DeviceList */ +open class EmulatorDisplay( + @JvmField val width: Int, + @JvmField val height: Int, + @JvmField val density: Int, + val cutout: Rect, +) { + + fun propString(prefix: String) = + "${prefix}width = $width,${prefix}height = $height,${prefix}density = $density,${prefix}cutout = Rect(${cutout.left}, ${cutout.top}, ${cutout.right}, ${cutout.bottom})" + + /** + * Returns a CachedDisplayInfo rotated to the given rotation, representing the current emulation + */ + fun toCachedDisplayInfo(rotation: Int): CachedDisplayInfo { + val size = Point(width, height) + RotationUtils.rotateSize(size, rotation) + val cutoutRotated = Rect(cutout) + RotationUtils.rotateRect(cutoutRotated, rotation) + return CachedDisplayInfo( + size, + rotation, + DisplayCutout(Insets.of(cutoutRotated), null, null, null, null), + ) + } +} diff --git a/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/LauncherDeviceList.kt b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/LauncherDeviceList.kt new file mode 100644 index 0000000000..e4d70b615a --- /dev/null +++ b/tests/multivalentTests/src/com/android/launcher3/util/launcheremulator/models/LauncherDeviceList.kt @@ -0,0 +1,391 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator.models + +import android.graphics.Rect + +object LauncherDeviceList { + + val pixel9pro = + DeviceEmulationData( + name = "pixel9pro", + width = 960, + height = 2142, + density = 360, + cutout = Rect(0, 153, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09000003f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 153, + "status_bar_height" to 153, + "navigation_bar_height_landscape" to 54, + "navigation_bar_height" to 54, + "status_bar_height_landscape" to 54, + "navigation_bar_width" to 54, + ), + supportsFixedLandscape = false, + ) + + val pixel9proFold = + DeviceEmulationData( + name = "pixel9profold", + width = 2076, + height = 2152, + density = 390, + cutout = Rect(0, 136, 0, 0), + densityMaxScale = 1.3849792f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09000003f, + defaultGrid = "medium", + secondDisplay = + EmulatorDisplay( + width = 1080, + height = 2424, + density = 390, + cutout = Rect(0, 152, 0, 0), + ), + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 88, + "status_bar_height" to 88, + "navigation_bar_height_landscape" to 59, + "navigation_bar_height" to 59, + "status_bar_height_landscape" to 88, + "navigation_bar_width" to 59, + ), + supportsFixedLandscape = false, + ) + + val pixel9proFold_frontDisplay = + DeviceEmulationData( + name = "pixel9profold_front", + width = 1080, + height = 2424, + density = 390, + cutout = Rect(0, 152, 0, 0), + densityMaxScale = 1.3849792f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09000003f, + defaultGrid = "medium", + secondDisplay = + EmulatorDisplay( + width = 2076, + height = 2152, + density = 390, + cutout = Rect(0, 136, 0, 0), + ), + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 161, + "status_bar_height" to 161, + "navigation_bar_height_landscape" to 59, + "navigation_bar_height" to 59, + "status_bar_height_landscape" to 59, + "navigation_bar_width" to 59, + ), + supportsFixedLandscape = false, + ) + + val pixel8proHD = + DeviceEmulationData( + name = "p8pro_qhd", + width = 1344, + height = 2992, + density = 480, + cutout = Rect(0, 151, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 151, + "status_bar_height" to 151, + "navigation_bar_height_landscape" to 72, + "navigation_bar_height" to 72, + "status_bar_height_landscape" to 84, + "navigation_bar_width" to 72, + ), + supportsFixedLandscape = true, + ) + + val pixel8proFHD = + DeviceEmulationData( + name = "p8pro_fhd", + width = 1008, + height = 2244, + density = 360, + cutout = Rect(0, 113, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 151, + "status_bar_height" to 151, + "navigation_bar_height_landscape" to 54, + "navigation_bar_height" to 54, + "status_bar_height_landscape" to 63, + "navigation_bar_width" to 54, + ), + supportsFixedLandscape = true, + ) + + val pixel8 = + DeviceEmulationData( + name = "p8", + width = 1080, + height = 2400, + density = 420, + cutout = Rect(0, 132, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 132, + "status_bar_height" to 132, + "navigation_bar_height_landscape" to 63, + "navigation_bar_height" to 63, + "status_bar_height_landscape" to 74, + "navigation_bar_width" to 63, + ), + supportsFixedLandscape = true, + ) + + val pixel7pro = + DeviceEmulationData( + name = "pixel7pro", + width = 1080, + height = 2340, + density = 420, + cutout = Rect(0, 98, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 130, + "status_bar_height" to 130, + "navigation_bar_height_landscape" to 63, + "navigation_bar_height" to 63, + "status_bar_height_landscape" to 74, + "navigation_bar_width" to 63, + ), + supportsFixedLandscape = true, + ) + + val pixel6pro = + DeviceEmulationData( + name = "pixel6pro", + width = 1440, + height = 3120, + density = 560, + cutout = Rect(0, 130, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 130, + "status_bar_height" to 130, + "navigation_bar_height_landscape" to 84, + "navigation_bar_height" to 84, + "status_bar_height_landscape" to 98, + "navigation_bar_width" to 84, + ), + supportsFixedLandscape = true, + ) + + val pixel6 = + DeviceEmulationData( + name = "pixel6", + width = 1080, + height = 2400, + density = 420, + cutout = Rect(0, 118, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 118, + "status_bar_height" to 118, + "navigation_bar_height_landscape" to 63, + "navigation_bar_height" to 63, + "status_bar_height_landscape" to 74, + "navigation_bar_width" to 63, + ), + supportsFixedLandscape = true, + ) + + val pixelTablet2023 = + DeviceEmulationData( + name = "pixelTablet2023", + width = 2560, + height = 1600, + density = 320, + cutout = Rect(0, 0, 0, 0), + densityMaxScale = 1.3312378f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "tablet_normal", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 104, + "status_bar_height" to 104, + "navigation_bar_height_landscape" to 48, + "navigation_bar_height" to 48, + "status_bar_height_landscape" to 104, + "navigation_bar_width" to 48, + ), + supportsFixedLandscape = false, + ) + + val pixelFold2023 = + DeviceEmulationData( + name = "pixelFoldable2023", + width = 2208, + height = 1840, + density = 420, + cutout = Rect(0, 0, 0, 0), + densityMaxScale = 1.1679993f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = + EmulatorDisplay( + width = 1080, + height = 2092, + density = 420, + cutout = Rect(0, 133, 0, 0), + ), + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 110, + "status_bar_height" to 110, + "navigation_bar_height_landscape" to 63, + "navigation_bar_height" to 63, + "status_bar_height_landscape" to 110, + "navigation_bar_width" to 63, + ), + supportsFixedLandscape = false, + ) + + val pixelFold2023_frontDisplay = + DeviceEmulationData( + name = "pixelFoldable2023_frontDisplay", + width = 1080, + height = 2092, + density = 420, + cutout = Rect(0, 133, 0, 0), + densityMaxScale = 1.1679993f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = + EmulatorDisplay( + width = 2208, + height = 1840, + density = 420, + cutout = Rect(0, 0, 0, 0), + ), + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 133, + "status_bar_height" to 133, + "navigation_bar_height_landscape" to 63, + "navigation_bar_height" to 63, + "status_bar_height_landscape" to 110, + "navigation_bar_width" to 63, + ), + supportsFixedLandscape = false, + ) + + val pixel5 = + DeviceEmulationData( + name = "pixel5", + width = 1080, + height = 2340, + density = 440, + cutout = Rect(0, 136, 0, 0), + densityMaxScale = 1.5f, + densityMinScale = 0.85f, + densityMinScaleInterval = 0.09f, + defaultGrid = "medium", + secondDisplay = null, + resourceOverrides = + mapOf( + "status_bar_height_portrait" to 136, + "status_bar_height" to 136, + "navigation_bar_height_landscape" to 66, + "navigation_bar_height" to 66, + "status_bar_height_landscape" to 77, + "navigation_bar_width" to 66, + ), + supportsFixedLandscape = true, + ) + + // This is the list of devices that are currently under development, we keep the other list in + // case special tests are needed for other devices + val CURRENT_DEVICES = + listOf( + pixel9pro, + pixel9proFold, + pixel9proFold_frontDisplay, + pixel8proHD, + pixel8proFHD, + pixel8, + pixel7pro, + pixelTablet2023, + pixelFold2023, + pixelFold2023_frontDisplay, + ) + + val ALL_DEVICES = + listOf( + pixel9pro, + pixel9proFold, + pixel9proFold_frontDisplay, + pixel8proHD, + pixel8proFHD, + pixel8, + pixel7pro, + pixel6pro, + pixelTablet2023, + pixelFold2023, + pixelFold2023_frontDisplay, + pixel6, + pixel5, + ) +} diff --git a/tests/src/com/android/launcher3/util/launcheremulator/Android.bp b/tests/src/com/android/launcher3/util/launcheremulator/Android.bp new file mode 100644 index 0000000000..d04763a52f --- /dev/null +++ b/tests/src/com/android/launcher3/util/launcheremulator/Android.bp @@ -0,0 +1,6 @@ +filegroup { + name: "launcher-device-customizer-util", + srcs: [ + "LauncherCustomizer.kt", + ], +} diff --git a/tests/src/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt b/tests/src/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt new file mode 100644 index 0000000000..e62d9bb642 --- /dev/null +++ b/tests/src/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator + +import android.app.UiModeManager +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.net.Uri.Builder +import android.os.RemoteException +import android.os.UserHandle +import android.platform.uiautomatorhelpers.DeviceHelpers +import android.platform.uiautomatorhelpers.DeviceHelpers.context +import android.platform.uiautomatorhelpers.DeviceHelpers.uiDevice +import android.provider.Settings.Global +import android.provider.Settings.System +import android.util.Log +import android.view.Display +import android.view.Surface +import android.view.WindowManagerGlobal +import com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn +import com.android.launcher3.Flags +import com.android.launcher3.InvariantDeviceProfile +import com.android.launcher3.InvariantDeviceProfile.OnIDPChangeListener +import com.android.launcher3.LauncherAppState +import com.android.launcher3.LauncherPrefs +import com.android.launcher3.LauncherPrefs.Companion.get +import com.android.launcher3.dagger.LauncherComponentProvider.appComponent +import com.android.launcher3.util.DisplayController +import com.android.launcher3.util.DisplayController.DisplayInfoChangeListener +import com.android.launcher3.util.DisplayController.Info +import com.android.launcher3.util.Executors +import com.android.launcher3.util.Executors.MAIN_EXECUTOR +import com.android.launcher3.util.ModelTestExtensions.clearModelDb +import com.android.launcher3.util.TestUtil +import com.android.launcher3.util.launcheremulator.DensityPicker.Density +import com.android.launcher3.util.launcheremulator.models.DeviceEmulationData +import com.android.launcher3.util.launcheremulator.models.EmulationParams +import com.android.launcher3.util.launcheremulator.models.FontScale.DEFAULT +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation.LANDSCAPE +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation.PORTRAIT +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation.SEASCAPE +import com.android.launcher3.util.window.WindowManagerProxy +import java.util.concurrent.CountDownLatch +import org.junit.Assert +import org.mockito.Mockito.doAnswer +import org.mockito.kotlin.any +import org.mockito.kotlin.mockingDetails +import org.mockito.kotlin.reset +import org.mockito.kotlin.whenever +import org.mockito.stubbing.Answer + +object LauncherCustomizer { + private const val TAG = "LauncherCustomizer" + private const val RESULT_SUCCESS = 1 + private const val COL_ICON_THEMED_VALUE = "boolean_value" + + // As specified in com.android.settings.development.RtlLayoutPreferenceController + private const val RTL_ON = 1 + private const val RTL_OFF = 0 + + /** Apply all non null customizations starting from device, then grid, font scale and theme */ + @Throws(Exception::class) + fun applyAll(context: Context, params: EmulationParams) { + LauncherAppState.getInstance(DeviceHelpers.context).model.clearModelDb() + + System.putFloat(context.contentResolver, System.FONT_SCALE, params.fontScale.value) + + // Equivalent to adb shell settings put global debug.force_rtl 1 (or 0) + Global.putInt( + context.contentResolver, + Global.DEVELOPMENT_FORCE_RTL, + if (params.isRtl) RTL_ON else RTL_OFF, + ) + + emulate(context, params.device, params.density) + + applyFixedLandscape(params.isFixedLandscape) + + applyGridOption(context, params.grid) + + context + .getSystemService(UiModeManager::class.java)!! + .setApplicationNightMode( + if (params.isDarkTheme) UiModeManager.MODE_NIGHT_YES + else UiModeManager.MODE_NIGHT_NO + ) + + applyIsThemed(context, params.isUsingThemeIcons) + + // Flush the main thread so that all the settings are applied + TestUtil.runOnExecutorSync(Executors.MAIN_EXECUTOR) {} + + if (!isOrientationCorrect(params.orientation) && !params.isFixedLandscape) { + Log.d(TAG, "retry orientation: ${params.orientation}") + applyOrientation(params.orientation) + } + } + + private fun applyFixedLandscape(isFixedLandscape: Boolean) { + val idp = InvariantDeviceProfile.INSTANCE[context] + get(context).put(LauncherPrefs.ALLOW_ROTATION, !isFixedLandscape) + if (idp.isFixedLandscape == isFixedLandscape) return + val latch = CountDownLatch(1) + val listener = OnIDPChangeListener { + if (idp.isFixedLandscape == isFixedLandscape) latch.countDown() + } + idp.addOnChangeListener(listener) + get(context).put(LauncherPrefs.FIXED_LANDSCAPE_MODE, isFixedLandscape) + latch.await() + idp.removeOnChangeListener(listener) + } + + private fun applyGridOption(context: Context, gridParam: String) { + var grid = gridParam + if (Flags.oneGridSpecs()) { + when (gridParam) { + "normal" -> grid = "medium" + } + } else { + when (gridParam) { + "medium" -> grid = "normal" + } + } + sendGridRequest(context, "default_grid", "name", grid) + } + + private fun applyIsThemed(context: Context, isThemed: Boolean) = + sendGridRequest(context, "icon_themed", COL_ICON_THEMED_VALUE, isThemed) + + private fun sendGridRequest(context: Context, method: String, arg: String, argValue: Any?) { + val testProviderAuthority = context.packageName + ".grid_control" + val gridUri = + Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(testProviderAuthority) + .appendPath(method) + .build() + val values = ContentValues() + values.putObject(arg, argValue) + Assert.assertEquals( + RESULT_SUCCESS, + context.appComponent.gridCustomizationsProxy.update(gridUri, values, null, null), + ) + } + + private fun isOrientationCorrect(orientation: LauncherOrientation): Boolean { + return (orientation == PORTRAIT && !isLandscape() && !isSeascape()) || + (orientation == LANDSCAPE && isLandscape()) || + (orientation == SEASCAPE && isSeascape()) + } + + /** Returns if the device orientation is in landscape (width >= height) and the rotation is 0 */ + private fun isLandscape(): Boolean { + val displayRotation = uiDevice.displayRotation + val isLandscape = uiDevice.displayWidth >= uiDevice.displayHeight + return isLandscape && + (displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_90) + } + + /** Returns if the device orientation is in landscape (width >= height) and the rotation is */ + private fun isSeascape(): Boolean { + val displayRotation = uiDevice.displayRotation + val isLandscape = uiDevice.displayWidth >= uiDevice.displayHeight + return isLandscape && displayRotation == Surface.ROTATION_270 + } + + @Throws(RemoteException::class) + private fun applyOrientation(orientation: LauncherOrientation) { + if (!isOrientationCorrect(orientation)) { + when (orientation) { + PORTRAIT -> uiDevice.setOrientationPortrait() + LANDSCAPE -> uiDevice.setOrientationLandscape() + SEASCAPE -> uiDevice.setOrientationRight() + } + } + } + + /** @param device data required to emulate a given device display */ + @Throws(Exception::class) + private fun emulate(context: Context, device: DeviceEmulationData, densityScale: Density) { + val densities = DensityPicker.getDisplayEntries(device) + + // Set up emulation + // Override WindowManagerProxy + TestUtil.runOnExecutorSync(MAIN_EXECUTOR) { + val wmp = WindowManagerProxy.INSTANCE[context] + if (mockingDetails(wmp).isSpy) reset(wmp) + + spyOn(wmp) + val wmpOverride = TestWindowManagerProxy(device) + val answer = Answer { + wmpOverride::class + .java + .getMethod(it.method.name, *it.method.parameterTypes) + .invoke(wmpOverride, *it.arguments) + } + + doAnswer(answer).whenever(wmp).isTaskbarDrawnInProcess + doAnswer(answer).whenever(wmp).estimateInternalDisplayBounds(any()) + doAnswer(answer).whenever(wmp).isInDesktopMode(any()) + doAnswer(answer).whenever(wmp).showLockedTaskbarOnHome(any()) + doAnswer(answer).whenever(wmp).isHomeVisible() + doAnswer(answer).whenever(wmp).getRealBounds(any(), any()) + doAnswer(answer).whenever(wmp).normalizeWindowInsets(any(), any(), any()) + doAnswer(answer).whenever(wmp).getDisplayInfo(any()) + doAnswer(answer).whenever(wmp).getCurrentBounds(any()) + doAnswer(answer).whenever(wmp).getRotation(any()) + doAnswer(answer).whenever(wmp).getNavigationMode(any()) + } + + val userId = UserHandle.myUserId() + + // This is equivalent to calling: + // adb shell wm size {device.width}x{device.height} and adb shell wm scale 1 " + WindowManagerGlobal.getWindowManagerService()!!.apply { + setForcedDisplaySize(Display.DEFAULT_DISPLAY, device.width, device.height) + setForcedDisplayScalingMode(Display.DEFAULT_DISPLAY, 1) + + // Change density twice to force display controller to reset its state + val targetDensity = densities[densityScale]!! + setForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, targetDensity / 2, userId) + waitForDensityChange(context, targetDensity / 2) + + setForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, targetDensity, userId) + waitForDensityChange(context, targetDensity) + } + } + + @Throws(Exception::class) + private fun waitForDensityChange(context: Context, targetDensity: Int) { + val latch = CountDownLatch(1) + Executors.MAIN_EXECUTOR.execute { + val controller = DisplayController.INSTANCE[context] + if (controller.info.densityDpi == targetDensity) { + latch.countDown() + return@execute + } + controller.addChangeListener( + object : DisplayInfoChangeListener { + override fun onDisplayInfoChanged(context: Context, info: Info, flags: Int) { + if (info.densityDpi == targetDensity) { + // Remove listener asynchronously + Executors.MAIN_EXECUTOR.handler.post { + controller.removeChangeListener(this) + } + latch.countDown() + } + } + } + ) + } + latch.await() + } + + @Throws(Exception::class) + fun stopEmulation() { + // Disable themed icon to prevent interfering with future image tests + sendGridRequest(context, "icon_themed", COL_ICON_THEMED_VALUE, false) + System.putFloat(context.contentResolver, System.FONT_SCALE, DEFAULT.value) + Global.putInt(context.contentResolver, Global.DEVELOPMENT_FORCE_RTL, RTL_OFF) + applyFixedLandscape(false) + + TestUtil.runOnExecutorSync(MAIN_EXECUTOR) { + WindowManagerProxy.INSTANCE[context].let { wmp -> + if (mockingDetails(wmp).isSpy) reset(wmp) + } + } + + WindowManagerGlobal.getWindowManagerService()!!.apply { + clearForcedDisplaySize(Display.DEFAULT_DISPLAY) + clearForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, UserHandle.myUserId()) + } + uiDevice.setOrientationNatural() + } +} diff --git a/tests/src_deviceless/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt b/tests/src_deviceless/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt new file mode 100644 index 0000000000..770ae237ba --- /dev/null +++ b/tests/src_deviceless/com/android/launcher3/util/launcheremulator/LauncherCustomizer.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2025 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.launcher3.util.launcheremulator + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.res.Configuration +import android.hardware.display.DisplayManagerGlobal +import android.net.Uri.Builder +import android.view.Display +import android.view.DisplayInfo +import com.android.launcher3.Flags +import com.android.launcher3.LauncherPrefs +import com.android.launcher3.LauncherPrefs.Companion.get +import com.android.launcher3.dagger.LauncherComponentProvider.appComponent +import com.android.launcher3.util.DisplayController +import com.android.launcher3.util.launcheremulator.models.EmulationParams +import com.android.launcher3.util.launcheremulator.models.LauncherOrientation +import com.android.launcher3.util.window.WindowManagerProxy +import kotlin.math.roundToInt +import org.junit.Assert +import org.robolectric.RuntimeEnvironment +import org.robolectric.shadow.api.Shadow.extract +import org.robolectric.shadows.ShadowDisplayManagerGlobal +import org.robolectric.util.ReflectionHelpers + +/** Alternate implementation of LauncherCustomizer for deviceless tests */ +object LauncherCustomizer { + + private const val RESULT_SUCCESS = 1 + private const val COL_ICON_THEMED_VALUE = "boolean_value" + + /** Apply all non null customizations starting from device, then grid, font scale and theme */ + @Throws(Exception::class) + @JvmStatic + fun applyAll(context: Context, params: EmulationParams) { + val device = params.device + val proxy = WindowManagerProxy.INSTANCE.get(context) + if (proxy is TestWindowManagerProxy) proxy.setDevice(device) + + val density = DensityPicker.getDisplayEntries(device)[params.density]!! + val isLandscape = params.orientation != LauncherOrientation.PORTRAIT + + val scaledWidth = (device.width * 160f / density).roundToInt() + val scaledHeight = (device.height * 160f / density).roundToInt() + val darkMode = if (params.isDarkTheme) "night" else "notnight" + val landscape = if (isLandscape) "land" else "port" + // Use test pseudolocales *_XA (RTL) and *_XB (LTR) + val locale = if (params.isRtl) "en-rXB" else "en" + val qualifier = + "${locale}-w${scaledWidth}dp-h${scaledHeight}dp-${landscape}-${darkMode}-${density}dpi" + RuntimeEnvironment.setQualifiers(qualifier) + RuntimeEnvironment.setFontScale(params.fontScale.value) + + // Hack to ensure that the device size exactly matches real devices. Robolectric auto + // sets the appWidth to widthDp * density, which can cause rounding errors. Instead we + // set those values using reflection + val minSize = Math.min(device.width, device.height) + val maxSize = Math.max(device.width, device.height) + val di = DisplayManagerGlobal.getInstance().getDisplayInfo(Display.DEFAULT_DISPLAY) + di.appWidth = if (isLandscape) maxSize else minSize + di.appHeight = if (isLandscape) minSize else maxSize + di.logicalHeight = di.appHeight + di.logicalWidth = di.appWidth + di.smallestNominalAppHeight = minSize + di.smallestNominalAppWidth = minSize + di.largestNominalAppHeight = maxSize + di.largestNominalAppWidth = maxSize + val dmGlobal: ShadowDisplayManagerGlobal = extract(DisplayManagerGlobal.getInstance()) + ReflectionHelpers.callInstanceMethod( + dmGlobal, + "changeDisplay", + ReflectionHelpers.ClassParameter.from(Int::class.java, Display.DEFAULT_DISPLAY), + ReflectionHelpers.ClassParameter.from(DisplayInfo::class.java, di), + ) + + DisplayController.INSTANCE[context].onConfigurationChanged( + Configuration(context.resources.configuration) + ) + + get(context).put(LauncherPrefs.ALLOW_ROTATION, !params.isFixedLandscape) + get(context).put(LauncherPrefs.FIXED_LANDSCAPE_MODE, params.isFixedLandscape) + + applyGridOption(context, "default_grid", "name", params.grid) + applyGridOption(context, "icon_themed", COL_ICON_THEMED_VALUE, params.isUsingThemeIcons) + } + + private fun applyGridOption(context: Context, method: String, arg: String, paramArgValue: Any) { + var argValue: Any = paramArgValue + if (Flags.oneGridSpecs()) { + if (argValue is String && argValue.compareTo("normal") == 0) { + argValue = "medium" + } + } else { + if (argValue is String && argValue.compareTo("medium") == 0) { + argValue = "normal" + } + } + val gridUri = + Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(context.packageName + ".grid_control") + .appendPath(method) + .build() + Assert.assertEquals( + RESULT_SUCCESS, + context.appComponent.gridCustomizationsProxy.update( + gridUri, + ContentValues().apply { putObject(arg, argValue) }, + null, + null, + ), + ) + } + + @Throws(java.lang.Exception::class) fun stopEmulation() {} +}