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

View File

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