Merge "Making the Launcher Customizar available to Launcher3 and not only Nexus" into main

This commit is contained in:
Sebastián Franco
2025-05-06 08:56:51 -07:00
committed by Android (Google) Code Review
11 changed files with 1419 additions and 0 deletions
+1
View File
@@ -163,6 +163,7 @@ filegroup {
"src/**/*Test.kt",
"src/**/RoboApiWrapper.kt",
"src/**/EventsRule.kt",
"src/**/LauncherCustomizer.kt",
"multivalentTests/src/**/*Test.java",
"multivalentTests/src/**/*Test.kt",
],
@@ -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",
],
}
@@ -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<Density, Integer> 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<Density, Integer> 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;
}
}
@@ -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> 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<CachedDisplayInfo, List<WindowBounds>> estimateInternalDisplayBounds(
Context displayInfoContext) {
ArrayMap<CachedDisplayInfo, List<WindowBounds>> 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;
}
}
@@ -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<String, Int>,
@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<String, Int> = 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 }!!
}
}
@@ -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())
}
@@ -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),
)
}
}
@@ -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,
)
}
@@ -0,0 +1,6 @@
filegroup {
name: "launcher-device-customizer-util",
srcs: [
"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()
}
}
@@ -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<Void>(
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() {}
}