Loading src/com/android/settings/connecteddevice/display/DisplayTopology.kt +59 −10 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading Loading @@ -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() { Loading @@ -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) } } /** Loading Loading @@ -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. Loading @@ -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) { Loading @@ -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. Loading @@ -348,9 +394,12 @@ class DisplayTopologyPreference(context : Context) } } } mPaneContent.removeViews(blocksPos.size, recycleableBlocks.size) mPaneContent.removeViews(newBounds.size, recycleableBlocks.size) mTopologyInfo = TopologyInfo(topology, scaling, newBounds) mTopologyInfo = TopologyInfo(topology, scaling, blocksPos) // Cancel the drag if one is in progress. mDrag = null } private fun onBlockTouchDown( Loading tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt +118 −11 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -56,6 +61,7 @@ class DisplayTopologyPreferenceTest { class TestInjector(context : Context) : DisplayTopologyPreference.Injector(context) { var topology: DisplayTopology? = null var systemWallpaper: Drawable? = null var topologyListener: Consumer<DisplayTopology>? = null override var displayTopology : DisplayTopology? get() = topology Loading @@ -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 Loading @@ -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) Loading @@ -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) Loading Loading @@ -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) } } Loading
src/com/android/settings/connecteddevice/display/DisplayTopology.kt +59 −10 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading Loading @@ -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() { Loading @@ -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) } } /** Loading Loading @@ -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. Loading @@ -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) { Loading @@ -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. Loading @@ -348,9 +394,12 @@ class DisplayTopologyPreference(context : Context) } } } mPaneContent.removeViews(blocksPos.size, recycleableBlocks.size) mPaneContent.removeViews(newBounds.size, recycleableBlocks.size) mTopologyInfo = TopologyInfo(topology, scaling, newBounds) mTopologyInfo = TopologyInfo(topology, scaling, blocksPos) // Cancel the drag if one is in progress. mDrag = null } private fun onBlockTouchDown( Loading
tests/robotests/src/com/android/settings/connecteddevice/display/DisplayTopologyPreferenceTest.kt +118 −11 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -56,6 +61,7 @@ class DisplayTopologyPreferenceTest { class TestInjector(context : Context) : DisplayTopologyPreference.Injector(context) { var topology: DisplayTopology? = null var systemWallpaper: Drawable? = null var topologyListener: Consumer<DisplayTopology>? = null override var displayTopology : DisplayTopology? get() = topology Loading @@ -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 Loading @@ -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) Loading @@ -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) Loading Loading @@ -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) } }