From 807cf78a51ec247d2027060c1d7872ee7e267f05 Mon Sep 17 00:00:00 2001 From: Matthew DeVore Date: Thu, 5 Dec 2024 19:05:42 +0000 Subject: [PATCH] Show display topology in the pane Populate the topology pane with the topology as returned from DisplayManager. This adds padding but not the proper rounded corners or highlighting for the blocks. That will come later, probably after feature complete while still in dogfood. Test: add and remove overlays while external display fragment is shown - verify pane is refreshed Test: add two overlay displays, verify two blocks appear in pane with system wallpaper Test: with no freeform window displays, verify a "not enabled" message appears in pane with no display blocks Test: DisplayTopologyPreferenceTest Flag: com.android.settings.flags.display_topology_pane_in_display_list Bug: b/352648432 Change-Id: Ibb35af53c24d6feb1d763e4b2bf2ec9fee2ae24d --- res/layout/display_topology_preference.xml | 11 +- .../display/DisplayTopology.kt | 139 +++++++++++++++++- .../display/DisplayTopologyPreferenceTest.kt | 120 +++++++++++++++ 3 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt 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)) + } +}