Merge "Show display topology in the pane" into main
This commit is contained in:
committed by
Android (Google) Code Review
commit
7b7f72d902
@@ -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>
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user