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

Commit 5a7e9e63 authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[Media TTT] Use a listener pattern to notify about view removals.

In order to handle the swipe-to-dismiss gesture correctly, we need to
notify MediaTttSenderCoordinator about both a view timing out, and a
view being removed due to swipe. So, having the `onViewTimeout` Runnable
will no longer work.

This CL instead uses a listener interface to notify
`MediaTttSenderCoordinator` whenever the view has timed out / been
removed. A future CL will add the swipe-to-dismiss gesture. That gesture
will also trigger this new listener method, so that
MediaTttSenderCoordinator always stays up-to-date.

Bug: 262584940
Test: manual: verify media ttt chipbars with different IDs still works
(id1=triggered, then id2=almostClose. When id2 times out, id1 is
redisplayed, then also times out)
Test: atest MediaTttSenderCoordinatorTest
TemporaryViewDisplayControllerTest

Change-Id: I73c6235718f3b660ea416ca459ae777d8624a7be
parent a2d811ed
Loading
Loading
Loading
Loading
+16 −3
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import com.android.systemui.media.taptotransfer.MediaTttFlags
import com.android.systemui.media.taptotransfer.common.MediaTttLogger
import com.android.systemui.media.taptotransfer.common.MediaTttUtils
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.temporarydisplay.TemporaryViewDisplayController
import com.android.systemui.temporarydisplay.ViewPriority
import com.android.systemui.temporarydisplay.chipbar.ChipbarCoordinator
import com.android.systemui.temporarydisplay.chipbar.ChipbarEndItem
@@ -54,6 +55,7 @@ constructor(

    private var displayedState: ChipStateSender? = null
    // A map to store current chip state per id.
    // TODO(b/265455911): Log whenever we add or remove from the store.
    private var stateMap: MutableMap<String, ChipStateSender> = mutableMapOf()

    private val commandQueueCallbacks =
@@ -102,10 +104,9 @@ constructor(
        }
        uiEventLogger.logSenderStateChange(chipState)

        stateMap.put(routeInfo.id, chipState)
        if (chipState == ChipStateSender.FAR_FROM_RECEIVER) {
            // No need to store the state since it is the default state
            stateMap.remove(routeInfo.id)
            removeIdFromStore(routeInfo.id)
            // Return early if we're not displaying a chip anyway
            val currentDisplayedState = displayedState ?: return

@@ -126,7 +127,9 @@ constructor(
            displayedState = null
            chipbarCoordinator.removeView(routeInfo.id, removalReason)
        } else {
            stateMap[routeInfo.id] = chipState
            displayedState = chipState
            chipbarCoordinator.registerListener(displayListener)
            chipbarCoordinator.displayView(
                createChipbarInfo(
                    chipState,
@@ -135,7 +138,7 @@ constructor(
                    context,
                    logger,
                )
            ) { stateMap.remove(routeInfo.id) }
            )
        }
    }

@@ -225,4 +228,14 @@ constructor(
            onClickListener,
        )
    }

    private val displayListener =
        TemporaryViewDisplayController.Listener { id -> removeIdFromStore(id) }

    private fun removeIdFromStore(id: String) {
        stateMap.remove(id)
        if (stateMap.isEmpty()) {
            chipbarCoordinator.unregisterListener(displayListener)
        }
    }
}
+31 −14
Original line number Diff line number Diff line
@@ -119,15 +119,26 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
        dumpManager.registerNormalDumpable(this)
    }

    private val listeners: MutableSet<Listener> = mutableSetOf()

    /** Registers a listener. */
    fun registerListener(listener: Listener) {
        listeners.add(listener)
    }

    /** Unregisters a listener. */
    fun unregisterListener(listener: Listener) {
        listeners.remove(listener)
    }

    /**
     * Displays the view with the provided [newInfo].
     *
     * This method handles inflating and attaching the view, then delegates to [updateView] to
     * display the correct information in the view.
     * @param onViewTimeout a runnable that runs after the view timeout.
     */
    @Synchronized
    fun displayView(newInfo: T, onViewTimeout: Runnable? = null) {
    fun displayView(newInfo: T) {
        val timeout = accessibilityManager.getRecommendedTimeoutMillis(
            newInfo.timeoutMs,
            // Not all views have controls so FLAG_CONTENT_CONTROLS might be superfluous, but
@@ -146,14 +157,13 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
            logger.logViewUpdate(newInfo)
            currentDisplayInfo.info = newInfo
            currentDisplayInfo.timeExpirationMillis = timeExpirationMillis
            updateTimeout(currentDisplayInfo, timeout, onViewTimeout)
            updateTimeout(currentDisplayInfo, timeout)
            updateView(newInfo, view)
            return
        }

        val newDisplayInfo = DisplayInfo(
            info = newInfo,
            onViewTimeout = onViewTimeout,
            timeExpirationMillis = timeExpirationMillis,
            // Null values will be updated to non-null if/when this view actually gets displayed
            view = null,
@@ -196,7 +206,7 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
    private fun showNewView(newDisplayInfo: DisplayInfo, timeout: Int) {
        logger.logViewAddition(newDisplayInfo.info)
        createAndAcquireWakeLock(newDisplayInfo)
        updateTimeout(newDisplayInfo, timeout, newDisplayInfo.onViewTimeout)
        updateTimeout(newDisplayInfo, timeout)
        inflateAndUpdateView(newDisplayInfo)
    }

@@ -227,19 +237,16 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
    /**
     * Creates a runnable that will remove [displayInfo] in [timeout] ms from now.
     *
     * @param onViewTimeout an optional runnable that will be run if the view times out.
     * @return a runnable that, when run, will *cancel* the view's timeout.
     */
    private fun updateTimeout(displayInfo: DisplayInfo, timeout: Int, onViewTimeout: Runnable?) {
    private fun updateTimeout(displayInfo: DisplayInfo, timeout: Int) {
        val cancelViewTimeout = mainExecutor.executeDelayed(
            {
                removeView(displayInfo.info.id, REMOVAL_REASON_TIMEOUT)
                onViewTimeout?.run()
            },
            timeout.toLong()
        )

        displayInfo.onViewTimeout = onViewTimeout
        // Cancel old view timeout and re-set it.
        displayInfo.cancelViewTimeout?.run()
        displayInfo.cancelViewTimeout = cancelViewTimeout
@@ -317,6 +324,9 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
        // event comes in while this view is animating out, we still display the new view
        // appropriately.
        activeViews.remove(displayInfo)
        listeners.forEach {
            it.onInfoPermanentlyRemoved(id)
        }

        // No need to time the view out since it's already gone
        displayInfo.cancelViewTimeout?.run()
@@ -380,6 +390,9 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
        invalidViews.forEach {
            activeViews.remove(it)
            logger.logViewExpiration(it.info)
            listeners.forEach { listener ->
                listener.onInfoPermanentlyRemoved(it.info.id)
            }
        }
    }

@@ -436,6 +449,15 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
        onAnimationEnd.run()
    }

    /** A listener interface to be notified of various view events. */
    fun interface Listener {
        /**
         * Called whenever a [DisplayInfo] with the given [id] has been removed and will never be
         * displayed again (unless another call to [updateView] is made).
         */
        fun onInfoPermanentlyRemoved(id: String)
    }

    /** A container for all the display-related state objects. */
    inner class DisplayInfo(
        /**
@@ -460,11 +482,6 @@ abstract class TemporaryViewDisplayController<T : TemporaryViewInfo, U : Tempora
         */
        var wakeLock: WakeLock?,

        /**
         * See [displayView].
         */
        var onViewTimeout: Runnable?,

        /**
         * A runnable that, when run, will cancel this view's timeout.
         *
+182 −5
Original line number Diff line number Diff line
@@ -45,13 +45,17 @@ import com.android.systemui.plugins.FalsingManager
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.temporarydisplay.TemporaryViewDisplayController
import com.android.systemui.temporarydisplay.chipbar.ChipbarCoordinator
import com.android.systemui.temporarydisplay.chipbar.ChipbarInfo
import com.android.systemui.temporarydisplay.chipbar.ChipbarLogger
import com.android.systemui.temporarydisplay.chipbar.FakeChipbarCoordinator
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.time.FakeSystemClock
import com.android.systemui.util.view.ViewUtil
import com.android.systemui.util.wakelock.WakeLockFake
@@ -61,6 +65,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.Mock
import org.mockito.Mockito.atLeast
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.verify
@@ -161,9 +166,7 @@ class MediaTttSenderCoordinatorTest : SysuiTestCase() {
            )
        underTest.start()

        val callbackCaptor = ArgumentCaptor.forClass(CommandQueue.Callbacks::class.java)
        verify(commandQueue).addCallback(callbackCaptor.capture())
        commandQueueCallback = callbackCaptor.value!!
        setCommandQueueCallback()
    }

    @Test
@@ -920,6 +923,172 @@ class MediaTttSenderCoordinatorTest : SysuiTestCase() {
        verify(windowManager).removeView(any())
    }

    @Test
    fun newState_viewListenerRegistered() {
        val mockChipbarCoordinator = mock<ChipbarCoordinator>()
        underTest =
            MediaTttSenderCoordinator(
                mockChipbarCoordinator,
                commandQueue,
                context,
                logger,
                mediaTttFlags,
                uiEventLogger,
            )
        underTest.start()
        // Re-set the command queue callback since we've created a new [MediaTttSenderCoordinator]
        // with a new callback.
        setCommandQueueCallback()

        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_ALMOST_CLOSE_TO_END_CAST,
            routeInfo,
            null,
        )

        verify(mockChipbarCoordinator).registerListener(any())
    }

    @Test
    fun onInfoPermanentlyRemoved_viewListenerUnregistered() {
        val mockChipbarCoordinator = mock<ChipbarCoordinator>()
        underTest =
            MediaTttSenderCoordinator(
                mockChipbarCoordinator,
                commandQueue,
                context,
                logger,
                mediaTttFlags,
                uiEventLogger,
            )
        underTest.start()
        setCommandQueueCallback()

        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_ALMOST_CLOSE_TO_END_CAST,
            routeInfo,
            null,
        )

        val listenerCaptor = argumentCaptor<TemporaryViewDisplayController.Listener>()
        verify(mockChipbarCoordinator).registerListener(capture(listenerCaptor))

        // WHEN the listener is notified that the view has been removed
        listenerCaptor.value.onInfoPermanentlyRemoved(DEFAULT_ID)

        // THEN the media coordinator unregisters the listener
        verify(mockChipbarCoordinator).unregisterListener(listenerCaptor.value)
    }

    @Test
    fun onInfoPermanentlyRemoved_wrongId_viewListenerNotUnregistered() {
        val mockChipbarCoordinator = mock<ChipbarCoordinator>()
        underTest =
            MediaTttSenderCoordinator(
                mockChipbarCoordinator,
                commandQueue,
                context,
                logger,
                mediaTttFlags,
                uiEventLogger,
            )
        underTest.start()
        setCommandQueueCallback()

        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_ALMOST_CLOSE_TO_END_CAST,
            routeInfo,
            null,
        )

        val listenerCaptor = argumentCaptor<TemporaryViewDisplayController.Listener>()
        verify(mockChipbarCoordinator).registerListener(capture(listenerCaptor))

        // WHEN the listener is notified that a different view has been removed
        listenerCaptor.value.onInfoPermanentlyRemoved("differentViewId")

        // THEN the media coordinator doesn't unregister the listener
        verify(mockChipbarCoordinator, never()).unregisterListener(listenerCaptor.value)
    }

    @Test
    fun farFromReceiverState_viewListenerUnregistered() {
        val mockChipbarCoordinator = mock<ChipbarCoordinator>()
        underTest =
            MediaTttSenderCoordinator(
                mockChipbarCoordinator,
                commandQueue,
                context,
                logger,
                mediaTttFlags,
                uiEventLogger,
            )
        underTest.start()
        setCommandQueueCallback()

        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_ALMOST_CLOSE_TO_END_CAST,
            routeInfo,
            null,
        )

        val listenerCaptor = argumentCaptor<TemporaryViewDisplayController.Listener>()
        verify(mockChipbarCoordinator).registerListener(capture(listenerCaptor))

        // WHEN we go to the FAR_FROM_RECEIVER state
        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_FAR_FROM_RECEIVER,
            routeInfo,
            null
        )

        // THEN the media coordinator unregisters the listener
        verify(mockChipbarCoordinator).unregisterListener(listenerCaptor.value)
    }

    @Test
    fun statesWithDifferentIds_onInfoPermanentlyRemovedForOneId_viewListenerNotUnregistered() {
        val mockChipbarCoordinator = mock<ChipbarCoordinator>()
        underTest =
            MediaTttSenderCoordinator(
                mockChipbarCoordinator,
                commandQueue,
                context,
                logger,
                mediaTttFlags,
                uiEventLogger,
            )
        underTest.start()
        setCommandQueueCallback()

        // WHEN there are two different media transfers with different IDs
        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_ALMOST_CLOSE_TO_END_CAST,
            MediaRoute2Info.Builder("route1", OTHER_DEVICE_NAME)
                .addFeature("feature")
                .setClientPackageName(PACKAGE_NAME)
                .build(),
            null,
        )
        commandQueueCallback.updateMediaTapToTransferSenderDisplay(
            StatusBarManager.MEDIA_TRANSFER_SENDER_STATE_ALMOST_CLOSE_TO_END_CAST,
            MediaRoute2Info.Builder("route2", OTHER_DEVICE_NAME)
                .addFeature("feature")
                .setClientPackageName(PACKAGE_NAME)
                .build(),
            null,
        )

        val listenerCaptor = argumentCaptor<TemporaryViewDisplayController.Listener>()
        verify(mockChipbarCoordinator, atLeast(1)).registerListener(capture(listenerCaptor))

        // THEN one of them is removed
        listenerCaptor.value.onInfoPermanentlyRemoved("route1")

        // THEN the media coordinator doesn't unregister the listener (since route2 is still active)
        verify(mockChipbarCoordinator, never()).unregisterListener(listenerCaptor.value)
    }

    private fun getChipbarView(): ViewGroup {
        val viewCaptor = ArgumentCaptor.forClass(View::class.java)
        verify(windowManager).addView(viewCaptor.capture(), any())
@@ -960,8 +1129,16 @@ class MediaTttSenderCoordinatorTest : SysuiTestCase() {
            null
        )
    }

    private fun setCommandQueueCallback() {
        val callbackCaptor = argumentCaptor<CommandQueue.Callbacks>()
        verify(commandQueue).addCallback(capture(callbackCaptor))
        commandQueueCallback = callbackCaptor.value
        reset(commandQueue)
    }
}

private const val DEFAULT_ID = "defaultId"
private const val APP_NAME = "Fake app name"
private const val OTHER_DEVICE_NAME = "My Tablet"
private const val BLANK_DEVICE_NAME = " "
@@ -969,13 +1146,13 @@ private const val PACKAGE_NAME = "com.android.systemui"
private const val TIMEOUT = 10000

private val routeInfo =
    MediaRoute2Info.Builder("id", OTHER_DEVICE_NAME)
    MediaRoute2Info.Builder(DEFAULT_ID, OTHER_DEVICE_NAME)
        .addFeature("feature")
        .setClientPackageName(PACKAGE_NAME)
        .build()

private val routeInfoWithBlankDeviceName =
    MediaRoute2Info.Builder("id", BLANK_DEVICE_NAME)
    MediaRoute2Info.Builder(DEFAULT_ID, BLANK_DEVICE_NAME)
        .addFeature("feature")
        .setClientPackageName(PACKAGE_NAME)
        .build()
+171 −27

File changed.

Preview size limit exceeded, changes collapsed.