From 5a7e9e634979a50ec38cf6994c9ae420ffdbb1ac Mon Sep 17 00:00:00 2001 From: Caitlin Shkuratov Date: Fri, 13 Jan 2023 20:07:31 +0000 Subject: [PATCH] [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 --- .../sender/MediaTttSenderCoordinator.kt | 19 +- .../TemporaryViewDisplayController.kt | 45 ++-- .../sender/MediaTttSenderCoordinatorTest.kt | 187 ++++++++++++++++- .../TemporaryViewDisplayControllerTest.kt | 198 +++++++++++++++--- 4 files changed, 400 insertions(+), 49 deletions(-) diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinator.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinator.kt index 935f38de2e4f1..be93c54b498f1 100644 --- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinator.kt +++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinator.kt @@ -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 = 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) + } + } } diff --git a/packages/SystemUI/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayController.kt b/packages/SystemUI/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayController.kt index ad48e218687d1..cf722ce8ecfab 100644 --- a/packages/SystemUI/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayController.kt +++ b/packages/SystemUI/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayController.kt @@ -119,15 +119,26 @@ abstract class TemporaryViewDisplayController = 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 + listener.onInfoPermanentlyRemoved(it.info.id) + } } } @@ -436,6 +449,15 @@ abstract class TemporaryViewDisplayController() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() diff --git a/packages/SystemUI/tests/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayControllerTest.kt index 99e20124f5974..45f7df3c3f5ba 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/temporarydisplay/TemporaryViewDisplayControllerTest.kt @@ -159,7 +159,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { underTest.displayView(getState()) assertThat(fakeWakeLock.isHeld).isTrue() - underTest.removeView("id", "test reason") + underTest.removeView(DEFAULT_ID, "test reason") assertThat(fakeWakeLock.isHeld).isFalse() } @@ -175,6 +175,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { @Test fun displayView_twiceWithDifferentIds_oldViewRemovedNewViewAdded() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "name", @@ -199,10 +201,15 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { assertThat(windowParamsCaptor.allValues[0].title).isEqualTo("First Fake Window Title") assertThat(windowParamsCaptor.allValues[1].title).isEqualTo("Second Fake Window Title") verify(windowManager).removeView(viewCaptor.allValues[0]) + // Since the controller is still storing the older view in case it'll get re-displayed + // later, the listener shouldn't be notified + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test fun displayView_viewDoesNotDisappearsBeforeTimeout() { + val listener = registerListener() + val state = getState() underTest.displayView(state) reset(windowManager) @@ -210,10 +217,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { fakeClock.advanceTime(TIMEOUT_MS - 1) verify(windowManager, never()).removeView(any()) + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test fun displayView_viewDisappearsAfterTimeout() { + val listener = registerListener() + val state = getState() underTest.displayView(state) reset(windowManager) @@ -221,10 +231,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { fakeClock.advanceTime(TIMEOUT_MS + 1) verify(windowManager).removeView(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly(DEFAULT_ID) } @Test fun displayView_calledAgainBeforeTimeout_timeoutReset() { + val listener = registerListener() + // First, display the view val state = getState() underTest.displayView(state) @@ -239,10 +252,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { // Verify we didn't hide the view verify(windowManager, never()).removeView(any()) + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test fun displayView_calledAgainBeforeTimeout_eventuallyTimesOut() { + val listener = registerListener() + // First, display the view val state = getState() underTest.displayView(state) @@ -255,6 +271,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { fakeClock.advanceTime(TIMEOUT_MS + 1) verify(windowManager).removeView(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly(DEFAULT_ID) } @Test @@ -270,26 +287,10 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { assertThat(underTest.mostRecentViewInfo?.name).isEqualTo("Second name") } - @Test - fun viewUpdatedWithNewOnViewTimeoutRunnable_newRunnableUsed() { - var runnable1Run = false - underTest.displayView(ViewInfo(name = "name", id = "id1", windowTitle = "1")) { - runnable1Run = true - } - - var runnable2Run = false - underTest.displayView(ViewInfo(name = "name", id = "id1", windowTitle = "1")) { - runnable2Run = true - } - - fakeClock.advanceTime(TIMEOUT_MS + 1) - - assertThat(runnable1Run).isFalse() - assertThat(runnable2Run).isTrue() - } - @Test fun multipleViewsWithDifferentIds_moreRecentReplacesOlder() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "name", @@ -315,10 +316,16 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { assertThat(windowParamsCaptor.allValues[1].title).isEqualTo("Second Fake Window Title") verify(windowManager).removeView(viewCaptor.allValues[0]) verify(configurationController, never()).removeCallback(any()) + + // Since the controller is still storing the older view in case it'll get re-displayed + // later, the listener shouldn't be notified + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test - fun multipleViewsWithDifferentIds_recentActiveViewIsDisplayed() { + fun multipleViewsWithDifferentIds_newViewRemoved_previousViewIsDisplayed() { + val listener = registerListener() + underTest.displayView(ViewInfo("First name", id = "id1")) verify(windowManager).addView(any(), any()) @@ -329,24 +336,35 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager).removeView(any()) verify(windowManager).addView(any(), any()) reset(windowManager) + assertThat(listener.permanentlyRemovedIds).isEmpty() + // WHEN the current view is removed underTest.removeView("id2", "test reason") + // THEN it's correctly removed verify(windowManager).removeView(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly("id2") + + // And the previous view is correctly added verify(windowManager).addView(any(), any()) assertThat(underTest.mostRecentViewInfo?.id).isEqualTo("id1") assertThat(underTest.mostRecentViewInfo?.name).isEqualTo("First name") + // WHEN the previous view times out reset(windowManager) fakeClock.advanceTime(TIMEOUT_MS + 1) + // THEN it is also removed verify(windowManager).removeView(any()) assertThat(underTest.activeViews.size).isEqualTo(0) verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).isEqualTo(listOf("id2", "id1")) } @Test fun multipleViewsWithDifferentIds_oldViewRemoved_recentViewIsDisplayed() { + val listener = registerListener() + underTest.displayView(ViewInfo("First name", id = "id1")) verify(windowManager).addView(any(), any()) @@ -361,7 +379,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { // WHEN an old view is removed underTest.removeView("id1", "test reason") - // THEN we don't update anything + // THEN we don't update anything except the listener + assertThat(listener.permanentlyRemovedIds).containsExactly("id1") verify(windowManager, never()).removeView(any()) assertThat(underTest.mostRecentViewInfo?.id).isEqualTo("id2") assertThat(underTest.mostRecentViewInfo?.name).isEqualTo("Second name") @@ -372,10 +391,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager).removeView(any()) assertThat(underTest.activeViews.size).isEqualTo(0) verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).isEqualTo(listOf("id1", "id2")) } @Test fun multipleViewsWithDifferentIds_threeDifferentViews_recentActiveViewIsDisplayed() { + val listener = registerListener() + underTest.displayView(ViewInfo("First name", id = "id1")) underTest.displayView(ViewInfo("Second name", id = "id2")) underTest.displayView(ViewInfo("Third name", id = "id3")) @@ -387,6 +409,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { underTest.removeView("id3", "test reason") verify(windowManager).removeView(any()) + assertThat(listener.permanentlyRemovedIds).isEqualTo(listOf("id3")) assertThat(underTest.mostRecentViewInfo?.id).isEqualTo("id2") assertThat(underTest.mostRecentViewInfo?.name).isEqualTo("Second name") verify(configurationController, never()).removeCallback(any()) @@ -395,6 +418,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { underTest.removeView("id2", "test reason") verify(windowManager).removeView(any()) + assertThat(listener.permanentlyRemovedIds).isEqualTo(listOf("id3", "id2")) assertThat(underTest.mostRecentViewInfo?.id).isEqualTo("id1") assertThat(underTest.mostRecentViewInfo?.name).isEqualTo("First name") verify(configurationController, never()).removeCallback(any()) @@ -403,6 +427,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { fakeClock.advanceTime(TIMEOUT_MS + 1) verify(windowManager).removeView(any()) + assertThat(listener.permanentlyRemovedIds).isEqualTo(listOf("id3", "id2", "id1")) assertThat(underTest.activeViews.size).isEqualTo(0) verify(configurationController).removeCallback(any()) } @@ -438,6 +463,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { @Test fun multipleViews_mostRecentViewRemoved_otherViewsTimedOutAndNotDisplayed() { + val listener = registerListener() + underTest.displayView(ViewInfo("First name", id = "id1", timeoutMs = 4000)) fakeClock.advanceTime(1000) underTest.displayView(ViewInfo("Second name", id = "id2", timeoutMs = 4000)) @@ -451,10 +478,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager, never()).addView(any(), any()) assertThat(underTest.activeViews.size).isEqualTo(0) verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly("id1", "id2", "id3") } @Test fun multipleViews_mostRecentViewRemoved_viewWithShortTimeLeftNotDisplayed() { + val listener = registerListener() + underTest.displayView(ViewInfo("First name", id = "id1", timeoutMs = 4000)) fakeClock.advanceTime(1000) underTest.displayView(ViewInfo("Second name", id = "id2", timeoutMs = 2500)) @@ -467,10 +497,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager, never()).addView(any(), any()) assertThat(underTest.activeViews.size).isEqualTo(0) verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly("id1", "id2") } @Test fun lowerThenHigherPriority_higherReplacesLower() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "normal", @@ -499,10 +532,15 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager).addView(capture(viewCaptor), capture(windowParamsCaptor)) assertThat(windowParamsCaptor.value.title).isEqualTo("Critical Window Title") verify(configurationController, never()).removeCallback(any()) + // Since the controller is still storing the older view in case it'll get re-displayed + // later, the listener shouldn't be notified + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test fun lowerThenHigherPriority_lowerPriorityRedisplayed() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "normal", @@ -537,6 +575,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { // THEN the normal view is re-displayed verify(windowManager).removeView(viewCaptor.allValues[1]) + assertThat(listener.permanentlyRemovedIds).containsExactly("critical") verify(windowManager).addView(any(), capture(windowParamsCaptor)) assertThat(windowParamsCaptor.value.title).isEqualTo("Normal Window Title") verify(configurationController, never()).removeCallback(any()) @@ -544,6 +583,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { @Test fun lowerThenHigherPriority_lowerPriorityNotRedisplayedBecauseTimedOut() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "normal", @@ -573,6 +614,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager, never()).addView(any(), any()) assertThat(underTest.activeViews).isEmpty() verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly("critical", "normal") } @Test @@ -609,6 +651,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { @Test fun higherThenLowerPriority_lowerEventuallyDisplayed() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "critical", @@ -644,6 +688,7 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { // THEN the second normal view is displayed verify(windowManager).removeView(viewCaptor.value) + assertThat(listener.permanentlyRemovedIds).containsExactly("critical") verify(windowManager).addView(capture(viewCaptor), capture(windowParamsCaptor)) assertThat(windowParamsCaptor.value.title).isEqualTo("Normal Window Title") assertThat(underTest.activeViews.size).isEqualTo(1) @@ -652,6 +697,8 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { @Test fun higherThenLowerPriority_lowerNotDisplayedBecauseTimedOut() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "critical", @@ -691,10 +738,13 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { verify(windowManager, never()).addView(any(), any()) assertThat(underTest.activeViews).isEmpty() verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly("critical", "normal") } @Test fun criticalThenNewCritical_newCriticalDisplayed() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "critical 1", @@ -724,10 +774,15 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { assertThat(windowParamsCaptor.value.title).isEqualTo("Critical Window Title 2") assertThat(underTest.activeViews.size).isEqualTo(2) verify(configurationController, never()).removeCallback(any()) + // Since the controller is still storing the older view in case it'll get re-displayed + // later, the listener shouldn't be notified + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test fun normalThenNewNormal_newNormalDisplayed() { + val listener = registerListener() + underTest.displayView( ViewInfo( name = "normal 1", @@ -757,6 +812,9 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { assertThat(windowParamsCaptor.value.title).isEqualTo("Normal Window Title 2") assertThat(underTest.activeViews.size).isEqualTo(2) verify(configurationController, never()).removeCallback(any()) + // Since the controller is still storing the older view in case it'll get re-displayed + // later, the listener shouldn't be notified + assertThat(listener.permanentlyRemovedIds).isEmpty() } @Test @@ -957,25 +1015,103 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { } @Test - fun removeView_viewRemovedAndRemovalLogged() { + fun removeView_viewRemovedAndRemovalLoggedAndListenerNotified() { + val listener = registerListener() + // First, add the view underTest.displayView(getState()) // Then, remove it val reason = "test reason" - val deviceId = "id" - underTest.removeView(deviceId, reason) + underTest.removeView(DEFAULT_ID, reason) verify(windowManager).removeView(any()) - verify(logger).logViewRemoval(deviceId, reason) + verify(logger).logViewRemoval(DEFAULT_ID, reason) verify(configurationController).removeCallback(any()) + assertThat(listener.permanentlyRemovedIds).containsExactly(DEFAULT_ID) } @Test - fun removeView_noAdd_viewNotRemoved() { + fun removeView_noAdd_viewNotRemovedAndListenerNotNotified() { + val listener = registerListener() + underTest.removeView("id", "reason") verify(windowManager, never()).removeView(any()) + assertThat(listener.permanentlyRemovedIds).isEmpty() + } + + @Test + fun listenerRegistered_notifiedOnRemoval() { + val listener = registerListener() + underTest.displayView(getState()) + + underTest.removeView(DEFAULT_ID, "reason") + + assertThat(listener.permanentlyRemovedIds).containsExactly(DEFAULT_ID) + } + + @Test + fun listenerRegistered_notifiedOnTimedOutEvenWhenNotDisplayed() { + val listener = registerListener() + underTest.displayView( + ViewInfo( + id = "id1", + name = "name1", + timeoutMs = 3000, + ), + ) + + // Display a second view + underTest.displayView( + ViewInfo( + id = "id2", + name = "name2", + timeoutMs = 2500, + ), + ) + + // WHEN the second view times out + fakeClock.advanceTime(2501) + + // THEN the listener is notified of both IDs, since id2 timed out and id1 doesn't have + // enough time left to be redisplayed + assertThat(listener.permanentlyRemovedIds).containsExactly("id1", "id2") + } + + @Test + fun multipleListeners_allNotified() { + val listener1 = registerListener() + val listener2 = registerListener() + val listener3 = registerListener() + + underTest.displayView(getState()) + + underTest.removeView(DEFAULT_ID, "reason") + + assertThat(listener1.permanentlyRemovedIds).containsExactly(DEFAULT_ID) + assertThat(listener2.permanentlyRemovedIds).containsExactly(DEFAULT_ID) + assertThat(listener3.permanentlyRemovedIds).containsExactly(DEFAULT_ID) + } + + @Test + fun sameListenerRegisteredMultipleTimes_onlyNotifiedOnce() { + val listener = registerListener() + underTest.registerListener(listener) + underTest.registerListener(listener) + + underTest.displayView(getState()) + + underTest.removeView(DEFAULT_ID, "reason") + + assertThat(listener.permanentlyRemovedIds).hasSize(1) + assertThat(listener.permanentlyRemovedIds).containsExactly(DEFAULT_ID) + } + + private fun registerListener(): Listener { + return Listener().also { + underTest.registerListener(it) + } } private fun getState(name: String = "name") = ViewInfo(name) @@ -1030,9 +1166,17 @@ class TemporaryViewDisplayControllerTest : SysuiTestCase() { override val windowTitle: String = "Window Title", override val wakeReason: String = "WAKE_REASON", override val timeoutMs: Int = TIMEOUT_MS.toInt(), - override val id: String = "id", + override val id: String = DEFAULT_ID, override val priority: ViewPriority = ViewPriority.NORMAL, ) : TemporaryViewInfo() + + inner class Listener : TemporaryViewDisplayController.Listener { + val permanentlyRemovedIds = mutableListOf() + override fun onInfoPermanentlyRemoved(id: String) { + permanentlyRemovedIds.add(id) + } + } } private const val TIMEOUT_MS = 10000L +private const val DEFAULT_ID = "defaultId"