Loading packages/SystemUI/multivalentTests/src/com/android/systemui/inputdevice/tutorial/domain/interactor/TutorialNotificationCoordinatorTest.kt +62 −4 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package com.android.systemui.inputdevice.tutorial.domain.interactor import android.app.Notification import android.app.NotificationManager import android.service.notification.StatusBarNotification import androidx.annotation.StringRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest Loading @@ -28,6 +29,7 @@ import com.android.systemui.inputdevice.tutorial.ui.TutorialNotificationCoordina import com.android.systemui.keyboard.data.repository.FakeKeyboardRepository import com.android.systemui.kosmos.backgroundScope import com.android.systemui.kosmos.testScope import com.android.systemui.kosmos.useUnconfinedTestDispatcher import com.android.systemui.res.R import com.android.systemui.settings.userTracker import com.android.systemui.statusbar.commandline.commandRegistry Loading @@ -35,6 +37,7 @@ import com.android.systemui.testKosmos import com.android.systemui.touchpad.data.repository.FakeTouchpadRepository import com.google.common.truth.Truth.assertThat import kotlin.time.Duration.Companion.hours import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before Loading @@ -44,23 +47,29 @@ import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.times import org.mockito.junit.MockitoJUnit import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.firstValue import org.mockito.kotlin.never import org.mockito.kotlin.secondValue import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class TutorialNotificationCoordinatorTest : SysuiTestCase() { private lateinit var underTest: TutorialNotificationCoordinator private val kosmos = testKosmos() private val kosmos = testKosmos().useUnconfinedTestDispatcher() private val testScope = kosmos.testScope private val keyboardRepository = FakeKeyboardRepository() private val touchpadRepository = FakeTouchpadRepository() private lateinit var repository: TutorialSchedulerRepository @Mock private lateinit var notificationManager: NotificationManager @Mock private lateinit var notification: StatusBarNotification @Captor private lateinit var notificationCaptor: ArgumentCaptor<Notification> @get:Rule val rule = MockitoJUnit.rule() Loading Loading @@ -107,6 +116,7 @@ class TutorialNotificationCoordinatorTest : SysuiTestCase() { fun showTouchpadNotification() = runTestAndClear { touchpadRepository.setIsAnyTouchpadConnected(true) testScope.advanceTimeBy(LAUNCH_DELAY) mockExistingNotification() verifyNotification( R.string.launch_touchpad_tutorial_notification_title, R.string.launch_touchpad_tutorial_notification_content, Loading @@ -131,6 +141,45 @@ class TutorialNotificationCoordinatorTest : SysuiTestCase() { .notifyAsUser(eq(TAG), eq(NOTIFICATION_ID), any(), any()) } @Test fun showKeyboardNotificationThenDisconnectKeyboard() = runTestAndClear { keyboardRepository.setIsAnyKeyboardConnected(true) testScope.advanceTimeBy(LAUNCH_DELAY) verifyNotification( R.string.launch_keyboard_tutorial_notification_title, R.string.launch_keyboard_tutorial_notification_content, ) mockExistingNotification() // After the keyboard is disconnected, i.e. there is nothing connected, the notification // should be cancelled keyboardRepository.setIsAnyKeyboardConnected(false) verify(notificationManager).cancelAsUser(eq(TAG), eq(NOTIFICATION_ID), any()) } @Test fun showKeyboardTouchpadNotificationThenDisconnectKeyboard() = runTestAndClear { keyboardRepository.setIsAnyKeyboardConnected(true) touchpadRepository.setIsAnyTouchpadConnected(true) testScope.advanceTimeBy(LAUNCH_DELAY) mockExistingNotification() keyboardRepository.setIsAnyKeyboardConnected(false) verify(notificationManager, times(2)) .notifyAsUser(eq(TAG), eq(NOTIFICATION_ID), notificationCaptor.capture(), any()) // Connect both device and the first notification is for both notificationCaptor.firstValue.verify( R.string.launch_keyboard_touchpad_tutorial_notification_title, R.string.launch_keyboard_touchpad_tutorial_notification_content, ) // After the keyboard is disconnected, i.e. with only the touchpad left, the notification // should be update to the one for only touchpad notificationCaptor.secondValue.verify( R.string.launch_touchpad_tutorial_notification_title, R.string.launch_touchpad_tutorial_notification_content, ) } private fun runTestAndClear(block: suspend () -> Unit) = testScope.runTest { try { Loading @@ -140,12 +189,21 @@ class TutorialNotificationCoordinatorTest : SysuiTestCase() { } } // Assume that there's an existing notification when the updater checks activeNotifications private fun mockExistingNotification() { whenever(notification.id).thenReturn(NOTIFICATION_ID) whenever(notificationManager.activeNotifications).thenReturn(arrayOf(notification)) } private fun verifyNotification(@StringRes titleResId: Int, @StringRes contentResId: Int) { verify(notificationManager) .notifyAsUser(eq(TAG), eq(NOTIFICATION_ID), notificationCaptor.capture(), any()) val notification = notificationCaptor.value val actualTitle = notification.getString(Notification.EXTRA_TITLE) val actualContent = notification.getString(Notification.EXTRA_TEXT) notificationCaptor.value.verify(titleResId, contentResId) } private fun Notification.verify(@StringRes titleResId: Int, @StringRes contentResId: Int) { val actualTitle = getString(Notification.EXTRA_TITLE) val actualContent = getString(Notification.EXTRA_TEXT) assertThat(actualTitle).isEqualTo(context.getString(titleResId)) assertThat(actualContent).isEqualTo(context.getString(contentResId)) } Loading packages/SystemUI/src/com/android/systemui/inputdevice/tutorial/domain/interactor/TutorialSchedulerInteractor.kt +22 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow Loading Loading @@ -99,6 +101,25 @@ constructor( waitForDeviceConnection(deviceType) } // This flow is used by the notification updater once an initial notification is launched. It // listens to the device connection changes for both keyboard and touchpad. When either of the // device is disconnected, resolve the tutorial type base on the latest connection state. // Dropping the initial state because it's the existing notification. Filtering out BOTH because // we only care about disconnections. val tutorialTypeUpdates: Flow<TutorialType> = keyboardRepository.isAnyKeyboardConnected .combine(touchpadRepository.isAnyTouchpadConnected, ::Pair) .map { (keyboardConnected, touchpadConnected) -> when { keyboardConnected && touchpadConnected -> TutorialType.BOTH keyboardConnected -> TutorialType.KEYBOARD touchpadConnected -> TutorialType.TOUCHPAD else -> TutorialType.NONE } } .drop(1) .filter { it != TutorialType.BOTH } private suspend fun waitForDeviceConnection(deviceType: DeviceType) = isAnyDeviceConnected[deviceType]!!.filter { it }.first() Loading Loading @@ -172,6 +193,7 @@ constructor( pw.println( " launch time = ${repo.getScheduledTutorialLaunchTime(TOUCHPAD)}" ) pw.println("Delay time = ${LAUNCH_DELAY.seconds} sec") } "notify" -> { if (args.size != 2) help(pw) Loading packages/SystemUI/src/com/android/systemui/inputdevice/tutorial/ui/TutorialNotificationCoordinator.kt +28 −1 Original line number Diff line number Diff line Loading @@ -42,6 +42,9 @@ import com.android.systemui.res.R import com.android.systemui.settings.UserTracker import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.merge /** When the scheduler is due, show a notification to launch tutorial */ Loading @@ -55,19 +58,43 @@ constructor( private val notificationManager: NotificationManager, private val userTracker: UserTracker, ) { private var updaterJob: Job? = null fun start() { backgroundScope.launch { merge( tutorialSchedulerInteractor.tutorials, tutorialSchedulerInteractor.commandTutorials, ) .collect { showNotification(it) } .filter { it != TutorialType.NONE } .collectLatest { showNotification(it) updaterJob?.cancel() updaterJob = backgroundScope.launch { updateWhenDeviceDisconnects() } } } } private suspend fun updateWhenDeviceDisconnects() { // Only update the notification when there is an active one (i.e. if the notification has // been dismissed by the user, or if the tutorial has been launched, there's no need to // update) tutorialSchedulerInteractor.tutorialTypeUpdates .filter { hasNotification() } .collect { if (it == TutorialType.NONE) notificationManager.cancelAsUser(TAG, NOTIFICATION_ID, userTracker.userHandle) else showNotification(it) } } private fun hasNotification() = notificationManager.activeNotifications.any { it.id == NOTIFICATION_ID } // By sharing the same tag and id, we update the content of existing notification instead of // creating multiple notifications private fun showNotification(tutorialType: TutorialType) { // Safe guard - but this should never been reached if (tutorialType == TutorialType.NONE) return if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) Loading Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/inputdevice/tutorial/domain/interactor/TutorialNotificationCoordinatorTest.kt +62 −4 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package com.android.systemui.inputdevice.tutorial.domain.interactor import android.app.Notification import android.app.NotificationManager import android.service.notification.StatusBarNotification import androidx.annotation.StringRes import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest Loading @@ -28,6 +29,7 @@ import com.android.systemui.inputdevice.tutorial.ui.TutorialNotificationCoordina import com.android.systemui.keyboard.data.repository.FakeKeyboardRepository import com.android.systemui.kosmos.backgroundScope import com.android.systemui.kosmos.testScope import com.android.systemui.kosmos.useUnconfinedTestDispatcher import com.android.systemui.res.R import com.android.systemui.settings.userTracker import com.android.systemui.statusbar.commandline.commandRegistry Loading @@ -35,6 +37,7 @@ import com.android.systemui.testKosmos import com.android.systemui.touchpad.data.repository.FakeTouchpadRepository import com.google.common.truth.Truth.assertThat import kotlin.time.Duration.Companion.hours import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before Loading @@ -44,23 +47,29 @@ import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.times import org.mockito.junit.MockitoJUnit import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.firstValue import org.mockito.kotlin.never import org.mockito.kotlin.secondValue import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class TutorialNotificationCoordinatorTest : SysuiTestCase() { private lateinit var underTest: TutorialNotificationCoordinator private val kosmos = testKosmos() private val kosmos = testKosmos().useUnconfinedTestDispatcher() private val testScope = kosmos.testScope private val keyboardRepository = FakeKeyboardRepository() private val touchpadRepository = FakeTouchpadRepository() private lateinit var repository: TutorialSchedulerRepository @Mock private lateinit var notificationManager: NotificationManager @Mock private lateinit var notification: StatusBarNotification @Captor private lateinit var notificationCaptor: ArgumentCaptor<Notification> @get:Rule val rule = MockitoJUnit.rule() Loading Loading @@ -107,6 +116,7 @@ class TutorialNotificationCoordinatorTest : SysuiTestCase() { fun showTouchpadNotification() = runTestAndClear { touchpadRepository.setIsAnyTouchpadConnected(true) testScope.advanceTimeBy(LAUNCH_DELAY) mockExistingNotification() verifyNotification( R.string.launch_touchpad_tutorial_notification_title, R.string.launch_touchpad_tutorial_notification_content, Loading @@ -131,6 +141,45 @@ class TutorialNotificationCoordinatorTest : SysuiTestCase() { .notifyAsUser(eq(TAG), eq(NOTIFICATION_ID), any(), any()) } @Test fun showKeyboardNotificationThenDisconnectKeyboard() = runTestAndClear { keyboardRepository.setIsAnyKeyboardConnected(true) testScope.advanceTimeBy(LAUNCH_DELAY) verifyNotification( R.string.launch_keyboard_tutorial_notification_title, R.string.launch_keyboard_tutorial_notification_content, ) mockExistingNotification() // After the keyboard is disconnected, i.e. there is nothing connected, the notification // should be cancelled keyboardRepository.setIsAnyKeyboardConnected(false) verify(notificationManager).cancelAsUser(eq(TAG), eq(NOTIFICATION_ID), any()) } @Test fun showKeyboardTouchpadNotificationThenDisconnectKeyboard() = runTestAndClear { keyboardRepository.setIsAnyKeyboardConnected(true) touchpadRepository.setIsAnyTouchpadConnected(true) testScope.advanceTimeBy(LAUNCH_DELAY) mockExistingNotification() keyboardRepository.setIsAnyKeyboardConnected(false) verify(notificationManager, times(2)) .notifyAsUser(eq(TAG), eq(NOTIFICATION_ID), notificationCaptor.capture(), any()) // Connect both device and the first notification is for both notificationCaptor.firstValue.verify( R.string.launch_keyboard_touchpad_tutorial_notification_title, R.string.launch_keyboard_touchpad_tutorial_notification_content, ) // After the keyboard is disconnected, i.e. with only the touchpad left, the notification // should be update to the one for only touchpad notificationCaptor.secondValue.verify( R.string.launch_touchpad_tutorial_notification_title, R.string.launch_touchpad_tutorial_notification_content, ) } private fun runTestAndClear(block: suspend () -> Unit) = testScope.runTest { try { Loading @@ -140,12 +189,21 @@ class TutorialNotificationCoordinatorTest : SysuiTestCase() { } } // Assume that there's an existing notification when the updater checks activeNotifications private fun mockExistingNotification() { whenever(notification.id).thenReturn(NOTIFICATION_ID) whenever(notificationManager.activeNotifications).thenReturn(arrayOf(notification)) } private fun verifyNotification(@StringRes titleResId: Int, @StringRes contentResId: Int) { verify(notificationManager) .notifyAsUser(eq(TAG), eq(NOTIFICATION_ID), notificationCaptor.capture(), any()) val notification = notificationCaptor.value val actualTitle = notification.getString(Notification.EXTRA_TITLE) val actualContent = notification.getString(Notification.EXTRA_TEXT) notificationCaptor.value.verify(titleResId, contentResId) } private fun Notification.verify(@StringRes titleResId: Int, @StringRes contentResId: Int) { val actualTitle = getString(Notification.EXTRA_TITLE) val actualContent = getString(Notification.EXTRA_TEXT) assertThat(actualTitle).isEqualTo(context.getString(titleResId)) assertThat(actualContent).isEqualTo(context.getString(contentResId)) } Loading
packages/SystemUI/src/com/android/systemui/inputdevice/tutorial/domain/interactor/TutorialSchedulerInteractor.kt +22 −0 Original line number Diff line number Diff line Loading @@ -39,6 +39,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow Loading Loading @@ -99,6 +101,25 @@ constructor( waitForDeviceConnection(deviceType) } // This flow is used by the notification updater once an initial notification is launched. It // listens to the device connection changes for both keyboard and touchpad. When either of the // device is disconnected, resolve the tutorial type base on the latest connection state. // Dropping the initial state because it's the existing notification. Filtering out BOTH because // we only care about disconnections. val tutorialTypeUpdates: Flow<TutorialType> = keyboardRepository.isAnyKeyboardConnected .combine(touchpadRepository.isAnyTouchpadConnected, ::Pair) .map { (keyboardConnected, touchpadConnected) -> when { keyboardConnected && touchpadConnected -> TutorialType.BOTH keyboardConnected -> TutorialType.KEYBOARD touchpadConnected -> TutorialType.TOUCHPAD else -> TutorialType.NONE } } .drop(1) .filter { it != TutorialType.BOTH } private suspend fun waitForDeviceConnection(deviceType: DeviceType) = isAnyDeviceConnected[deviceType]!!.filter { it }.first() Loading Loading @@ -172,6 +193,7 @@ constructor( pw.println( " launch time = ${repo.getScheduledTutorialLaunchTime(TOUCHPAD)}" ) pw.println("Delay time = ${LAUNCH_DELAY.seconds} sec") } "notify" -> { if (args.size != 2) help(pw) Loading
packages/SystemUI/src/com/android/systemui/inputdevice/tutorial/ui/TutorialNotificationCoordinator.kt +28 −1 Original line number Diff line number Diff line Loading @@ -42,6 +42,9 @@ import com.android.systemui.res.R import com.android.systemui.settings.UserTracker import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.merge /** When the scheduler is due, show a notification to launch tutorial */ Loading @@ -55,19 +58,43 @@ constructor( private val notificationManager: NotificationManager, private val userTracker: UserTracker, ) { private var updaterJob: Job? = null fun start() { backgroundScope.launch { merge( tutorialSchedulerInteractor.tutorials, tutorialSchedulerInteractor.commandTutorials, ) .collect { showNotification(it) } .filter { it != TutorialType.NONE } .collectLatest { showNotification(it) updaterJob?.cancel() updaterJob = backgroundScope.launch { updateWhenDeviceDisconnects() } } } } private suspend fun updateWhenDeviceDisconnects() { // Only update the notification when there is an active one (i.e. if the notification has // been dismissed by the user, or if the tutorial has been launched, there's no need to // update) tutorialSchedulerInteractor.tutorialTypeUpdates .filter { hasNotification() } .collect { if (it == TutorialType.NONE) notificationManager.cancelAsUser(TAG, NOTIFICATION_ID, userTracker.userHandle) else showNotification(it) } } private fun hasNotification() = notificationManager.activeNotifications.any { it.id == NOTIFICATION_ID } // By sharing the same tag and id, we update the content of existing notification instead of // creating multiple notifications private fun showNotification(tutorialType: TutorialType) { // Safe guard - but this should never been reached if (tutorialType == TutorialType.NONE) return if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) Loading