Merge "Show display topology in the pane" into main

This commit is contained in:
Matthew DeVore
2024-12-16 16:24:18 -08:00
committed by Android (Google) Code Review
3 changed files with 264 additions and 6 deletions

View File

@@ -16,8 +16,9 @@
<FrameLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/display_topology_pane_holder"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:layout_height="160dp" android:layout_height="wrap_content"
android:layout_width="match_parent" android:layout_width="match_parent"
android:paddingHorizontal="@dimen/display_topology_pane_margin" android:paddingHorizontal="@dimen/display_topology_pane_margin"
android:orientation="horizontal"> android:orientation="horizontal">
@@ -27,14 +28,14 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:src="@drawable/display_topology_background"/> android:src="@drawable/display_topology_background"/>
<FrameLayout <FrameLayout
android:id="@+id/display_topology_container" android:id="@+id/display_topology_pane_content"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"/> android:layout_height="match_parent"/>
<TextView <TextView
android:id="@+id/topology_hint" android:id="@+id/topology_hint"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_gravity="top|center_horizontal" android:layout_gravity="top|center_horizontal"
android:paddingTop="10dp" android:paddingBottom="10dp"
android:text="@string/external_display_topology_hint"/> android:paddingTop="10dp" />
</FrameLayout> </FrameLayout>

View File

@@ -16,14 +16,32 @@
package com.android.settings.connecteddevice.display package com.android.settings.connecteddevice.display
import android.app.WallpaperManager
import com.android.settings.R import com.android.settings.R
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.Point import android.graphics.Point
import android.graphics.PointF import android.graphics.PointF
import android.graphics.RectF 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.Preference
import androidx.preference.PreferenceViewHolder
import java.util.Locale import java.util.Locale
@@ -143,11 +161,27 @@ class TopologyScale(
const val PREFERENCE_KEY = "display_topology_preference" 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 * DisplayTopologyPreference allows the user to change the display topology
* when there is one or more extended display attached. * 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 { init {
layoutResource = R.layout.display_topology_preference layoutResource = R.layout.display_topology_preference
@@ -155,5 +189,108 @@ class DisplayTopologyPreference(context : Context) : Preference(context) {
isSelectable = false isSelectable = false
key = PREFERENCE_KEY 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<Int, RectF>, 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()
}
}
} }
} }

View File

@@ -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<Context>()
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))
}
}