Merge "Use topology listener to detect changes" into main

This commit is contained in:
Matthew DeVore
2025-01-08 15:44:06 -08:00
committed by Android (Google) Code Review
2 changed files with 177 additions and 21 deletions

View File

@@ -45,7 +45,9 @@ import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import java.util.Locale
import java.util.function.Consumer
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -210,6 +212,8 @@ class DisplayTopologyPreference(context : Context)
*/
private var mPaneNeedsRefresh = false
private val mTopologyListener = Consumer<DisplayTopology> { applyTopology(it) }
init {
layoutResource = R.layout.display_topology_preference
@@ -238,10 +242,17 @@ class DisplayTopologyPreference(context : Context)
}
override fun onAttached() {
super.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
injector.registerTopologyListener(mTopologyListener)
}
override fun onDetached() {
super.onDetached()
injector.unregisterTopologyListener(mTopologyListener)
}
override fun onGlobalLayout() {
@@ -265,6 +276,14 @@ class DisplayTopologyPreference(context : Context)
open val wallpaper : Drawable
get() = WallpaperManager.getInstance(context).drawable ?: ColorDrawable(Color.BLACK)
open fun registerTopologyListener(listener: Consumer<DisplayTopology>) {
displayManager.registerTopologyListener(context.mainExecutor, listener)
}
open fun unregisterTopologyListener(listener: Consumer<DisplayTopology>) {
displayManager.unregisterTopologyListener(listener)
}
}
/**
@@ -294,12 +313,18 @@ class DisplayTopologyPreference(context : Context)
private var mTopologyInfo : TopologyInfo? = null
private var mDrag : BlockDrag? = null
@VisibleForTesting fun refreshPane() {
val recycleableBlocks = ArrayDeque<DisplayBlock>()
for (i in 0..mPaneContent.childCount-1) {
recycleableBlocks.add(mPaneContent.getChildAt(i) as DisplayBlock)
private fun sameDisplayPosition(a: RectF, b: RectF): Boolean {
// Comparing in display coordinates, so a 1 pixel difference will be less than one dp in
// pane coordinates. Canceling the drag and refreshing the pane will not change the apparent
// position of displays in the pane.
val EPSILON = 1f
return EPSILON > abs(a.left - b.left) &&
EPSILON > abs(a.right - b.right) &&
EPSILON > abs(a.top - b.top) &&
EPSILON > abs(a.bottom - b.bottom)
}
@VisibleForTesting fun refreshPane() {
val topology = injector.displayTopology
if (topology == null) {
// This occurs when no topology is active.
@@ -309,18 +334,39 @@ class DisplayTopologyPreference(context : Context)
mTopologyInfo = null
return
}
applyTopology(topology)
}
@VisibleForTesting var mTimesReceivedSameTopology = 0
private fun applyTopology(topology: DisplayTopology) {
mTopologyHint.text = context.getString(R.string.external_display_topology_hint)
val blocksPos = buildList {
val oldBounds = mTopologyInfo?.positions
val newBounds = buildList {
val bounds = topology.absoluteBounds
(0..bounds.size()-1).forEach {
add(Pair(bounds.keyAt(it), bounds.valueAt(it)))
}
}
if (oldBounds != null && oldBounds.size == newBounds.size &&
oldBounds.zip(newBounds).all { (old, new) ->
old.first == new.first && sameDisplayPosition(old.second, new.second)
}) {
mTimesReceivedSameTopology++
return
}
val recycleableBlocks = ArrayDeque<DisplayBlock>()
for (i in 0..mPaneContent.childCount-1) {
recycleableBlocks.add(mPaneContent.getChildAt(i) as DisplayBlock)
}
val scaling = TopologyScale(
mPaneContent.width, minEdgeLength = 60, maxBlockRatio = 0.12f,
blocksPos.map { it.second }.toList())
newBounds.map { it.second }.toList())
mPaneHolder.layoutParams.let {
val newHeight = scaling.paneHeight.toInt()
if (it.height != newHeight) {
@@ -329,7 +375,7 @@ class DisplayTopologyPreference(context : Context)
}
}
blocksPos.forEach { (id, pos) ->
newBounds.forEach { (id, pos) ->
val block = recycleableBlocks.removeFirstOrNull() ?: DisplayBlock(context).apply {
// We need a separate wallpaper Drawable for each display block, since each needs to
// be drawn at a separate size.
@@ -348,9 +394,12 @@ class DisplayTopologyPreference(context : Context)
}
}
}
mPaneContent.removeViews(blocksPos.size, recycleableBlocks.size)
mPaneContent.removeViews(newBounds.size, recycleableBlocks.size)
mTopologyInfo = TopologyInfo(topology, scaling, blocksPos)
mTopologyInfo = TopologyInfo(topology, scaling, newBounds)
// Cancel the drag if one is in progress.
mDrag = null
}
private fun onBlockTouchDown(

View File

@@ -18,6 +18,7 @@ package com.android.settings.connecteddevice.display
import android.hardware.display.DisplayTopology.TreeNode.POSITION_BOTTOM
import android.hardware.display.DisplayTopology.TreeNode.POSITION_LEFT
import android.hardware.display.DisplayTopology.TreeNode.POSITION_TOP
import android.content.Context
import android.graphics.Color
@@ -34,6 +35,10 @@ import androidx.test.core.view.MotionEventBuilder
import com.android.settings.R
import com.google.common.truth.Truth.assertThat
import java.util.function.Consumer
import kotlin.math.abs
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -54,8 +59,9 @@ class DisplayTopologyPreferenceTest {
}
class TestInjector(context : Context) : DisplayTopologyPreference.Injector(context) {
var topology : DisplayTopology? = null
var systemWallpaper : Drawable? = null
var topology: DisplayTopology? = null
var systemWallpaper: Drawable? = null
var topologyListener: Consumer<DisplayTopology>? = null
override var displayTopology : DisplayTopology?
get() = topology
@@ -63,6 +69,21 @@ class DisplayTopologyPreferenceTest {
override val wallpaper : Drawable
get() = systemWallpaper!!
override fun registerTopologyListener(listener: Consumer<DisplayTopology>) {
if (topologyListener != null) {
throw IllegalStateException(
"already have a listener registered: ${topologyListener}")
}
topologyListener = listener
}
override fun unregisterTopologyListener(listener: Consumer<DisplayTopology>) {
if (topologyListener != listener) {
throw IllegalStateException("no such listener registered: ${listener}")
}
topologyListener = null
}
}
@Test
@@ -81,20 +102,21 @@ class DisplayTopologyPreferenceTest {
.map { preference.mPaneContent.getChildAt(it) as DisplayBlock }
.toList()
/**
* Sets up a simple topology in the pane with two displays. Returns the left-hand display and
* right-hand display in order in a list. The right-hand display is the root.
*/
fun setupTwoDisplays(): List<DisplayBlock> {
fun twoDisplayTopology(childPosition: Int, childOffset: Float): DisplayTopology {
val primaryId = 1
val child = DisplayTopology.TreeNode(
/* displayId= */ 42, /* width= */ 100f, /* height= */ 80f,
POSITION_LEFT, /* offset= */ 42f)
childPosition, childOffset)
val root = DisplayTopology.TreeNode(
/* displayId= */ 0, /* width= */ 200f, /* height= */ 160f,
POSITION_LEFT, /* offset= */ 0f)
primaryId, /* width= */ 200f, /* height= */ 160f, POSITION_LEFT, /* offset= */ 0f)
root.addChild(child)
injector.topology = DisplayTopology(root, /*primaryDisplayId=*/ 0)
return DisplayTopology(root, primaryId)
}
/** Uses the topology in the injector to populate and prepare the pane for interaction. */
fun preparePane() {
// This layoutParams needs to be non-null for the global layout handler.
preference.mPaneHolder.layoutParams = FrameLayout.LayoutParams(
/* width= */ 640, /* height= */ 480)
@@ -106,6 +128,17 @@ class DisplayTopologyPreferenceTest {
preference.onAttached()
preference.onGlobalLayout()
}
/**
* Sets up a simple topology in the pane with two displays. Returns the left-hand display and
* right-hand display in order in a list. The right-hand display is the root.
*/
fun setupTwoDisplays(childPosition: Int = POSITION_LEFT, childOffset: Float = 42f):
List<DisplayBlock> {
injector.topology = twoDisplayTopology(childPosition, childOffset)
preparePane()
val paneChildren = getPaneChildren()
assertThat(paneChildren).hasSize(2)
@@ -219,4 +252,78 @@ class DisplayTopologyPreferenceTest {
assertThat(childrenAfter).hasSize(3)
assertThat(childrenAfter.subList(0, 2)).isEqualTo(childrenBefore)
}
@Test
fun applyNewTopologyViaListenerUpdate() {
setupTwoDisplays()
val newTopology = injector.topology!!.copy()
newTopology.addDisplay(/* displayId= */ 8008, /* width= */ 300f, /* height= */ 320f)
injector.topology = newTopology
injector.topologyListener!!.accept(newTopology)
assertThat(preference.mTimesReceivedSameTopology).isEqualTo(0)
val paneChildren = getPaneChildren()
assertThat(paneChildren).hasSize(3)
// Look for a display with the same unusual aspect ratio as the one we've added.
val expectedAspectRatio = 300f/320f
assertThat(paneChildren
.map { (it.layoutParams.width.toFloat() + BLOCK_PADDING*2) /
(it.layoutParams.height.toFloat() + BLOCK_PADDING*2) }
.filter { abs(it - expectedAspectRatio) < 0.001f }
).hasSize(1)
}
@Test
fun ignoreListenerUpdateOfUnchangedTopology() {
injector.topology = twoDisplayTopology(POSITION_TOP, /* offset= */ 12.0f)
preparePane()
assertThat(preference.mTimesReceivedSameTopology).isEqualTo(0)
injector.topology = twoDisplayTopology(POSITION_TOP, /* offset= */ 12.1f)
injector.topologyListener!!.accept(injector.topology!!)
assertThat(preference.mTimesReceivedSameTopology).isEqualTo(1)
}
@Test
fun updatedTopologyCancelsDragIfNonTrivialChange() {
val (leftBlock, rightBlock) = setupTwoDisplays(POSITION_LEFT, /* childOffset= */ 42f)
assertThat(leftBlock.unpaddedY).isWithin(0.01f).of(142.17f)
leftBlock.dispatchTouchEvent(MotionEventBuilder.newBuilder()
.setAction(MotionEvent.ACTION_DOWN)
.setPointer(0f, 0f)
.build())
leftBlock.dispatchTouchEvent(MotionEventBuilder.newBuilder()
.setAction(MotionEvent.ACTION_MOVE)
.setPointer(0f, 30f)
.build())
assertThat(leftBlock.unpaddedY).isWithin(0.01f).of(172.17f)
// Offset is only different by 0.5 dp, so the drag will not cancel.
injector.topology = twoDisplayTopology(POSITION_LEFT, /* childOffset= */ 41.5f)
injector.topologyListener!!.accept(injector.topology!!)
assertThat(leftBlock.unpaddedY).isWithin(0.01f).of(172.17f)
// Move block farther downward.
leftBlock.dispatchTouchEvent(MotionEventBuilder.newBuilder()
.setAction(MotionEvent.ACTION_MOVE)
.setPointer(0f, 50f)
.build())
assertThat(leftBlock.unpaddedY).isWithin(0.01f).of(192.17f)
injector.topology = twoDisplayTopology(POSITION_LEFT, /* childOffset= */ 20f)
injector.topologyListener!!.accept(injector.topology!!)
assertThat(leftBlock.unpaddedY).isWithin(0.01f).of(125.67f)
// Another move in the opposite direction should not move the left block.
leftBlock.dispatchTouchEvent(MotionEventBuilder.newBuilder()
.setAction(MotionEvent.ACTION_MOVE)
.setPointer(0f, -20f)
.build())
assertThat(leftBlock.unpaddedY).isWithin(0.01f).of(125.67f)
}
}