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)) + } +}