[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
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 935f38d..be93c54 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.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 @@
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 @@
}
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 @@
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 @@
context,
logger,
)
- ) { stateMap.remove(routeInfo.id) }
+ )
}
}
@@ -225,4 +228,14 @@
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 ad48e21..cf722ce 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 @@
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 @@
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 @@
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 @@
/**
* 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 @@
// 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 @@
invalidViews.forEach {
activeViews.remove(it)
logger.logViewExpiration(it.info)
+ listeners.forEach { listener ->
+ listener.onInfoPermanentlyRemoved(it.info.id)
+ }
}
}
@@ -436,6 +449,15 @@
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(
/**
@@ -461,11 +483,6 @@
var wakeLock: WakeLock?,
/**
- * See [displayView].
- */
- var onViewTimeout: Runnable?,
-
- /**
* A runnable that, when run, will cancel this view's timeout.
*
* Null if this info isn't currently being displayed.
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinatorTest.kt
index f5b3959..1cdce99 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinatorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/taptotransfer/sender/MediaTttSenderCoordinatorTest.kt
@@ -45,13 +45,17 @@
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.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 @@
)
underTest.start()
- val callbackCaptor = ArgumentCaptor.forClass(CommandQueue.Callbacks::class.java)
- verify(commandQueue).addCallback(callbackCaptor.capture())
- commandQueueCallback = callbackCaptor.value!!
+ setCommandQueueCallback()
}
@Test
@@ -920,6 +923,172 @@
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 @@
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 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 99e2012..45f7df3 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 @@
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 @@
@Test
fun displayView_twiceWithDifferentIds_oldViewRemovedNewViewAdded() {
+ val listener = registerListener()
+
underTest.displayView(
ViewInfo(
name = "name",
@@ -199,10 +201,15 @@
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 @@
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 @@
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 @@
// 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 @@
fakeClock.advanceTime(TIMEOUT_MS + 1)
verify(windowManager).removeView(any())
+ assertThat(listener.permanentlyRemovedIds).containsExactly(DEFAULT_ID)
}
@Test
@@ -271,25 +288,9 @@
}
@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 @@
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 @@
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 @@
// 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 @@
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 @@
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 @@
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 @@
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 @@
@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 @@
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 @@
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 @@
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 @@
// 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 @@
@Test
fun lowerThenHigherPriority_lowerPriorityNotRedisplayedBecauseTimedOut() {
+ val listener = registerListener()
+
underTest.displayView(
ViewInfo(
name = "normal",
@@ -573,6 +614,7 @@
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 @@
@Test
fun higherThenLowerPriority_lowerEventuallyDisplayed() {
+ val listener = registerListener()
+
underTest.displayView(
ViewInfo(
name = "critical",
@@ -644,6 +688,7 @@
// 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 @@
@Test
fun higherThenLowerPriority_lowerNotDisplayedBecauseTimedOut() {
+ val listener = registerListener()
+
underTest.displayView(
ViewInfo(
name = "critical",
@@ -691,10 +738,13 @@
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 @@
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 @@
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 @@
}
@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 @@
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<String>()
+ override fun onInfoPermanentlyRemoved(id: String) {
+ permanentlyRemovedIds.add(id)
+ }
+ }
}
private const val TIMEOUT_MS = 10000L
+private const val DEFAULT_ID = "defaultId"