diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt index 288035e2419..c4f0b29ac00 100644 --- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt +++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt @@ -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 { 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) { + displayManager.registerTopologyListener(context.mainExecutor, listener) + } + + open fun unregisterTopologyListener(listener: Consumer) { + 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() - 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() + 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( diff --git a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt index 33cdb3eb92d..9ab2cec5029 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt +++ b/tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt @@ -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? = null override var displayTopology : DisplayTopology? get() = topology @@ -63,6 +69,21 @@ class DisplayTopologyPreferenceTest { override val wallpaper : Drawable get() = systemWallpaper!! + + override fun registerTopologyListener(listener: Consumer) { + if (topologyListener != null) { + throw IllegalStateException( + "already have a listener registered: ${topologyListener}") + } + topologyListener = listener + } + + override fun unregisterTopologyListener(listener: Consumer) { + 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 { + 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 { + 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) + } }