Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 74a61215 authored by Matthew DeVore's avatar Matthew DeVore
Browse files

Use topology listener to detect changes

If some other app or the system changes the topology, we detect it and
refresh the topology pane. If the listener reports a topology that we
just applied, do not actually refresh the pane.

Flag: com.android.settings.flags.display_topology_pane_in_display_list
Bug: b/352650922
Test: atest DisplayTopologyPreferenceTest.kt
Test: with added logs, verify that a detach and re-attach w/o new topology does not cause a full refresh
Change-Id: Iecf50d563b430755c93bee5a1ff54f3f3d6eb3da
parent 54bb776d
Loading
Loading
Loading
Loading
+59 −10
Original line number Diff line number Diff line
@@ -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, newBounds)

        mTopologyInfo = TopologyInfo(topology, scaling, blocksPos)
        // Cancel the drag if one is in progress.
        mDrag = null
    }

    private fun onBlockTouchDown(
+118 −11
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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)
    }
}