diff --git a/res/layout/display_topology_preference.xml b/res/layout/display_topology_preference.xml
index d2e430027a5..beaf816e28f 100644
--- a/res/layout/display_topology_preference.xml
+++ b/res/layout/display_topology_preference.xml
@@ -16,8 +16,9 @@
@@ -27,14 +28,14 @@
android:layout_width="match_parent"
android:src="@drawable/display_topology_background"/>
+ android:layout_height="match_parent"/>
+ android:paddingBottom="10dp"
+ android:paddingTop="10dp" />
diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
index 76abc031055..9cac7727a7f 100644
--- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
+++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
@@ -16,14 +16,32 @@
package com.android.settings.connecteddevice.display
+import android.app.WallpaperManager
import com.android.settings.R
import android.content.Context
+import android.graphics.Color
import android.graphics.Point
import android.graphics.PointF
import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.ColorDrawable
+import android.hardware.display.DisplayManager
+import android.hardware.display.DisplayTopology
+import android.hardware.display.DisplayTopology.TreeNode.POSITION_BOTTOM
+import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT
+import android.hardware.display.DisplayTopology.TreeNode.POSITION_RIGHT
+import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP
+import android.util.Log
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.annotation.VisibleForTesting
import androidx.preference.Preference
+import androidx.preference.PreferenceViewHolder
import java.util.Locale
@@ -143,11 +161,27 @@ class TopologyScale(
const val PREFERENCE_KEY = "display_topology_preference"
+/** dp of padding on each side of a display block. */
+const val BLOCK_PADDING = 2
+
/**
* DisplayTopologyPreference allows the user to change the display topology
* when there is one or more extended display attached.
*/
-class DisplayTopologyPreference(context : Context) : Preference(context) {
+class DisplayTopologyPreference(context : Context)
+ : Preference(context), ViewTreeObserver.OnGlobalLayoutListener {
+ @VisibleForTesting lateinit var mPaneContent : FrameLayout
+ @VisibleForTesting lateinit var mPaneHolder : FrameLayout
+ @VisibleForTesting lateinit var mTopologyHint : TextView
+
+ @VisibleForTesting var injector : Injector
+
+ /**
+ * This is needed to prevent a repopulation of the pane causing another
+ * relayout and vice-versa ad infinitum.
+ */
+ private var mPaneNeedsRefresh = false
+
init {
layoutResource = R.layout.display_topology_preference
@@ -155,5 +189,108 @@ class DisplayTopologyPreference(context : Context) : Preference(context) {
isSelectable = false
key = PREFERENCE_KEY
+
+ injector = Injector()
+ }
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder) {
+ super.onBindViewHolder(holder)
+
+ val newPane = holder.findViewById(R.id.display_topology_pane_content) as FrameLayout
+ if (this::mPaneContent.isInitialized) {
+ if (newPane == mPaneContent) {
+ return
+ }
+ mPaneContent.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ }
+ mPaneContent = newPane
+ mPaneHolder = holder.itemView as FrameLayout
+ mTopologyHint = holder.findViewById(R.id.topology_hint) as TextView
+ mPaneContent.viewTreeObserver.addOnGlobalLayoutListener(this)
+ }
+
+ override fun onAttached() {
+ // We don't know if topology changes happened when we were detached, as it is impossible to
+ // listen at that time (we must remove listeners when detaching). Setting this flag makes
+ // the following onGlobalLayout call refresh the pane.
+ mPaneNeedsRefresh = true
+ }
+
+ override fun onGlobalLayout() {
+ if (mPaneNeedsRefresh) {
+ mPaneNeedsRefresh = false
+ refreshPane()
+ }
+ }
+
+ open class Injector {
+ open fun displayTopology(context : Context) : DisplayTopology? {
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ return displayManager.displayTopology
+ }
+
+ open fun wallpaper(context : Context) : Drawable {
+ return WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK)
+ }
+ }
+
+ private fun calcAbsRects(
+ dest : MutableMap, n : DisplayTopology.TreeNode, x : Float, y : Float) {
+ dest.put(n.displayId, RectF(x, y, x + n.width, y + n.height))
+
+ for (c in n.children) {
+ val (xoff, yoff) = when (c.position) {
+ POSITION_LEFT -> Pair(-c.width, +c.offset)
+ POSITION_RIGHT -> Pair(+n.width, +c.offset)
+ POSITION_TOP -> Pair(+c.offset, -c.height)
+ POSITION_BOTTOM -> Pair(+c.offset, +n.height)
+ else -> throw IllegalStateException("invalid position for display: ${c}")
+ }
+ calcAbsRects(dest, c, x + xoff, y + yoff)
+ }
+ }
+
+ private fun refreshPane() {
+ mPaneContent.removeAllViews()
+
+ val root = injector.displayTopology(context)?.root
+ if (root == null) {
+ // This occurs when no topology is active.
+ // TODO(b/352648432): show main display or mirrored displays rather than an empty pane.
+ mTopologyHint.text = ""
+ return
+ }
+ mTopologyHint.text = context.getString(R.string.external_display_topology_hint)
+
+ val blocksPos = buildMap { calcAbsRects(this, root, x = 0f, y = 0f) }
+
+ val scaling = TopologyScale(
+ mPaneContent.width, minEdgeLength = 60, maxBlockRatio = 0.12f, blocksPos.values)
+ mPaneHolder.layoutParams.let {
+ if (it.height != scaling.paneHeight) {
+ it.height = scaling.paneHeight
+ mPaneHolder.layoutParams = it
+ }
+ }
+ val wallpaper = injector.wallpaper(context)
+ blocksPos.values.forEach { p ->
+ Button(context).apply {
+ isScrollContainer = false
+ isVerticalScrollBarEnabled = false
+ isHorizontalScrollBarEnabled = false
+ background = wallpaper
+ val topLeft = scaling.displayToPaneCoor(PointF(p.left, p.top))
+ val bottomRight = scaling.displayToPaneCoor(PointF(p.right, p.bottom))
+
+ mPaneContent.addView(this)
+
+ val layout = layoutParams
+ layout.width = bottomRight.x - topLeft.x - BLOCK_PADDING * 2
+ layout.height = bottomRight.y - topLeft.y - BLOCK_PADDING * 2
+ layoutParams = layout
+ x = (topLeft.x + BLOCK_PADDING).toFloat()
+ y = (topLeft.y + BLOCK_PADDING).toFloat()
+ }
+ }
}
}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt
new file mode 100644
index 00000000000..ad633cc8c8a
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+package com.android.settings.connecteddevice.display
+
+import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.ColorDrawable
+import android.hardware.display.DisplayTopology
+import android.view.View
+import android.widget.FrameLayout
+import androidx.preference.PreferenceViewHolder
+import androidx.test.core.app.ApplicationProvider
+
+import com.android.settings.R
+import com.google.common.truth.Truth.assertThat
+
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class DisplayTopologyPreferenceTest {
+ val context = ApplicationProvider.getApplicationContext()
+ val preference = DisplayTopologyPreference(context)
+ val injector = TestInjector()
+ val rootView = View.inflate(context, preference.layoutResource, /*parent=*/ null)
+ val holder = PreferenceViewHolder.createInstanceForTests(rootView)
+ val wallpaper = ColorDrawable(Color.MAGENTA)
+
+ init {
+ preference.injector = injector
+ injector.systemWallpaper = wallpaper
+ preference.onBindViewHolder(holder)
+ }
+
+ class TestInjector : DisplayTopologyPreference.Injector() {
+ var topology : DisplayTopology? = null
+ var systemWallpaper : Drawable? = null
+
+ override fun displayTopology(context : Context) : DisplayTopology? { return topology }
+
+ override fun wallpaper(context : Context) : Drawable { return systemWallpaper!! }
+ }
+
+ @Test
+ fun disabledTopology() {
+ preference.onAttached()
+ preference.onGlobalLayout()
+
+ assertThat(preference.mPaneContent.childCount).isEqualTo(0)
+ // TODO(b/352648432): update test when we show the main display even when
+ // a topology is not active.
+ assertThat(preference.mTopologyHint.text).isEqualTo("")
+ }
+
+ @Test
+ fun twoDisplaysGenerateBlocks() {
+ val child = DisplayTopology.TreeNode(
+ /* displayId= */ 42, /* width= */ 100f, /* height= */ 80f,
+ POSITION_LEFT, /* offset= */ 42f)
+ val root = DisplayTopology.TreeNode(
+ /* displayId= */ 0, /* width= */ 200f, /* height= */ 160f,
+ POSITION_LEFT, /* offset= */ 0f)
+ root.addChild(child)
+ injector.topology = DisplayTopology(root, /*primaryDisplayId=*/ 0)
+
+ // This layoutParams needs to be non-null for the global layout handler.
+ preference.mPaneHolder.layoutParams = FrameLayout.LayoutParams(
+ /* width= */ 640, /* height= */ 480)
+
+ // Force pane width to have a reasonable value (hundreds of dp) so the TopologyScale is
+ // calculated reasonably.
+ preference.mPaneContent.left = 0
+ preference.mPaneContent.right = 640
+
+ preference.onAttached()
+ preference.onGlobalLayout()
+
+ assertThat(preference.mPaneContent.childCount).isEqualTo(2)
+ val block0 = preference.mPaneContent.getChildAt(0)
+ val block1 = preference.mPaneContent.getChildAt(1)
+
+ // Block of child display is on the left.
+ val (childBlock, rootBlock) = if (block0.x < block1.x)
+ listOf(block0, block1)
+ else
+ listOf(block1, block0)
+
+ // After accounting for padding, child should be half the length of root in each dimension.
+ assertThat(childBlock.layoutParams.width + BLOCK_PADDING)
+ .isEqualTo(rootBlock.layoutParams.width / 2)
+ assertThat(childBlock.layoutParams.height + BLOCK_PADDING)
+ .isEqualTo(rootBlock.layoutParams.height / 2)
+ assertThat(childBlock.y).isGreaterThan(rootBlock.y)
+ assertThat(block0.background).isEqualTo(wallpaper)
+ assertThat(block1.background).isEqualTo(wallpaper)
+ assertThat(rootBlock.x - BLOCK_PADDING * 2)
+ .isEqualTo(childBlock.x + childBlock.layoutParams.width)
+
+ assertThat(preference.mTopologyHint.text)
+ .isEqualTo(context.getString(R.string.external_display_topology_hint))
+ }
+}