Loading packages/SystemUI/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractor.kt +44 −1 Original line number Diff line number Diff line Loading @@ -21,6 +21,9 @@ import com.android.systemui.CoreStartable import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging import com.android.systemui.contextualeducation.GestureType import com.android.systemui.contextualeducation.GestureType.ALL_APPS import com.android.systemui.contextualeducation.GestureType.BACK import com.android.systemui.contextualeducation.GestureType.HOME import com.android.systemui.contextualeducation.GestureType.OVERVIEW import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.education.ContextualEducationMetricsLogger Loading @@ -37,6 +40,7 @@ import com.android.systemui.recents.OverviewProxyService import com.android.systemui.recents.OverviewProxyService.OverviewProxyListener import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow import java.time.Clock import java.time.Instant import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.days Loading @@ -48,6 +52,7 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.merge Loading @@ -71,6 +76,8 @@ constructor( const val TAG = "KeyboardTouchpadEduInteractor" const val MAX_SIGNAL_COUNT: Int = 2 const val MAX_EDUCATION_SHOW_COUNT: Int = 2 const val MAX_TOAST_PER_USAGE_SESSION: Int = 2 val usageSessionDuration = getDurationForConfig("persist.contextual_edu.usage_session_sec", 3.days) val minIntervalBetweenEdu = Loading Loading @@ -110,6 +117,16 @@ constructor( awaitClose { overviewProxyService.removeCallback(listener) } } private val gestureModelMap: Flow<Map<GestureType, GestureEduModel>> = combine( contextualEducationInteractor.backGestureModelFlow, contextualEducationInteractor.homeGestureModelFlow, contextualEducationInteractor.overviewGestureModelFlow, contextualEducationInteractor.allAppsGestureModelFlow, ) { back, home, overview, allApps -> mapOf(BACK to back, HOME to home, OVERVIEW to overview, ALL_APPS to allApps) } @OptIn(ExperimentalCoroutinesApi::class) override fun start() { backgroundScope.launch { Loading Loading @@ -211,7 +228,11 @@ constructor( private suspend fun incrementSignalCount(gestureType: GestureType) { val targetDevice = getTargetDevice(gestureType) if (isTargetDeviceConnected(targetDevice) && hasInitialDelayElapsed(targetDevice)) { if ( isTargetDeviceConnected(targetDevice) && hasInitialDelayElapsed(targetDevice) && isMinIntervalForToastEduElapsed(gestureType) ) { contextualEducationInteractor.incrementSignalCount(gestureType) } } Loading @@ -223,6 +244,28 @@ constructor( } } private suspend fun isMinIntervalForToastEduElapsed(gestureType: GestureType): Boolean { val gestureModelMap = gestureModelMap.first() // Only perform checking if the next edu is toast (i.e. no education is shown yet) if (gestureModelMap[gestureType]?.educationShownCount != 0) { return true } val wasLastEduToast = { gesture: GestureEduModel -> gesture.educationShownCount == 1 } val toastEduTimesInCurrentSession: List<Instant> = gestureModelMap.values .filter { wasLastEduToast(it) } .mapNotNull { it.lastEducationTime } .filter { it >= clock.instant().minusSeconds(usageSessionDuration.inWholeSeconds) } return if (toastEduTimesInCurrentSession.size >= MAX_TOAST_PER_USAGE_SESSION) { val lastToastTime: Instant? = toastEduTimesInCurrentSession.maxOrNull() clock.instant().isAfter(lastToastTime?.plusSeconds(usageSessionDuration.inWholeSeconds)) } else { true } } /** * Keyboard shortcut education would be provided for All Apps. Touchpad gesture education would * be provided for the rest of the gesture types (i.e. Home, Overview, Back). This method maps Loading packages/SystemUI/tests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorParameterizedTest.kt 0 → 100644 +471 −0 Original line number Diff line number Diff line /* * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.education.domain.interactor import android.content.pm.UserInfo import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.contextualeducation.GestureType import com.android.systemui.contextualeducation.GestureType.ALL_APPS import com.android.systemui.contextualeducation.GestureType.BACK import com.android.systemui.contextualeducation.GestureType.HOME import com.android.systemui.contextualeducation.GestureType.OVERVIEW import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.education.data.model.GestureEduModel import com.android.systemui.education.data.repository.contextualEducationRepository import com.android.systemui.education.data.repository.fakeEduClock import com.android.systemui.education.shared.model.EducationUiType import com.android.systemui.inputdevice.tutorial.data.repository.DeviceType import com.android.systemui.inputdevice.tutorial.tutorialSchedulerRepository import com.android.systemui.keyboard.data.repository.keyboardRepository import com.android.systemui.kosmos.testScope import com.android.systemui.recents.OverviewProxyService.OverviewProxyListener import com.android.systemui.testKosmos import com.android.systemui.touchpad.data.repository.touchpadRepository import com.android.systemui.user.data.repository.fakeUserRepository import com.google.common.truth.Truth.assertThat import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import platform.test.runner.parameterized.ParameterizedAndroidJunit4 import platform.test.runner.parameterized.Parameters @SmallTest @RunWith(ParameterizedAndroidJunit4::class) @kotlinx.coroutines.ExperimentalCoroutinesApi class KeyboardTouchpadEduInteractorParameterizedTest(private val gestureType: GestureType) : SysuiTestCase() { private val kosmos = testKosmos() private val testScope = kosmos.testScope private val contextualEduInteractor = kosmos.contextualEducationInteractor private val repository = kosmos.contextualEducationRepository private val touchpadRepository = kosmos.touchpadRepository private val keyboardRepository = kosmos.keyboardRepository private val tutorialSchedulerRepository = kosmos.tutorialSchedulerRepository private val userRepository = kosmos.fakeUserRepository private val overviewProxyService = kosmos.mockOverviewProxyService private val underTest: KeyboardTouchpadEduInteractor = kosmos.keyboardTouchpadEduInteractor private val eduClock = kosmos.fakeEduClock private val minDurationForNextEdu = KeyboardTouchpadEduInteractor.minIntervalBetweenEdu + 1.seconds private val initialDelayElapsedDuration = KeyboardTouchpadEduInteractor.initialDelayDuration + 1.seconds @Before fun setup() { underTest.start() contextualEduInteractor.start() userRepository.setUserInfos(USER_INFOS) testScope.launch { contextualEduInteractor.updateKeyboardFirstConnectionTime() contextualEduInteractor.updateTouchpadFirstConnectionTime() } } @Test fun newEducationInfoOnMaxSignalCountReached() = testScope.runTest { triggerMaxEducationSignals(gestureType) val model by collectLastValue(underTest.educationTriggered) assertThat(model?.gestureType).isEqualTo(gestureType) } @Test fun newEducationToastOn1stEducation() = testScope.runTest { val model by collectLastValue(underTest.educationTriggered) triggerMaxEducationSignals(gestureType) assertThat(model?.educationUiType).isEqualTo(EducationUiType.Toast) } @Test fun newEducationNotificationOn2ndEducation() = testScope.runTest { val model by collectLastValue(underTest.educationTriggered) triggerMaxEducationSignals(gestureType) // runCurrent() to trigger 1st education runCurrent() eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) assertThat(model?.educationUiType).isEqualTo(EducationUiType.Notification) } @Test fun noEducationInfoBeforeMaxSignalCountReached() = testScope.runTest { contextualEduInteractor.incrementSignalCount(gestureType) val model by collectLastValue(underTest.educationTriggered) assertThat(model).isNull() } @Test fun noEducationInfoWhenShortcutTriggeredPreviously() = testScope.runTest { val model by collectLastValue(underTest.educationTriggered) contextualEduInteractor.updateShortcutTriggerTime(gestureType) triggerMaxEducationSignals(gestureType) assertThat(model).isNull() } @Test fun no2ndEducationBeforeMinEduIntervalReached() = testScope.runTest { val models by collectValues(underTest.educationTriggered) triggerMaxEducationSignals(gestureType) runCurrent() // Offset a duration that is less than the required education interval eduClock.offset(1.seconds) triggerMaxEducationSignals(gestureType) runCurrent() assertThat(models.filterNotNull().size).isEqualTo(1) } @Test fun noNewEducationInfoAfterMaxEducationCountReached() = testScope.runTest { val models by collectValues(underTest.educationTriggered) // Trigger 2 educations triggerMaxEducationSignals(gestureType) runCurrent() eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) runCurrent() // Try triggering 3rd education eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) assertThat(models.filterNotNull().size).isEqualTo(2) } @Test fun startNewUsageSessionWhen2ndSignalReceivedAfterSessionDeadline() = testScope.runTest { val model by collectLastValue( kosmos.contextualEducationRepository.readGestureEduModelFlow(gestureType) ) contextualEduInteractor.incrementSignalCount(gestureType) eduClock.offset(KeyboardTouchpadEduInteractor.usageSessionDuration.plus(1.seconds)) val secondSignalReceivedTime = eduClock.instant() contextualEduInteractor.incrementSignalCount(gestureType) assertThat(model) .isEqualTo( GestureEduModel( signalCount = 1, usageSessionStartTime = secondSignalReceivedTime, userId = 0, gestureType = gestureType, ) ) } @Test fun newTouchpadConnectionTimeOnFirstTouchpadConnected() = testScope.runTest { setIsAnyTouchpadConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.touchpadFirstConnectionTime).isEqualTo(eduClock.instant()) } @Test fun unchangedTouchpadConnectionTimeOnSecondConnection() = testScope.runTest { val firstConnectionTime = eduClock.instant() setIsAnyTouchpadConnected(true) setIsAnyTouchpadConnected(false) eduClock.offset(1.hours) setIsAnyTouchpadConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.touchpadFirstConnectionTime).isEqualTo(firstConnectionTime) } @Test fun newTouchpadConnectionTimeOnUserChanged() = testScope.runTest { // Touchpad connected for user 0 setIsAnyTouchpadConnected(true) // Change user eduClock.offset(1.hours) val newUserFirstConnectionTime = eduClock.instant() userRepository.setSelectedUserInfo(USER_INFOS[0]) runCurrent() val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.touchpadFirstConnectionTime).isEqualTo(newUserFirstConnectionTime) } @Test fun newKeyboardConnectionTimeOnKeyboardConnected() = testScope.runTest { setIsAnyKeyboardConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.keyboardFirstConnectionTime).isEqualTo(eduClock.instant()) } @Test fun unchangedKeyboardConnectionTimeOnSecondConnection() = testScope.runTest { val firstConnectionTime = eduClock.instant() setIsAnyKeyboardConnected(true) setIsAnyKeyboardConnected(false) eduClock.offset(1.hours) setIsAnyKeyboardConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.keyboardFirstConnectionTime).isEqualTo(firstConnectionTime) } @Test fun newKeyboardConnectionTimeOnUserChanged() = testScope.runTest { // Keyboard connected for user 0 setIsAnyKeyboardConnected(true) // Change user eduClock.offset(1.hours) val newUserFirstConnectionTime = eduClock.instant() userRepository.setSelectedUserInfo(USER_INFOS[0]) runCurrent() val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.keyboardFirstConnectionTime).isEqualTo(newUserFirstConnectionTime) } @Test fun updateShortcutTimeOnKeyboardShortcutTriggered() = testScope.runTest { // Only All Apps needs to update the keyboard shortcut assumeTrue(gestureType == ALL_APPS) kosmos.contextualEducationRepository.setKeyboardShortcutTriggered(ALL_APPS) val model by collectLastValue( kosmos.contextualEducationRepository.readGestureEduModelFlow(ALL_APPS) ) assertThat(model?.lastShortcutTriggeredTime).isEqualTo(eduClock.instant()) } @Test fun dataUpdatedOnIncrementSignalCountWhenTouchpadConnected() = testScope.runTest { assumeTrue(gestureType != ALL_APPS) setUpForInitialDelayElapse() touchpadRepository.setIsAnyTouchpadConnected(true) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue + 1) } @Test fun dataUnchangedOnIncrementSignalCountWhenTouchpadDisconnected() = testScope.runTest { setUpForInitialDelayElapse() touchpadRepository.setIsAnyTouchpadConnected(false) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } @Test fun dataUpdatedOnIncrementSignalCountWhenKeyboardConnected() = testScope.runTest { assumeTrue(gestureType == ALL_APPS) setUpForInitialDelayElapse() keyboardRepository.setIsAnyKeyboardConnected(true) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue + 1) } @Test fun dataUnchangedOnIncrementSignalCountWhenKeyboardDisconnected() = testScope.runTest { setUpForInitialDelayElapse() keyboardRepository.setIsAnyKeyboardConnected(false) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } @Test fun dataAddedOnUpdateShortcutTriggerTime() = testScope.runTest { val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) assertThat(model?.lastShortcutTriggeredTime).isNull() val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ true, gestureType) assertThat(model?.lastShortcutTriggeredTime).isEqualTo(kosmos.fakeEduClock.instant()) } @Test fun dataUpdatedOnIncrementSignalCountAfterInitialDelay() = testScope.runTest { setUpForDeviceConnection() tutorialSchedulerRepository.updateLaunchTime(DeviceType.TOUCHPAD, eduClock.instant()) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount eduClock.offset(initialDelayElapsedDuration) val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue + 1) } @Test fun dataUnchangedOnIncrementSignalCountBeforeInitialDelay() = testScope.runTest { setUpForDeviceConnection() tutorialSchedulerRepository.updateLaunchTime(DeviceType.TOUCHPAD, eduClock.instant()) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount // No offset to the clock to simulate update before initial delay val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } @Test fun dataUnchangedOnIncrementSignalCountWithoutOobeLaunchTime() = testScope.runTest { // No update to OOBE launch time to simulate no OOBE is launched yet setUpForDeviceConnection() val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } private suspend fun setUpForInitialDelayElapse() { tutorialSchedulerRepository.updateLaunchTime(DeviceType.TOUCHPAD, eduClock.instant()) tutorialSchedulerRepository.updateLaunchTime(DeviceType.KEYBOARD, eduClock.instant()) eduClock.offset(initialDelayElapsedDuration) } fun logMetricsForToastEducation() = testScope.runTest { triggerMaxEducationSignals(gestureType) runCurrent() verify(kosmos.mockEduMetricsLogger) .logContextualEducationTriggered(gestureType, EducationUiType.Toast) } @Test fun logMetricsForNotificationEducation() = testScope.runTest { triggerMaxEducationSignals(gestureType) runCurrent() eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) runCurrent() verify(kosmos.mockEduMetricsLogger) .logContextualEducationTriggered(gestureType, EducationUiType.Notification) } @After fun clear() { testScope.launch { tutorialSchedulerRepository.clear() } } private suspend fun triggerMaxEducationSignals(gestureType: GestureType) { // Increment max number of signal to try triggering education for (i in 1..KeyboardTouchpadEduInteractor.MAX_SIGNAL_COUNT) { contextualEduInteractor.incrementSignalCount(gestureType) } } private fun TestScope.setIsAnyTouchpadConnected(isConnected: Boolean) { touchpadRepository.setIsAnyTouchpadConnected(isConnected) runCurrent() } private fun TestScope.setIsAnyKeyboardConnected(isConnected: Boolean) { keyboardRepository.setIsAnyKeyboardConnected(isConnected) runCurrent() } private fun setUpForDeviceConnection() { touchpadRepository.setIsAnyTouchpadConnected(true) keyboardRepository.setIsAnyKeyboardConnected(true) } private fun getOverviewProxyListener(): OverviewProxyListener { val listenerCaptor = argumentCaptor<OverviewProxyListener>() verify(overviewProxyService).addCallback(listenerCaptor.capture()) return listenerCaptor.firstValue } companion object { private val USER_INFOS = listOf(UserInfo(101, "Second User", 0)) @JvmStatic @Parameters(name = "{0}") fun getGestureTypes(): List<GestureType> { return listOf(BACK, HOME, OVERVIEW, ALL_APPS) } } } packages/SystemUI/tests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorTest.kt +59 −351 File changed.Preview size limit exceeded, changes collapsed. Show changes packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryKosmos.kt +3 −1 Original line number Diff line number Diff line Loading @@ -17,9 +17,11 @@ package com.android.systemui.education.data.repository import com.android.systemui.kosmos.Kosmos import java.time.Duration import java.time.Instant var Kosmos.contextualEducationRepository: FakeContextualEducationRepository by Kosmos.Fixture { FakeContextualEducationRepository() } var Kosmos.fakeEduClock: FakeEduClock by Kosmos.Fixture { FakeEduClock(Instant.MIN) } var Kosmos.fakeEduClock: FakeEduClock by Kosmos.Fixture { FakeEduClock(Instant.ofEpochSecond(Duration.ofDays(30).seconds)) } Loading
packages/SystemUI/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractor.kt +44 −1 Original line number Diff line number Diff line Loading @@ -21,6 +21,9 @@ import com.android.systemui.CoreStartable import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging import com.android.systemui.contextualeducation.GestureType import com.android.systemui.contextualeducation.GestureType.ALL_APPS import com.android.systemui.contextualeducation.GestureType.BACK import com.android.systemui.contextualeducation.GestureType.HOME import com.android.systemui.contextualeducation.GestureType.OVERVIEW import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.education.ContextualEducationMetricsLogger Loading @@ -37,6 +40,7 @@ import com.android.systemui.recents.OverviewProxyService import com.android.systemui.recents.OverviewProxyService.OverviewProxyListener import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow import java.time.Clock import java.time.Instant import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.days Loading @@ -48,6 +52,7 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.merge Loading @@ -71,6 +76,8 @@ constructor( const val TAG = "KeyboardTouchpadEduInteractor" const val MAX_SIGNAL_COUNT: Int = 2 const val MAX_EDUCATION_SHOW_COUNT: Int = 2 const val MAX_TOAST_PER_USAGE_SESSION: Int = 2 val usageSessionDuration = getDurationForConfig("persist.contextual_edu.usage_session_sec", 3.days) val minIntervalBetweenEdu = Loading Loading @@ -110,6 +117,16 @@ constructor( awaitClose { overviewProxyService.removeCallback(listener) } } private val gestureModelMap: Flow<Map<GestureType, GestureEduModel>> = combine( contextualEducationInteractor.backGestureModelFlow, contextualEducationInteractor.homeGestureModelFlow, contextualEducationInteractor.overviewGestureModelFlow, contextualEducationInteractor.allAppsGestureModelFlow, ) { back, home, overview, allApps -> mapOf(BACK to back, HOME to home, OVERVIEW to overview, ALL_APPS to allApps) } @OptIn(ExperimentalCoroutinesApi::class) override fun start() { backgroundScope.launch { Loading Loading @@ -211,7 +228,11 @@ constructor( private suspend fun incrementSignalCount(gestureType: GestureType) { val targetDevice = getTargetDevice(gestureType) if (isTargetDeviceConnected(targetDevice) && hasInitialDelayElapsed(targetDevice)) { if ( isTargetDeviceConnected(targetDevice) && hasInitialDelayElapsed(targetDevice) && isMinIntervalForToastEduElapsed(gestureType) ) { contextualEducationInteractor.incrementSignalCount(gestureType) } } Loading @@ -223,6 +244,28 @@ constructor( } } private suspend fun isMinIntervalForToastEduElapsed(gestureType: GestureType): Boolean { val gestureModelMap = gestureModelMap.first() // Only perform checking if the next edu is toast (i.e. no education is shown yet) if (gestureModelMap[gestureType]?.educationShownCount != 0) { return true } val wasLastEduToast = { gesture: GestureEduModel -> gesture.educationShownCount == 1 } val toastEduTimesInCurrentSession: List<Instant> = gestureModelMap.values .filter { wasLastEduToast(it) } .mapNotNull { it.lastEducationTime } .filter { it >= clock.instant().minusSeconds(usageSessionDuration.inWholeSeconds) } return if (toastEduTimesInCurrentSession.size >= MAX_TOAST_PER_USAGE_SESSION) { val lastToastTime: Instant? = toastEduTimesInCurrentSession.maxOrNull() clock.instant().isAfter(lastToastTime?.plusSeconds(usageSessionDuration.inWholeSeconds)) } else { true } } /** * Keyboard shortcut education would be provided for All Apps. Touchpad gesture education would * be provided for the rest of the gesture types (i.e. Home, Overview, Back). This method maps Loading
packages/SystemUI/tests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorParameterizedTest.kt 0 → 100644 +471 −0 Original line number Diff line number Diff line /* * Copyright 2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.education.domain.interactor import android.content.pm.UserInfo import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.contextualeducation.GestureType import com.android.systemui.contextualeducation.GestureType.ALL_APPS import com.android.systemui.contextualeducation.GestureType.BACK import com.android.systemui.contextualeducation.GestureType.HOME import com.android.systemui.contextualeducation.GestureType.OVERVIEW import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.education.data.model.GestureEduModel import com.android.systemui.education.data.repository.contextualEducationRepository import com.android.systemui.education.data.repository.fakeEduClock import com.android.systemui.education.shared.model.EducationUiType import com.android.systemui.inputdevice.tutorial.data.repository.DeviceType import com.android.systemui.inputdevice.tutorial.tutorialSchedulerRepository import com.android.systemui.keyboard.data.repository.keyboardRepository import com.android.systemui.kosmos.testScope import com.android.systemui.recents.OverviewProxyService.OverviewProxyListener import com.android.systemui.testKosmos import com.android.systemui.touchpad.data.repository.touchpadRepository import com.android.systemui.user.data.repository.fakeUserRepository import com.google.common.truth.Truth.assertThat import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.verify import platform.test.runner.parameterized.ParameterizedAndroidJunit4 import platform.test.runner.parameterized.Parameters @SmallTest @RunWith(ParameterizedAndroidJunit4::class) @kotlinx.coroutines.ExperimentalCoroutinesApi class KeyboardTouchpadEduInteractorParameterizedTest(private val gestureType: GestureType) : SysuiTestCase() { private val kosmos = testKosmos() private val testScope = kosmos.testScope private val contextualEduInteractor = kosmos.contextualEducationInteractor private val repository = kosmos.contextualEducationRepository private val touchpadRepository = kosmos.touchpadRepository private val keyboardRepository = kosmos.keyboardRepository private val tutorialSchedulerRepository = kosmos.tutorialSchedulerRepository private val userRepository = kosmos.fakeUserRepository private val overviewProxyService = kosmos.mockOverviewProxyService private val underTest: KeyboardTouchpadEduInteractor = kosmos.keyboardTouchpadEduInteractor private val eduClock = kosmos.fakeEduClock private val minDurationForNextEdu = KeyboardTouchpadEduInteractor.minIntervalBetweenEdu + 1.seconds private val initialDelayElapsedDuration = KeyboardTouchpadEduInteractor.initialDelayDuration + 1.seconds @Before fun setup() { underTest.start() contextualEduInteractor.start() userRepository.setUserInfos(USER_INFOS) testScope.launch { contextualEduInteractor.updateKeyboardFirstConnectionTime() contextualEduInteractor.updateTouchpadFirstConnectionTime() } } @Test fun newEducationInfoOnMaxSignalCountReached() = testScope.runTest { triggerMaxEducationSignals(gestureType) val model by collectLastValue(underTest.educationTriggered) assertThat(model?.gestureType).isEqualTo(gestureType) } @Test fun newEducationToastOn1stEducation() = testScope.runTest { val model by collectLastValue(underTest.educationTriggered) triggerMaxEducationSignals(gestureType) assertThat(model?.educationUiType).isEqualTo(EducationUiType.Toast) } @Test fun newEducationNotificationOn2ndEducation() = testScope.runTest { val model by collectLastValue(underTest.educationTriggered) triggerMaxEducationSignals(gestureType) // runCurrent() to trigger 1st education runCurrent() eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) assertThat(model?.educationUiType).isEqualTo(EducationUiType.Notification) } @Test fun noEducationInfoBeforeMaxSignalCountReached() = testScope.runTest { contextualEduInteractor.incrementSignalCount(gestureType) val model by collectLastValue(underTest.educationTriggered) assertThat(model).isNull() } @Test fun noEducationInfoWhenShortcutTriggeredPreviously() = testScope.runTest { val model by collectLastValue(underTest.educationTriggered) contextualEduInteractor.updateShortcutTriggerTime(gestureType) triggerMaxEducationSignals(gestureType) assertThat(model).isNull() } @Test fun no2ndEducationBeforeMinEduIntervalReached() = testScope.runTest { val models by collectValues(underTest.educationTriggered) triggerMaxEducationSignals(gestureType) runCurrent() // Offset a duration that is less than the required education interval eduClock.offset(1.seconds) triggerMaxEducationSignals(gestureType) runCurrent() assertThat(models.filterNotNull().size).isEqualTo(1) } @Test fun noNewEducationInfoAfterMaxEducationCountReached() = testScope.runTest { val models by collectValues(underTest.educationTriggered) // Trigger 2 educations triggerMaxEducationSignals(gestureType) runCurrent() eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) runCurrent() // Try triggering 3rd education eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) assertThat(models.filterNotNull().size).isEqualTo(2) } @Test fun startNewUsageSessionWhen2ndSignalReceivedAfterSessionDeadline() = testScope.runTest { val model by collectLastValue( kosmos.contextualEducationRepository.readGestureEduModelFlow(gestureType) ) contextualEduInteractor.incrementSignalCount(gestureType) eduClock.offset(KeyboardTouchpadEduInteractor.usageSessionDuration.plus(1.seconds)) val secondSignalReceivedTime = eduClock.instant() contextualEduInteractor.incrementSignalCount(gestureType) assertThat(model) .isEqualTo( GestureEduModel( signalCount = 1, usageSessionStartTime = secondSignalReceivedTime, userId = 0, gestureType = gestureType, ) ) } @Test fun newTouchpadConnectionTimeOnFirstTouchpadConnected() = testScope.runTest { setIsAnyTouchpadConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.touchpadFirstConnectionTime).isEqualTo(eduClock.instant()) } @Test fun unchangedTouchpadConnectionTimeOnSecondConnection() = testScope.runTest { val firstConnectionTime = eduClock.instant() setIsAnyTouchpadConnected(true) setIsAnyTouchpadConnected(false) eduClock.offset(1.hours) setIsAnyTouchpadConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.touchpadFirstConnectionTime).isEqualTo(firstConnectionTime) } @Test fun newTouchpadConnectionTimeOnUserChanged() = testScope.runTest { // Touchpad connected for user 0 setIsAnyTouchpadConnected(true) // Change user eduClock.offset(1.hours) val newUserFirstConnectionTime = eduClock.instant() userRepository.setSelectedUserInfo(USER_INFOS[0]) runCurrent() val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.touchpadFirstConnectionTime).isEqualTo(newUserFirstConnectionTime) } @Test fun newKeyboardConnectionTimeOnKeyboardConnected() = testScope.runTest { setIsAnyKeyboardConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.keyboardFirstConnectionTime).isEqualTo(eduClock.instant()) } @Test fun unchangedKeyboardConnectionTimeOnSecondConnection() = testScope.runTest { val firstConnectionTime = eduClock.instant() setIsAnyKeyboardConnected(true) setIsAnyKeyboardConnected(false) eduClock.offset(1.hours) setIsAnyKeyboardConnected(true) val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.keyboardFirstConnectionTime).isEqualTo(firstConnectionTime) } @Test fun newKeyboardConnectionTimeOnUserChanged() = testScope.runTest { // Keyboard connected for user 0 setIsAnyKeyboardConnected(true) // Change user eduClock.offset(1.hours) val newUserFirstConnectionTime = eduClock.instant() userRepository.setSelectedUserInfo(USER_INFOS[0]) runCurrent() val model = contextualEduInteractor.getEduDeviceConnectionTime() assertThat(model.keyboardFirstConnectionTime).isEqualTo(newUserFirstConnectionTime) } @Test fun updateShortcutTimeOnKeyboardShortcutTriggered() = testScope.runTest { // Only All Apps needs to update the keyboard shortcut assumeTrue(gestureType == ALL_APPS) kosmos.contextualEducationRepository.setKeyboardShortcutTriggered(ALL_APPS) val model by collectLastValue( kosmos.contextualEducationRepository.readGestureEduModelFlow(ALL_APPS) ) assertThat(model?.lastShortcutTriggeredTime).isEqualTo(eduClock.instant()) } @Test fun dataUpdatedOnIncrementSignalCountWhenTouchpadConnected() = testScope.runTest { assumeTrue(gestureType != ALL_APPS) setUpForInitialDelayElapse() touchpadRepository.setIsAnyTouchpadConnected(true) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue + 1) } @Test fun dataUnchangedOnIncrementSignalCountWhenTouchpadDisconnected() = testScope.runTest { setUpForInitialDelayElapse() touchpadRepository.setIsAnyTouchpadConnected(false) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } @Test fun dataUpdatedOnIncrementSignalCountWhenKeyboardConnected() = testScope.runTest { assumeTrue(gestureType == ALL_APPS) setUpForInitialDelayElapse() keyboardRepository.setIsAnyKeyboardConnected(true) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue + 1) } @Test fun dataUnchangedOnIncrementSignalCountWhenKeyboardDisconnected() = testScope.runTest { setUpForInitialDelayElapse() keyboardRepository.setIsAnyKeyboardConnected(false) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } @Test fun dataAddedOnUpdateShortcutTriggerTime() = testScope.runTest { val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) assertThat(model?.lastShortcutTriggeredTime).isNull() val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ true, gestureType) assertThat(model?.lastShortcutTriggeredTime).isEqualTo(kosmos.fakeEduClock.instant()) } @Test fun dataUpdatedOnIncrementSignalCountAfterInitialDelay() = testScope.runTest { setUpForDeviceConnection() tutorialSchedulerRepository.updateLaunchTime(DeviceType.TOUCHPAD, eduClock.instant()) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount eduClock.offset(initialDelayElapsedDuration) val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue + 1) } @Test fun dataUnchangedOnIncrementSignalCountBeforeInitialDelay() = testScope.runTest { setUpForDeviceConnection() tutorialSchedulerRepository.updateLaunchTime(DeviceType.TOUCHPAD, eduClock.instant()) val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount // No offset to the clock to simulate update before initial delay val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } @Test fun dataUnchangedOnIncrementSignalCountWithoutOobeLaunchTime() = testScope.runTest { // No update to OOBE launch time to simulate no OOBE is launched yet setUpForDeviceConnection() val model by collectLastValue(repository.readGestureEduModelFlow(gestureType)) val originalValue = model!!.signalCount val listener = getOverviewProxyListener() listener.updateContextualEduStats(/* isTrackpadGesture= */ false, gestureType) assertThat(model?.signalCount).isEqualTo(originalValue) } private suspend fun setUpForInitialDelayElapse() { tutorialSchedulerRepository.updateLaunchTime(DeviceType.TOUCHPAD, eduClock.instant()) tutorialSchedulerRepository.updateLaunchTime(DeviceType.KEYBOARD, eduClock.instant()) eduClock.offset(initialDelayElapsedDuration) } fun logMetricsForToastEducation() = testScope.runTest { triggerMaxEducationSignals(gestureType) runCurrent() verify(kosmos.mockEduMetricsLogger) .logContextualEducationTriggered(gestureType, EducationUiType.Toast) } @Test fun logMetricsForNotificationEducation() = testScope.runTest { triggerMaxEducationSignals(gestureType) runCurrent() eduClock.offset(minDurationForNextEdu) triggerMaxEducationSignals(gestureType) runCurrent() verify(kosmos.mockEduMetricsLogger) .logContextualEducationTriggered(gestureType, EducationUiType.Notification) } @After fun clear() { testScope.launch { tutorialSchedulerRepository.clear() } } private suspend fun triggerMaxEducationSignals(gestureType: GestureType) { // Increment max number of signal to try triggering education for (i in 1..KeyboardTouchpadEduInteractor.MAX_SIGNAL_COUNT) { contextualEduInteractor.incrementSignalCount(gestureType) } } private fun TestScope.setIsAnyTouchpadConnected(isConnected: Boolean) { touchpadRepository.setIsAnyTouchpadConnected(isConnected) runCurrent() } private fun TestScope.setIsAnyKeyboardConnected(isConnected: Boolean) { keyboardRepository.setIsAnyKeyboardConnected(isConnected) runCurrent() } private fun setUpForDeviceConnection() { touchpadRepository.setIsAnyTouchpadConnected(true) keyboardRepository.setIsAnyKeyboardConnected(true) } private fun getOverviewProxyListener(): OverviewProxyListener { val listenerCaptor = argumentCaptor<OverviewProxyListener>() verify(overviewProxyService).addCallback(listenerCaptor.capture()) return listenerCaptor.firstValue } companion object { private val USER_INFOS = listOf(UserInfo(101, "Second User", 0)) @JvmStatic @Parameters(name = "{0}") fun getGestureTypes(): List<GestureType> { return listOf(BACK, HOME, OVERVIEW, ALL_APPS) } } }
packages/SystemUI/tests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorTest.kt +59 −351 File changed.Preview size limit exceeded, changes collapsed. Show changes
packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryKosmos.kt +3 −1 Original line number Diff line number Diff line Loading @@ -17,9 +17,11 @@ package com.android.systemui.education.data.repository import com.android.systemui.kosmos.Kosmos import java.time.Duration import java.time.Instant var Kosmos.contextualEducationRepository: FakeContextualEducationRepository by Kosmos.Fixture { FakeContextualEducationRepository() } var Kosmos.fakeEduClock: FakeEduClock by Kosmos.Fixture { FakeEduClock(Instant.MIN) } var Kosmos.fakeEduClock: FakeEduClock by Kosmos.Fixture { FakeEduClock(Instant.ofEpochSecond(Duration.ofDays(30).seconds)) }