diff options
12 files changed, 1164 insertions, 3 deletions
diff --git a/core/java/android/hardware/display/DisplayTopology.java b/core/java/android/hardware/display/DisplayTopology.java index 1d2f133ee759..11eed72480dc 100644 --- a/core/java/android/hardware/display/DisplayTopology.java +++ b/core/java/android/hardware/display/DisplayTopology.java @@ -108,7 +108,6 @@ public final class DisplayTopology implements Parcelable { public DisplayTopology() {} - @VisibleForTesting public DisplayTopology(TreeNode root, int primaryDisplayId) { mRoot = root; if (mRoot != null) { @@ -541,6 +540,19 @@ public final class DisplayTopology implements Parcelable { } @Override + public boolean equals(Object obj) { + if (!(obj instanceof DisplayTopology)) { + return false; + } + return obj.toString().equals(toString()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override public String toString() { StringWriter out = new StringWriter(); PrintWriter writer = new PrintWriter(out); @@ -610,7 +622,7 @@ public final class DisplayTopology implements Parcelable { } @Nullable - private static TreeNode findDisplay(int displayId, @Nullable TreeNode startingNode) { + public static TreeNode findDisplay(int displayId, @Nullable TreeNode startingNode) { if (startingNode == null) { return null; } @@ -775,16 +787,22 @@ public final class DisplayTopology implements Parcelable { */ private float mOffset; - private final List<TreeNode> mChildren = new ArrayList<>(); + private final List<TreeNode> mChildren; @VisibleForTesting public TreeNode(int displayId, float width, float height, @Position int position, float offset) { + this(displayId, width, height, position, offset, List.of()); + } + + public TreeNode(int displayId, float width, float height, int position, + float offset, List<TreeNode> children) { mDisplayId = displayId; mWidth = width; mHeight = height; mPosition = position; mOffset = offset; + mChildren = new ArrayList<>(children); } public TreeNode(Parcel source) { diff --git a/services/core/Android.bp b/services/core/Android.bp index 42385fc5bdb0..fed070a1b8a8 100644 --- a/services/core/Android.bp +++ b/services/core/Android.bp @@ -143,6 +143,7 @@ java_library_static { ":platform-compat-overrides", ":display-device-config", ":display-layout-config", + ":display-topology", ":device-state-config", "java/com/android/server/EventLogTags.logtags", "java/com/android/server/am/EventLogTags.logtags", diff --git a/services/core/java/com/android/server/display/DisplayTopologyStore.java b/services/core/java/com/android/server/display/DisplayTopologyStore.java new file mode 100644 index 000000000000..2256c11feee8 --- /dev/null +++ b/services/core/java/com/android/server/display/DisplayTopologyStore.java @@ -0,0 +1,33 @@ +/* + * Copyright 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.server.display; + +import android.annotation.Nullable; +import android.hardware.display.DisplayTopology; + +/** + * Allows to save and restore {@link DisplayTopology}. + * See implementation: {@link DisplayTopologyXmlStore} + */ +interface DisplayTopologyStore { + boolean saveTopology(DisplayTopology topology); + + @Nullable + DisplayTopology restoreTopology(DisplayTopology topology); + + void reloadTopologies(int userId); +} diff --git a/services/core/java/com/android/server/display/DisplayTopologyXmlStore.java b/services/core/java/com/android/server/display/DisplayTopologyXmlStore.java new file mode 100644 index 000000000000..b7f31b75f6dc --- /dev/null +++ b/services/core/java/com/android/server/display/DisplayTopologyXmlStore.java @@ -0,0 +1,582 @@ +/* + * Copyright 2024 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.server.display; + +import static android.view.Display.DEFAULT_DISPLAY; +import static android.view.Display.INVALID_DISPLAY; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Comparator.comparingInt; + +import android.annotation.Nullable; +import android.hardware.display.DisplayTopology; +import android.os.Environment; +import android.util.AtomicFile; +import android.util.AtomicFilePrintWriter; +import android.util.Slog; +import android.util.SparseArray; + +// automatically generated classes from display-topology.xsd +import com.android.server.display.topology.Children; +import com.android.server.display.topology.Display; +import com.android.server.display.topology.DisplayTopologyState; +import com.android.server.display.topology.Position; +import com.android.server.display.topology.Topology; +import com.android.server.display.topology.XmlParser; +import com.android.server.display.topology.XmlWriter; +import com.android.server.display.utils.DebugUtils; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.datatype.DatatypeConfigurationException; + +/** + * Saves and restores {@link DisplayTopology} to/from xml files with topologies for each + * {@link DisplayTopologyXmlStore#mUserId} user. + */ +class DisplayTopologyXmlStore implements DisplayTopologyStore { + private static final String TAG = "DisplayManager.DisplayTopologyXmlStore"; + private static final String ETC_DIR = "etc"; + private static final String DISPLAY_CONFIG_DIR = "displayconfig"; + + // To enable these logs, run: + // adb shell setprop persist.log.tag.DisplayManager.DisplayTopologyXmlStore DEBUG + // adb reboot + private static final boolean DEBUG = DebugUtils.isDebuggable(TAG); + + private static final int PERSISTENT_TOPOLOGY_VERSION = 1; + /** + * {@link #restoreTopology} needs to reorder topologies to keep the most recently used + * topologies order close to 0. In case current topology displays change often, the persistence + * of the reordered topologies can become a performance issue. To avoid persistence for small + * changes in the order values lets use this constant, serving as the threshold when + * to trigger persistence during {@link #restoreTopology}. + */ + private static final int MIN_REORDER_WHICH_TRIGGERS_PERSISTENCE = 10; + private static final int MAX_NUMBER_OF_TOPOLOGIES = 100; + + static File getUserTopologyFile(int userId) { + return new File(Environment.getDataSystemCeDirectory(userId), "display_topology.xml"); + } + + private static File getVendorTopologyFile() { + return Environment.buildPath(Environment.getVendorDirectory(), + ETC_DIR, DISPLAY_CONFIG_DIR, "display_topology.xml"); + } + + private static File getProductTopologyFile() { + return Environment.buildPath(Environment.getProductDirectory(), + ETC_DIR, DISPLAY_CONFIG_DIR, "display_topology.xml"); + } + + private static List<Topology> readTopologiesFromInputStream( + @Nullable InputStream iStream) + throws DatatypeConfigurationException, XmlPullParserException, IOException { + if (null == iStream) { + if (DEBUG) { + Slog.d(TAG, "iStream is null"); + } + return List.of(); + } + // use parser automatically generated from display-topology.xsd + var topologyState = XmlParser.read(iStream); + if (topologyState.getVersion() > PERSISTENT_TOPOLOGY_VERSION) { + Slog.e(TAG, "Topology version=" + topologyState.getVersion() + + " is not supported by DisplayTopologyXmlStore version=" + + PERSISTENT_TOPOLOGY_VERSION); + return List.of(); + } + if (DEBUG) { + Slog.d(TAG, "readTopologiesFromInputStream: done"); + } + + var topologyList = topologyState.getTopology(); + topologyList.sort(comparingInt(Topology::getOrder)); + return topologyList; + } + + private static int getOrderOrDefault(@Nullable Topology topology, int defaultOrder) { + return null != topology ? topology.getOrder() : defaultOrder; + } + + private final Injector mInjector; + private int mUserId = -1; + private final List<Topology> mImmutableTopologies = new ArrayList<>(); + private final Map<String, Topology> mTopologies = new HashMap<>(); + + DisplayTopologyXmlStore(Injector injector) { + mInjector = injector; + reloadImmutableTopologies(); + } + + /** + * Persists the topology into XML + * @param topology the topology to persist + * @return true if persisted successfully, false otherwise + */ + @Override + public boolean saveTopology(DisplayTopology topology) { + String topologyId = getTopologyId(topology); + if (DEBUG) { + Slog.d(TAG, "saveTopology userId=" + mUserId + ", topologyId=" + topologyId); + } + if (null == topologyId) { + Slog.w(TAG, "saveTopology cancelled: topology id is null for " + topology); + return false; + } + + Topology topologyToPersist = convertTopologyForPersistence(topology, topologyId); + if (null == topologyToPersist) { + Slog.w(TAG, "saveTopology cancelled: can't convert topology " + topology); + return false; + } + + if (!prependTopology(topologyToPersist)) { + Slog.w(TAG, "saveTopology cancelled: can't prependTopology"); + return false; + } + saveTopologiesToFile(); + return true; + } + + /** + * Searches for the topology's id in the store. If topology is found in the store, + * then uses the passed topology display width and height, and the persisted topology + * structure, position and offset. + * @param topology original topology which we would like to restore to a state which was + * previously persisted, keeping the current width and height. + * @return null if topology is not found, or the new restored topology otherwise. + */ + @Nullable + @Override + public DisplayTopology restoreTopology(DisplayTopology topology) { + String topologyId = getTopologyId(topology); + if (DEBUG) { + Slog.d(TAG, "restoreTopology userId=" + mUserId + ", topologyId=" + topologyId); + } + if (null == topologyId) { + Slog.w(TAG, "restoreTopology cancelled: topology id is null for " + topology); + return null; + } + + Topology restoredTopology = mTopologies.get(topologyId); + if (null == restoredTopology) { + // Topology is not found in persistent storage. + if (DEBUG) { + Slog.d(TAG, "restoreTopology userId=" + mUserId + ", topologyId=" + topologyId + + " is not found"); + } + return null; + } + + // Reorder and save to file for significant changes in topologies order. + if (restoredTopology.getOrder() >= MIN_REORDER_WHICH_TRIGGERS_PERSISTENCE) { + moveTopologyToHead(restoredTopology); + saveTopologiesToFile(); + } + return convertPersistentTopologyToDisplayTopology(topology, restoredTopology.getDisplay(), + mInjector.getUniqueIdToDisplayIdMapping()); + } + + @Override + public void reloadTopologies(int userId) { + if (DEBUG) { + Slog.d(TAG, "reloadTopologies mUserId=" + mUserId + "->userId=" + userId); + } + if (mUserId != userId) { + mUserId = userId; + resetTopologies(); + } + reloadTopologies(); + } + + private void resetTopologies() { + mTopologies.clear(); + appendTopologies(mImmutableTopologies); + } + + /** + * Increases all orders by 1 for those topologies currently below the order of the + * passed topology. Sets the order of the passed topology to 0. + */ + private void moveTopologyToHead(Topology topology) { + if (topology.getOrder() == 0) { + return; + } + for (var t : mTopologies.values()) { + if (t.getOrder() < topology.getOrder()) { + t.setOrder(t.getOrder() + 1); + } + } + topology.setOrder(0); + } + + private void reloadImmutableTopologies() { + mImmutableTopologies.clear(); + try (InputStream iStream = mInjector.readProductTopologies()) { + mImmutableTopologies.addAll(readTopologiesFromInputStream(iStream)); + } catch (IOException | XmlPullParserException | DatatypeConfigurationException e) { + Slog.e(TAG, "reloadImmutableTopologies for product topologies failed", e); + } + try (InputStream iStream = mInjector.readVendorTopologies()) { + mImmutableTopologies.addAll(readTopologiesFromInputStream(iStream)); + } catch (IOException | XmlPullParserException | DatatypeConfigurationException e) { + Slog.e(TAG, "reloadImmutableTopologies for vendor topologies failed", e); + } + for (var topology : mImmutableTopologies) { + topology.setImmutable(true); + } + } + + private void reloadTopologies() { + if (mUserId < 0) { + Slog.e(TAG, "Can't reload topologies for userId=" + mUserId); + return; + } + try (InputStream iStream = mInjector.readUserTopologies(mUserId)) { + appendTopologies(readTopologiesFromInputStream(iStream)); + } catch (IOException | XmlPullParserException | DatatypeConfigurationException e) { + Slog.e(TAG, "reloadTopologies failed", e); + } + } + + private void appendTopologies(List<Topology> topologyList) { + for (var topology : topologyList) { + appendTopology(topology); + } + } + + private void appendTopology(Topology topology) { + Topology restoredTopology = mTopologies.get(topology.getId()); + if (null != restoredTopology && restoredTopology.getImmutable()) { + Slog.w(TAG, "addTopology: can't override immutable topology " + + topology.getId()); + return; + } + + // If topology is not found, and we exceed the limit of topologies + // (so we can't add more topologies), then skip this topology + if (null == restoredTopology && mTopologies.size() >= MAX_NUMBER_OF_TOPOLOGIES) { + if (DEBUG) { + Slog.d(TAG, "appendTopology: MAX_NUMBER_OF_TOPOLOGIES is reached," + + " can't append topology" + topology.getId()); + } + return; + } + topology.setOrder(getOrderOrDefault(restoredTopology, mTopologies.size())); + mTopologies.put(topology.getId(), topology); + } + + private boolean prependTopology(Topology topology) { + Topology restoredTopology = mTopologies.get(topology.getId()); + if (null != restoredTopology && restoredTopology.getImmutable()) { + Slog.w(TAG, "prependTopology: can't override immutable topology " + + topology.getId()); + return false; + } + + // If topology is not found, and we exceed the limit of topologies + // remove the max order mutable topology. + if (null == restoredTopology && mTopologies.size() >= MAX_NUMBER_OF_TOPOLOGIES) { + Topology topologyToRemove = findMaxOrderMutableTopology(); + if (topologyToRemove == null) { + Slog.w(TAG, "prependTopology: can't find a topology to remove to free up space"); + return false; + } + mTopologies.remove(topologyToRemove.getId()); + if (DEBUG) { + Slog.d(TAG, "prependTopology: remove topology " + topologyToRemove.getId()); + } + } + + topology.setOrder(Integer.MAX_VALUE); + moveTopologyToHead(topology); + mTopologies.put(topology.getId(), topology); + return true; + } + + /** + * Higher order of the topology means lower priority. + */ + @Nullable + private Topology findMaxOrderMutableTopology() { + Topology res = null; + for (var topology : mTopologies.values()) { + if (topology.getImmutable()) { + continue; + } + if (res == null || res.getOrder() < topology.getOrder()) { + res = topology; + } + } + return res; + } + + private void saveTopologiesToFile() { + if (mUserId < 0) { + Slog.e(TAG, "Can't save topologies for userId=" + mUserId); + return; + } + if (mTopologies.isEmpty()) { + if (DEBUG) { + Slog.d(TAG, "No topologies to save for userId=" + mUserId); + } + return; + } + var topologyState = new DisplayTopologyState(); + topologyState.setVersion(PERSISTENT_TOPOLOGY_VERSION); + for (var topology : mTopologies.values()) { + if (!topology.getImmutable()) { + topologyState.getTopology().add(topology); + } + } + + try (var pw = mInjector.getTopologyFilePrintWriter(mUserId)) { + // use writer automatically generated from display-topology.xsd + XmlWriter.write(new XmlWriter(pw), topologyState); + pw.markSuccess(); + if (DEBUG) Slog.d(TAG, "saveTopologiesToFile " + pw); + } catch (IOException e) { + Slog.e(TAG, "saveTopologiesToFile failed", e); + } + } + + private DisplayTopology convertPersistentTopologyToDisplayTopology( + DisplayTopology currentDisplayTopology, + Display persistentDisplayTopology, + Map<String, Integer> uniqueIdToDisplayIdMapping) { + var rootNode = convertPersistentDisplayToTreeNode(persistentDisplayTopology, + currentDisplayTopology, uniqueIdToDisplayIdMapping); + int primaryDisplayId = findPrimaryDisplayId(persistentDisplayTopology, + uniqueIdToDisplayIdMapping); + if (primaryDisplayId == INVALID_DISPLAY) { + Slog.e(TAG, "Primary display id is not found in persistent topology"); + primaryDisplayId = DEFAULT_DISPLAY; + } + return new DisplayTopology(rootNode, primaryDisplayId); + } + + private DisplayTopology.TreeNode convertPersistentDisplayToTreeNode( + Display persistentDisplay, + DisplayTopology currentDisplayTopology, + Map<String, Integer> uniqueIdToDisplayIdMapping + ) { + Integer displayId = uniqueIdToDisplayIdMapping.get(persistentDisplay.getId()); + if (null == displayId) { + throw new IllegalStateException("Can't map uniqueId=" + + persistentDisplay.getId() + " to displayId"); + } + + var displayNode = DisplayTopology.findDisplay(displayId, + currentDisplayTopology.getRoot()); + if (null == displayNode) { + throw new IllegalStateException("Can't find displayId=" + + displayId + " in current topology"); + } + + List<DisplayTopology.TreeNode> children = new ArrayList<>(); + for (var child : persistentDisplay.getChildren().getDisplay()) { + children.add(convertPersistentDisplayToTreeNode(child, currentDisplayTopology, + uniqueIdToDisplayIdMapping)); + } + + return new DisplayTopology.TreeNode( + displayId, displayNode.getWidth(), displayNode.getHeight(), + toDisplayTopologyPosition(persistentDisplay.getPosition()), + persistentDisplay.getOffset(), children); + } + + private int findPrimaryDisplayId(Display persistentDisplay, + Map<String, Integer> uniqueIdToDisplayIdMapping) { + if (persistentDisplay.getPrimary()) { + var displayId = uniqueIdToDisplayIdMapping.get(persistentDisplay.getId()); + if (null == displayId) { + throw new IllegalStateException("Can't map uniqueId=" + + persistentDisplay.getId() + " to displayId"); + } + return displayId; + } + for (var child : persistentDisplay.getChildren().getDisplay()) { + var displayId = findPrimaryDisplayId(child, uniqueIdToDisplayIdMapping); + if (displayId != INVALID_DISPLAY) { + return displayId; + } + } + return INVALID_DISPLAY; + } + + @Nullable + private Topology convertTopologyForPersistence(DisplayTopology topology, String topologyId) { + var rootNode = convertTreeNodeForPersistence(topology.getRoot(), + topology.getPrimaryDisplayId(), mInjector.getDisplayIdToUniqueIdMapping()); + if (null == rootNode) { + return null; + } + + Topology persistentTopology = new Topology(); + persistentTopology.setDisplay(rootNode); + persistentTopology.setId(topologyId); + return persistentTopology; + } + + @Nullable + private Display convertTreeNodeForPersistence( + @Nullable DisplayTopology.TreeNode node, + int primaryDisplayId, + SparseArray<String> idsToUniqueIds) { + if (null == node) { + Slog.e(TAG, "Can't convertTreeNodeForPersistence, node == null"); + return null; + } + var uniqueId = idsToUniqueIds.get(node.getDisplayId()); + if (null == uniqueId) { + Slog.e(TAG, "Can't convertTreeNodeForPersistence," + + " uniqueId is not found for " + node.getDisplayId()); + return null; + } + Children children = new Children(); + for (var child : node.getChildren()) { + var display = convertTreeNodeForPersistence(child, primaryDisplayId, idsToUniqueIds); + if (null == display) { + return null; + } + children.getDisplay().add(display); + } + var root = new Display(); + root.setPosition(toPersistentPosition(node.getPosition())); + root.setId(uniqueId); + root.setOffset(node.getOffset()); + root.setPrimary(node.getDisplayId() == primaryDisplayId); + root.setChildren(children); + return root; + } + + private Position toPersistentPosition(@DisplayTopology.TreeNode.Position int pos) { + return switch (pos) { + case DisplayTopology.TreeNode.POSITION_LEFT -> Position.left; + case DisplayTopology.TreeNode.POSITION_TOP -> Position.top; + case DisplayTopology.TreeNode.POSITION_RIGHT -> Position.right; + case DisplayTopology.TreeNode.POSITION_BOTTOM -> Position.bottom; + default -> throw new IllegalArgumentException("Unknown position=" + pos); + }; + } + + @DisplayTopology.TreeNode.Position + private int toDisplayTopologyPosition(Position pos) { + return switch (pos) { + case left -> DisplayTopology.TreeNode.POSITION_LEFT; + case top -> DisplayTopology.TreeNode.POSITION_TOP; + case right -> DisplayTopology.TreeNode.POSITION_RIGHT; + case bottom -> DisplayTopology.TreeNode.POSITION_BOTTOM; + }; + } + + private List<String> getUniqueIds(@Nullable DisplayTopology.TreeNode node, + SparseArray<String> mapping, List<String> uniqueIds) { + if (null == node) { + return uniqueIds; + } + uniqueIds.add(mapping.get(node.getDisplayId())); + for (var child : node.getChildren()) { + getUniqueIds(child, mapping, uniqueIds); + } + return uniqueIds; + } + + @Nullable + private String getTopologyId(DisplayTopology topology) { + SparseArray<String> mapping = mInjector.getDisplayIdToUniqueIdMapping(); + return getTopologyId(getUniqueIds(topology.getRoot(), mapping, new ArrayList<>())); + } + + @Nullable + private String getTopologyId(List<String> uniqueIds) { + if (uniqueIds.isEmpty() || uniqueIds.contains(null)) { + return null; + } + Collections.sort(uniqueIds); + return String.join("|", uniqueIds); + } + + abstract static class Injector { + /** + * Necessary mapping for conversion of {@link DisplayTopology} which uses + * {@link android.view.DisplayInfo#displayId} to {@link DisplayTopologyState} + * which uses {@link android.view.DisplayInfo#uniqueId} + * + * @return mapping from {@link android.view.DisplayInfo#displayId} + * to {@link android.view.DisplayInfo#uniqueId} + */ + public abstract SparseArray<String> getDisplayIdToUniqueIdMapping(); + + /** + * Necessary mapping for conversion opposite to {@link #getDisplayIdToUniqueIdMapping()} + * + * @return mapping from {@link android.view.DisplayInfo#uniqueId} + * to {@link android.view.DisplayInfo#displayId} + */ + public abstract Map<String, Integer> getUniqueIdToDisplayIdMapping(); + + /** + * Reads vendor topologies, if configured. + * @return input stream with vendor-defined topologies, or null if not configured. + */ + @Nullable + public InputStream readVendorTopologies() throws FileNotFoundException { + return getFileInputStream(getVendorTopologyFile()); + } + + /** + * Reads product topologies, if configured. + * @return input stream with product-defined topologies, or null if not configured. + */ + @Nullable + public InputStream readProductTopologies() throws FileNotFoundException { + return getFileInputStream(getProductTopologyFile()); + } + + @Nullable + InputStream readUserTopologies(int userId) throws FileNotFoundException { + return getFileInputStream(getUserTopologyFile(userId)); + } + + AtomicFilePrintWriter getTopologyFilePrintWriter(int userId) throws IOException { + var atomicFile = new AtomicFile(getUserTopologyFile(userId), + /*commitTag=*/ "topology-state"); + return new AtomicFilePrintWriter(atomicFile, UTF_8); + } + + @Nullable + private FileInputStream getFileInputStream(File file) throws FileNotFoundException { + if (DEBUG) { + Slog.d(TAG, "File: " + file + " exists=" + file.exists()); + } + return !file.exists() ? null : new FileInputStream(file); + } + } +} diff --git a/services/core/xsd/Android.bp b/services/core/xsd/Android.bp index 6a50d3834355..8b7bdf5cece2 100644 --- a/services/core/xsd/Android.bp +++ b/services/core/xsd/Android.bp @@ -30,6 +30,14 @@ xsd_config { } xsd_config { + name: "display-topology", + srcs: ["display-topology/display-topology.xsd"], + api_dir: "display-topology/schema", + package_name: "com.android.server.display.topology", + gen_writer: true, +} + +xsd_config { name: "display-device-config", srcs: ["display-device-config/display-device-config.xsd"], api_dir: "display-device-config/schema", diff --git a/services/core/xsd/display-topology/OWNERS b/services/core/xsd/display-topology/OWNERS new file mode 100644 index 000000000000..6ce1ee4d3de2 --- /dev/null +++ b/services/core/xsd/display-topology/OWNERS @@ -0,0 +1 @@ +include /services/core/java/com/android/server/display/OWNERS diff --git a/services/core/xsd/display-topology/display-topology.xsd b/services/core/xsd/display-topology/display-topology.xsd new file mode 100644 index 000000000000..00f766fe018c --- /dev/null +++ b/services/core/xsd/display-topology/display-topology.xsd @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Copyright (C) 2024 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. +--> + +<!-- + This defines the format of the XML file used to define how displays are arranged + in topologies. + It is parsed in com/android/server/display/PersistentTopologyStore.java + More information on display topology can be found in DisplayTopology.java +--> +<xs:schema version="2.0" + elementFormDefault="qualified" + xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:simpleType name="position"> + <xs:restriction base="xs:string"> + <xs:enumeration value="left"/> + <xs:enumeration value="top"/> + <xs:enumeration value="right"/> + <xs:enumeration value="bottom"/> + </xs:restriction> + </xs:simpleType> + <xs:complexType name="children"> + <xs:sequence> + <xs:element type="display" name="display" maxOccurs="unbounded" minOccurs="0"/> + </xs:sequence> + </xs:complexType> + <xs:complexType name="display"> + <xs:sequence> + <xs:element type="position" name="position" /> + <xs:element type="xs:float" name="offset"/> + <xs:element type="children" name="children" /> + </xs:sequence> + <xs:attribute type="xs:string" name="id" use="required"/> + <xs:attribute type="xs:boolean" name="primary"/> + </xs:complexType> + <xs:complexType name="topology"> + <xs:sequence> + <xs:element type="display" name="display"/> + </xs:sequence> + <xs:attribute type="xs:string" name="id" use="required"/> + <xs:attribute type="xs:int" name="order" use="required"/> + <xs:attribute type="xs:boolean" name="immutable"/> + </xs:complexType> + <xs:element name="displayTopologyState"> + <xs:complexType> + <xs:sequence> + <xs:element type="topology" name="topology" maxOccurs="1000" minOccurs="0"/> + </xs:sequence> + <xs:attribute type="xs:int" name="version" use="required"/> + </xs:complexType> + </xs:element> +</xs:schema> diff --git a/services/core/xsd/display-topology/schema/current.txt b/services/core/xsd/display-topology/schema/current.txt new file mode 100644 index 000000000000..eb59e9ec5f7b --- /dev/null +++ b/services/core/xsd/display-topology/schema/current.txt @@ -0,0 +1,64 @@ +// Signature format: 2.0 +package com.android.server.display.topology { + + public class Children { + ctor public Children(); + method public java.util.List<com.android.server.display.topology.Display> getDisplay(); + } + + public class Display { + ctor public Display(); + method public com.android.server.display.topology.Children getChildren(); + method public String getId(); + method public float getOffset(); + method public com.android.server.display.topology.Position getPosition(); + method public boolean getPrimary(); + method public void setChildren(com.android.server.display.topology.Children); + method public void setId(String); + method public void setOffset(float); + method public void setPosition(com.android.server.display.topology.Position); + method public void setPrimary(boolean); + } + + public class DisplayTopologyState { + ctor public DisplayTopologyState(); + method public java.util.List<com.android.server.display.topology.Topology> getTopology(); + method public int getVersion(); + method public void setVersion(int); + } + + public enum Position { + method public String getRawName(); + enum_constant public static final com.android.server.display.topology.Position bottom; + enum_constant public static final com.android.server.display.topology.Position left; + enum_constant public static final com.android.server.display.topology.Position right; + enum_constant public static final com.android.server.display.topology.Position top; + } + + public class Topology { + ctor public Topology(); + method public com.android.server.display.topology.Display getDisplay(); + method public String getId(); + method public boolean getImmutable(); + method public int getOrder(); + method public void setDisplay(com.android.server.display.topology.Display); + method public void setId(String); + method public void setImmutable(boolean); + method public void setOrder(int); + } + + public class XmlParser { + ctor public XmlParser(); + method public static com.android.server.display.topology.DisplayTopologyState read(java.io.InputStream) throws javax.xml.datatype.DatatypeConfigurationException, java.io.IOException, org.xmlpull.v1.XmlPullParserException; + method public static String readText(org.xmlpull.v1.XmlPullParser) throws java.io.IOException, org.xmlpull.v1.XmlPullParserException; + method public static void skip(org.xmlpull.v1.XmlPullParser) throws java.io.IOException, org.xmlpull.v1.XmlPullParserException; + } + + public class XmlWriter implements java.io.Closeable { + ctor public XmlWriter(java.io.PrintWriter); + method public void close(); + method public static void write(com.android.server.display.topology.XmlWriter, com.android.server.display.topology.DisplayTopologyState) throws java.io.IOException; + } + +} + diff --git a/services/core/xsd/display-topology/schema/last_current.txt b/services/core/xsd/display-topology/schema/last_current.txt new file mode 100644 index 000000000000..e69de29bb2d1 --- /dev/null +++ b/services/core/xsd/display-topology/schema/last_current.txt diff --git a/services/core/xsd/display-topology/schema/last_removed.txt b/services/core/xsd/display-topology/schema/last_removed.txt new file mode 100644 index 000000000000..e69de29bb2d1 --- /dev/null +++ b/services/core/xsd/display-topology/schema/last_removed.txt diff --git a/services/core/xsd/display-topology/schema/removed.txt b/services/core/xsd/display-topology/schema/removed.txt new file mode 100644 index 000000000000..d802177e249b --- /dev/null +++ b/services/core/xsd/display-topology/schema/removed.txt @@ -0,0 +1 @@ +// Signature format: 2.0 diff --git a/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyXmlStoreTest.java b/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyXmlStoreTest.java new file mode 100644 index 000000000000..48822821ba01 --- /dev/null +++ b/services/tests/displayservicetests/src/com/android/server/display/DisplayTopologyXmlStoreTest.java @@ -0,0 +1,388 @@ +/* + * Copyright 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.server.display; + + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.graphics.PointF; +import android.hardware.display.DisplayTopology; +import android.util.AtomicFilePrintWriter; +import android.util.SparseArray; + +import androidx.test.filters.SmallTest; + +import com.google.common.io.CharSource; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +/** + * Tests for {@link DisplayTopologyXmlStore} + * Run: atest PersistentTopologyStoreTest + */ +@SmallTest +@RunWith(TestParameterInjector.class) +public class DisplayTopologyXmlStoreTest { + private static final String NO_TOPOLOGIES = """ + <?xml version="1.0" encoding="utf-8"?> + <displayTopologyState version="1"/> + """; + + private static final String SIMPLE_TOPOLOGY = """ + <?xml version="1.0" encoding="utf-8"?> + <displayTopologyState version="1"> + <topology id="uniqueid0|uniqueid1" order="0"> + <display id="uniqueid0" primary="true"> + <position>left</position> + <offset>0.0</offset> + <children> + <display id="uniqueid1" primary="false"> + <position>top</position> + <offset>-560.0</offset> + <children> + </children> + </display> + </children> + </display> + </topology> + </displayTopologyState> + """; + + private static final String IMMUTABLE_TOPOLOGY = """ + <?xml version="1.0" encoding="utf-8"?> + <displayTopologyState version="1"> + <topology id="uniqueid10|uniqueid11" order="0" immutable="true"> + <display id="uniqueid10" primary="true"> + <position>left</position> + <offset>0.0</offset> + <children> + <display id="uniqueid11" primary="false"> + <position>top</position> + <offset>-560.0</offset> + <children> + </children> + </display> + </children> + </display> + </topology> + </displayTopologyState> + """; + + private static final String MULTIPLE_TOPOLOGIES = """ + <?xml version="1.0" encoding="utf-8"?> + <displayTopologyState version="1"> + <topology id="uniqueid0|uniqueid1" order="0"> + <display id="uniqueid0" primary="true"> + <position>left</position> + <offset>0.0</offset> + <children> + <display id="uniqueid1" primary="false"> + <position>top</position> + <offset>-560.0</offset> + <children> + </children> + </display> + </children> + </display> + </topology> + <topology id="uniqueid0|uniqueid1|uniqueid2|uniqueid3" order="0"> + <display id="uniqueid1" primary="false"> + <position>left</position> + <offset>0.0</offset> + <children> + <display id="uniqueid0" primary="false"> + <position>top</position> + <offset>-50</offset> + <children> + </children> + </display> + <display id="uniqueid2" primary="true"> + <position>right</position> + <offset>-100</offset> + <children> + <display id="uniqueid3" primary="false"> + <position>bottom</position> + <offset>-300</offset> + <children> + </children> + </display> + </children> + </display> + </children> + </display> + </topology> + </displayTopologyState> + """; + + private static InputStream asInputStream(String value) throws IOException { + return CharSource.wrap(value).asByteSource(UTF_8).openStream(); + } + + private static SparseArray<String> generateIdToUniqueId() { + var res = new SparseArray<String>(); + for (int i = 0; i < 200; i++) { + res.put(i, "uniqueid" + i); + } + return res; + } + + private static Map<String, Integer> generateUniqueIdToId() { + var res = new HashMap<String, Integer>(); + for (int i = 0; i < 200; i++) { + res.put("uniqueid" + i, i); + } + return res; + } + + private static DisplayTopology generateTopology(int displayId1, int displayId2) { + var topology = new DisplayTopology(); + topology.addDisplay(displayId1, 800f, 600f); + topology.addDisplay(displayId2, 1920f, 1080f); + return topology; + } + + @Mock + private DisplayTopologyXmlStore.Injector mInjector; + @Mock + private AtomicFilePrintWriter mPrintWriter0; + @Mock + private AtomicFilePrintWriter mPrintWriter1; + + private DisplayTopology mTopology; + + /** Setup tests. */ + @Before + public void setup() throws IOException { + MockitoAnnotations.initMocks(this); + configureTopologyFile(/*userId=*/ 0, NO_TOPOLOGIES, mPrintWriter0); + configureTopologyFile(/*userId=*/ 1, SIMPLE_TOPOLOGY, mPrintWriter1); + configureTopologyFile(/*userId=*/ 2, MULTIPLE_TOPOLOGIES, mPrintWriter1); + + when(mInjector.getDisplayIdToUniqueIdMapping()).thenReturn(generateIdToUniqueId()); + when(mInjector.getUniqueIdToDisplayIdMapping()).thenReturn(generateUniqueIdToId()); + + mTopology = generateTopology(0, 1); + } + + @Test + public void testSaveAndRestoreTopologyWithoutFileStreams() throws IOException { + final float initialOffset = -560f; + final float newOffset = -300f; + + var store = new DisplayTopologyXmlStore(mInjector); + assertThat(store.saveTopology(mTopology)).isTrue(); + assertThat(mTopology.getRoot().getChildren().getFirst().getOffset()) + .isEqualTo(initialOffset); + assertThat(mTopology.getRoot().getWidth()).isEqualTo(800f); + assertThat(mTopology.getRoot().getHeight()).isEqualTo(600f); + + // Change display size + assertThat(mTopology.updateDisplay(0, 640f, 480f)).isTrue(); + assertThat(mTopology.getRoot().getWidth()).isEqualTo(640f); + assertThat(mTopology.getRoot().getHeight()).isEqualTo(480f); + + // Move display#1. + mTopology.rearrange(Map.of(0, new PointF(0, 0), + 1, new PointF(newOffset, -1080f))); + assertThat(mTopology.getRoot().getChildren().getFirst().getOffset()).isEqualTo(newOffset); + + // Restore the topology, should apply saved offset, while keeping the current display sizes + mTopology = store.restoreTopology(mTopology); + + // Offset is taken from the persisted topology. + assertThat(mTopology.getRoot().getChildren().getFirst().getOffset()) + .isEqualTo(initialOffset); + + // Size is taken from the current topology + assertThat(mTopology.getRoot().getWidth()).isEqualTo(640f); + assertThat(mTopology.getRoot().getHeight()).isEqualTo(480f); + + // reloadTopologies was never called so, no file operations should have been performed. + verify(mInjector, never()).readUserTopologies(anyInt()); + verify(mInjector, never()).getTopologyFilePrintWriter(anyInt()); + } + + @Test + public void testSaveTopologyInPrintWriter() throws IOException { + var store = new DisplayTopologyXmlStore(mInjector); + store.reloadTopologies(/*userId=*/ 0); + assertThat(store.saveTopology(mTopology)).isTrue(); + verify(mPrintWriter0).print(eq(SIMPLE_TOPOLOGY)); + verify(mPrintWriter0).markSuccess(); + verify(mPrintWriter0).close(); + } + + @Test + public void testRestoreTopology() { + var store = new DisplayTopologyXmlStore(mInjector); + store.reloadTopologies(/*userId=*/ 0); + var restoredTopology = store.restoreTopology(mTopology); + + // Should return null because there was nothing persisted before. + assertThat(restoredTopology).isNull(); + + // Persist topology + assertThat(store.saveTopology(mTopology)).isTrue(); + + // Should return new instance (restored), but equal. + var restoredTopologyAfterSave = store.restoreTopology(mTopology); + assertThat(restoredTopologyAfterSave).isNotSameInstanceAs(mTopology); + assertThat(restoredTopologyAfterSave).isEqualTo(mTopology); + } + + @Test + public void testChangeUser() { + var store = new DisplayTopologyXmlStore(mInjector); + // Move display#1. + mTopology.rearrange(Map.of(0, new PointF(0, 0), + 1, new PointF(-10f, -1080f))); + assertThat(mTopology.getRoot().getChildren().getFirst().getOffset()).isEqualTo(-10f); + + store.reloadTopologies(/*userId=*/ 1); + // Should return new instance (restored), with new offset. + var restoredTopology = store.restoreTopology(mTopology); + assertThat(restoredTopology).isNotSameInstanceAs(mTopology); + assertThat(restoredTopology.getRoot().getChildren().getFirst().getOffset()) + .isEqualTo(-560f); + + // Change user. + store.reloadTopologies(/*userId=*/ 0); + // Should return null because the topology is not found for user 0. + assertThat(store.restoreTopology(mTopology)).isNull(); + } + + @Test + public void testMultipleUserTopologies() { + var store = new DisplayTopologyXmlStore(mInjector); + store.reloadTopologies(/*userId=*/ 2); + + var topology4Displays = new DisplayTopology(); + topology4Displays.addDisplay(0, 800f, 600f); + topology4Displays.addDisplay(1, 1920f, 1080f); + topology4Displays.addDisplay(2, 480f, 640f); + topology4Displays.addDisplay(3, 768f, 1024f); + + var restored = store.restoreTopology(topology4Displays); + assertThat(restored).isNotNull(); + assertThat(restored.getPrimaryDisplayId()).isEqualTo(2); + assertThat(restored.getRoot()).isNotNull(); + assertThat(restored.getRoot().getDisplayId()).isEqualTo(1); + assertThat(restored.getRoot().getWidth()).isEqualTo(1920f); + assertThat(restored.getRoot().getHeight()).isEqualTo(1080f); + assertThat(restored.getRoot().getOffset()).isEqualTo(0); + assertThat(restored.getRoot().getChildren().size()).isEqualTo(2); + assertThat(restored.getRoot().getChildren().getFirst().getDisplayId()).isEqualTo(0); + assertThat(restored.getRoot().getChildren().getFirst().getWidth()).isEqualTo(800f); + assertThat(restored.getRoot().getChildren().getFirst().getHeight()).isEqualTo(600f); + assertThat(restored.getRoot().getChildren().getFirst().getOffset()).isEqualTo(-50); + assertThat(restored.getRoot().getChildren().getFirst().getChildren().size()) + .isEqualTo(0); + assertThat(restored.getRoot().getChildren().getLast().getDisplayId()).isEqualTo(2); + assertThat(restored.getRoot().getChildren().getLast().getWidth()).isEqualTo(480f); + assertThat(restored.getRoot().getChildren().getLast().getHeight()).isEqualTo(640f); + assertThat(restored.getRoot().getChildren().getLast().getOffset()).isEqualTo(-100); + assertThat(restored.getRoot().getChildren().getLast().getChildren().size()) + .isEqualTo(1); + + assertThat(restored.getRoot().getChildren().getLast().getChildren().getFirst() + .getDisplayId()).isEqualTo(3); + assertThat(restored.getRoot().getChildren().getLast().getChildren().getFirst() + .getWidth()).isEqualTo(768f); + assertThat(restored.getRoot().getChildren().getLast().getChildren().getFirst() + .getHeight()).isEqualTo(1024f); + assertThat(restored.getRoot().getChildren().getLast().getChildren().getFirst() + .getOffset()).isEqualTo(-300); + } + + @Test + public void testLimitNumberOfTopologies() { + var store = new DisplayTopologyXmlStore(mInjector); + store.reloadTopologies(/*userId=*/ 0); + for (int i = 0; i < 110; i++) { + assertThat(store.saveTopology(generateTopology(i, i + 1))).isTrue(); + } + + assertThat(store.restoreTopology(generateTopology(110, 111))).isNull(); + assertThat(store.restoreTopology(generateTopology(109, 110))).isNotNull(); + assertThat(store.restoreTopology(generateTopology(10, 11))).isNotNull(); + assertThat(store.restoreTopology(generateTopology(9, 10))).isNull(); + for (int i = 0; i < 100; i++) { + assertThat(store.restoreTopology(generateTopology(i + 10, i + 11))).isNotNull(); + } + } + + @Test + public void testVendorTopology() throws IOException { + configureVendorTopologyFile(IMMUTABLE_TOPOLOGY); + var store = new DisplayTopologyXmlStore(mInjector); + store.reloadTopologies(/*userId=*/ 0); + var restored = store.restoreTopology(generateTopology(10, 11)); + assertThat(restored).isNotNull(); + assertThat(store.saveTopology(restored)).isFalse(); + + var userTopology = generateTopology(0, 1); + assertThat(store.restoreTopology(userTopology)).isNull(); + assertThat(store.saveTopology(userTopology)).isTrue(); + } + + @Test + public void testProductTopology() throws IOException { + configureProductTopologyFile(IMMUTABLE_TOPOLOGY); + var store = new DisplayTopologyXmlStore(mInjector); + store.reloadTopologies(/*userId=*/ 0); + var restored = store.restoreTopology(generateTopology(10, 11)); + assertThat(restored).isNotNull(); + assertThat(store.saveTopology(restored)).isFalse(); + + var userTopology = generateTopology(0, 1); + assertThat(store.restoreTopology(userTopology)).isNull(); + assertThat(store.saveTopology(userTopology)).isTrue(); + } + + private void configureTopologyFile(int userId, String initialFileContent, + AtomicFilePrintWriter printWriter) throws IOException { + doReturn(asInputStream(initialFileContent)).when(mInjector).readUserTopologies(eq(userId)); + doReturn(printWriter).when(mInjector).getTopologyFilePrintWriter(eq(userId)); + } + + private void configureVendorTopologyFile(String initialFileContent) throws IOException { + doReturn(asInputStream(initialFileContent)).when(mInjector).readVendorTopologies(); + } + + private void configureProductTopologyFile(String initialFileContent) throws IOException { + doReturn(asInputStream(initialFileContent)).when(mInjector).readProductTopologies(); + } +} |