diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt index e2247b2367a..e75247720b1 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt @@ -9,6 +9,7 @@ import androidx.appcompat.widget.Toolbar import androidx.core.view.doOnPreDraw import androidx.databinding.DataBindingUtil import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer import androidx.lifecycle.Transformations import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope @@ -297,11 +298,7 @@ class ExplorationActivityPresenter @Inject constructor( } is AsyncResult.Success -> { oppiaLogger.d("ExplorationActivity", "Successfully stopped exploration") - if (isCompletion) { - maybeShowSurveyDialog(profileId, topicId) - } else { - backPressActivitySelector() - } + maybeShowSurveyDialog(profileId, topicId) } } } @@ -528,42 +525,48 @@ class ExplorationActivityPresenter @Inject constructor( } private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) { - surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() - .observe( - activity - ) { gatingResult -> - when (gatingResult) { - is AsyncResult.Pending -> { - oppiaLogger.d("ExplorationActivity", "A gating decision is pending") - } - is AsyncResult.Failure -> { - oppiaLogger.e( - "ExplorationActivity", - "Failed to retrieve gating decision", - gatingResult.error - ) - backPressActivitySelector() - } - is AsyncResult.Success -> { - if (gatingResult.value) { - val dialogFragment = - SurveyWelcomeDialogFragment.newInstance( - profileId, - topicId, - explorationId, - SURVEY_QUESTIONS - ) - val transaction = activity.supportFragmentManager.beginTransaction() - transaction - .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) - .addToBackStack(null) - .commit() - } else { + val liveData = surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() + liveData.observe( + activity, + object : Observer> { + override fun onChanged(gatingResult: AsyncResult?) { + when (gatingResult) { + is AsyncResult.Pending -> { + oppiaLogger.d("ExplorationActivity", "A gating decision is pending") + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "ExplorationActivity", + "Failed to retrieve gating decision", + gatingResult.error + ) backPressActivitySelector() } + is AsyncResult.Success -> { + if (gatingResult.value) { + val dialogFragment = + SurveyWelcomeDialogFragment.newInstance( + profileId, + topicId, + explorationId, + SURVEY_QUESTIONS + ) + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) + .addToBackStack(null) + .commit() + + // Changes to underlying DataProviders will update the gating result. + liveData.removeObserver(this) + } else { + backPressActivitySelector() + } + } } } } + ) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt index 9aabc25f075..f1c00a6569a 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt @@ -189,7 +189,6 @@ class StateFragmentPresenter @Inject constructor( fun onReturnToTopicButtonClicked() { hideKeyboard() markExplorationCompleted() - maybeShowSurveyDialog(profileId, topicId) } private fun showOrHideAudioByState(state: State) { @@ -455,13 +454,17 @@ class StateFragmentPresenter @Inject constructor( fun getExplorationCheckpointState() = explorationCheckpointState private fun markExplorationCompleted() { - storyProgressController.recordCompletedChapter( + val markStoryCompletedLivedata = storyProgressController.recordCompletedChapter( profileId, topicId, storyId, explorationId, oppiaClock.getCurrentTimeMs() - ) + ).toLiveData() + + // Only check gating result when the previous operation has completed because gating depends on + // result of saving the time spent in the exploration, at the end of the exploration. + markStoryCompletedLivedata.observe(fragment, { maybeShowSurveyDialog(profileId, topicId) }) } private fun showHintsAndSolutions(helpIndex: HelpIndex, isCurrentStatePendingState: Boolean) { @@ -535,44 +538,43 @@ class StateFragmentPresenter @Inject constructor( } private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) { - surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() - .observe( - activity, - { gatingResult -> - when (gatingResult) { - is AsyncResult.Pending -> { - oppiaLogger.d("StateFragment", "A gating decision is pending") - } - is AsyncResult.Failure -> { - oppiaLogger.e( - "StateFragment", - "Failed to retrieve gating decision", - gatingResult.error - ) + surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData().observe( + activity, + { gatingResult -> + when (gatingResult) { + is AsyncResult.Pending -> { + oppiaLogger.d("StateFragment", "A gating decision is pending") + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateFragment", + "Failed to retrieve gating decision", + gatingResult.error + ) + (activity as StopStatePlayingSessionWithSavedProgressListener) + .deleteCurrentProgressAndStopSession(isCompletion = true) + } + is AsyncResult.Success -> { + if (gatingResult.value) { + val dialogFragment = + SurveyWelcomeDialogFragment.newInstance( + profileId, + topicId, + explorationId, + SURVEY_QUESTIONS + ) + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) + .commitNow() + } else { (activity as StopStatePlayingSessionWithSavedProgressListener) .deleteCurrentProgressAndStopSession(isCompletion = true) } - is AsyncResult.Success -> { - if (gatingResult.value) { - val dialogFragment = - SurveyWelcomeDialogFragment.newInstance( - profileId, - topicId, - explorationId, - SURVEY_QUESTIONS - ) - val transaction = activity.supportFragmentManager.beginTransaction() - transaction - .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) - .commitNow() - } else { - (activity as StopStatePlayingSessionWithSavedProgressListener) - .deleteCurrentProgressAndStopSession(isCompletion = true) - } - } } } - ) + } + ) } /** diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt index 312e5477daa..482451ae6b7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt @@ -3,7 +3,6 @@ package org.oppia.android.app.player.state.testing import android.widget.Button import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil -import androidx.lifecycle.Observer import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.app.model.ProfileId @@ -98,7 +97,7 @@ class StateFragmentTestActivityPresenter @Inject constructor( } startPlayingProvider.toLiveData().observe( activity, - Observer> { result -> + { result -> when (result) { is AsyncResult.Pending -> oppiaLogger.d(TEST_ACTIVITY_TAG, "Loading exploration") is AsyncResult.Failure -> diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt index bc24dfacd60..0255528e2fe 100644 --- a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt @@ -57,6 +57,7 @@ class SurveyWelcomeDialogFragmentPresenter @Inject constructor( } profileManagementController.updateSurveyLastShownTimestamp(profileId) + logSurveyPopUpShownEvent(explorationId, topicId, profileId) return binding.root diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt index a05e4756684..1c13ec60360 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt @@ -28,6 +28,7 @@ import androidx.test.espresso.contrib.RecyclerViewActions.scrollToHolder import androidx.test.espresso.intent.Intents import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.Visibility.GONE +import androidx.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE import androidx.test.espresso.matcher.ViewMatchers.hasChildCount import androidx.test.espresso.matcher.ViewMatchers.isClickable import androidx.test.espresso.matcher.ViewMatchers.isDisplayed @@ -168,6 +169,7 @@ import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.CoroutineExecutorService import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule @@ -216,6 +218,7 @@ class StateFragmentTest { @Inject lateinit var testGlideImageLoader: TestGlideImageLoader @Inject lateinit var profileManagementController: ProfileManagementController @Inject lateinit var fakeAnalyticsEventLogger: FakeAnalyticsEventLogger + @Inject lateinit var oppiaClock: FakeOppiaClock private val profileId = ProfileId.newBuilder().apply { internalId = 1 }.build() @@ -4317,6 +4320,258 @@ class StateFragmentTest { } } + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_lateNight_isPastGracePeriod_minimumAggregateTimeMet_noSurveyPopup() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(LATE_NIGHT_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(LATE_NIGHT_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + // Check that the fragment is removed. + // In production, the activity is finished and TopicActivity is navigated to, but since this + // test runs in a test activity, once the test completes, the fragment is removed and the + // placeholders are displayed instead. + onView(withId(R.id.play_test_exploration_button)).check( + matches( + withEffectiveVisibility( + VISIBLE + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_earlyMorning_isPastGracePeriod_minimumAggregateTimeMet_noSurveyPopup() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(EARLY_MORNING_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(EARLY_MORNING_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + // Check that the fragment is removed. + // In production, the activity is finished and TopicActivity is navigated to, but since this + // test runs in a test activity, once the test completes, the fragment is removed and the + // placeholders are displayed instead. + onView(withId(R.id.play_test_exploration_button)).check( + matches( + withEffectiveVisibility( + VISIBLE + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_midMorning_isPastGracePeriod_minimumAggregateTimeMet_surveyPopupShown() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(MID_MORNING_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(MID_MORNING_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + onView(withId(R.id.survey_onboarding_title_text)) + .check( + matches( + allOf( + withText(R.string.survey_onboarding_title_text), + isDisplayed() + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_afternoon_isPastGracePeriod_minimumAggregateTimeMet_surveyPopupShown() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + onView(withId(R.id.survey_onboarding_title_text)) + .check( + matches( + allOf( + withText(R.string.survey_onboarding_title_text), + isDisplayed() + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_evening_isPastGracePeriod_minimumAggregateTimeMet_surveyPopupShown() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + onView(withId(R.id.survey_onboarding_title_text)) + .check( + matches( + allOf( + withText(R.string.survey_onboarding_title_text), + isDisplayed() + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_allGatingConditionsMet_surveyDismissed_popupDoesNotShowAgain() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + onView(withId(R.id.maybe_later_button)) + .perform(click()) + testCoroutineDispatchers.runCurrent() + + // Check that the fragment is removed. + // When the survey popup is shown, the lastShownDateProvider is updated with current time, + // consequently updating the combined gating data provider. Recomputation of the gating result + // should not re-trigger the survey. + onView(withId(R.id.play_test_exploration_button)).check( + matches( + withEffectiveVisibility( + VISIBLE + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_surveyFeatureOff_allGatingConditionsMet_noSurveyPopup() { + // Survey Gating conditions are: isPastGracePeriod, has achieved minimum aggregate exploration + // time of 5min in a topic, and is within the hours of 9am and 10pm in the user's local time. + + // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be + // more than the grace period days in the past, so no need to explicitly define + // surveyLastShownTimestamp for computing the grace period. + + setUpTestWithSurveyFeatureOff() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + // Check that the fragment is removed. + // In production, the activity is finished and TopicActivity is navigated to, but since this + // test runs in a test activity, once the test completes, the fragment is removed and the + // placeholders are displayed instead. + onView(withId(R.id.play_test_exploration_button)).check( + matches( + withEffectiveVisibility( + VISIBLE + ) + ) + ) + } + } + + @Test + @RunOn(TestPlatform.ESPRESSO) // TODO(#1612): Enable for Robolectric. + fun testFinishChapter_updateGatingProvider_surveyGatingCriteriaMetEarlier_doesntUpdateUI() { + setUpTestWithSurveyFeatureOn() + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) + + launchForExploration(TEST_EXPLORATION_ID_2, shouldSavePartialProgress = true).use { + startPlayingExploration() + + playThroughPrototypeExploration() + + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS + SESSION_LENGTH_LONG) + + clickReturnToTopicButton() + + onView(withText(R.string.survey_onboarding_title_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(R.string.survey_onboarding_message_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + // Update the SurveyLastShownTimestamp to trigger an update in the data provider and notify + // subscribers of an update. + profileManagementController.updateSurveyLastShownTimestamp(profileId) + testCoroutineDispatchers.runCurrent() + + onView(withText(R.string.survey_onboarding_title_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(R.string.survey_onboarding_message_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + } + private fun addShadowMediaPlayerException(dataSource: Any, exception: Exception) { val classLoader = StateFragmentTest::class.java.classLoader!! val shadowMediaPlayerClass = classLoader.loadClass("org.robolectric.shadows.ShadowMediaPlayer") @@ -4866,6 +5121,16 @@ class StateFragmentTest { setUpTest() } + private fun setUpTestWithSurveyFeatureOn() { + TestPlatformParameterModule.forceEnableNpsSurvey(true) + setUpTest() + } + + private fun setUpTestWithSurveyFeatureOff() { + TestPlatformParameterModule.forceEnableNpsSurvey(false) + setUpTest() + } + private fun setUpTest() { Intents.init() setUpTestApplicationComponent() @@ -5111,4 +5376,27 @@ class StateFragmentTest { override fun getApplicationInjector(): ApplicationInjector = component } + + private companion object { + // Date & time: Wed Apr 24 2019 08:22:03 GMT. + private const val EARLY_MORNING_UTC_TIMESTAMP_MILLIS = 1556094123000 + + // Date & time: Wed Apr 24 2019 10:30:12 GMT. + private const val MID_MORNING_UTC_TIMESTAMP_MILLIS = 1556101812000 + + // Date & time: Tue Apr 23 2019 14:22:00 GMT. + private const val AFTERNOON_UTC_TIMESTAMP_MILLIS = 1556029320000 + + // Date & time: Tue Apr 23 2019 21:26:12 GMT. + private const val EVENING_UTC_TIMESTAMP_MILLIS = 1556054772000 + + // Date & time: Tue Apr 23 2019 23:22:00 GMT. + private const val LATE_NIGHT_UTC_TIMESTAMP_MILLIS = 1556061720000 + + // Exploration play through time less than the required 5 min + private const val SESSION_LENGTH_SHORT = 120000L + + // Exploration play through time greater than the required 5 min + private const val SESSION_LENGTH_LONG = 360000L + } } diff --git a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt index edc6c0a91e3..720d559a182 100644 --- a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt @@ -5,14 +5,22 @@ import android.content.Intent import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import dagger.Component import org.junit.After -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.oppia.android.R import org.oppia.android.app.activity.ActivityComponent import org.oppia.android.app.activity.ActivityComponentFactory import org.oppia.android.app.activity.route.ActivityRouterModule @@ -28,6 +36,7 @@ import org.oppia.android.app.model.EventLog import org.oppia.android.app.model.EventLog.Context.ActivityContextCase.OPEN_EXPLORATION_ACTIVITY import org.oppia.android.app.model.ExplorationActivityParams import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.Spotlight import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.shim.IntentFactoryShimModule import org.oppia.android.app.shim.ViewBindingShimModule @@ -61,9 +70,10 @@ import org.oppia.android.domain.oppialogger.analytics.ApplicationLifecycleModule import org.oppia.android.domain.oppialogger.analytics.CpuPerformanceSnapshotterModule import org.oppia.android.domain.oppialogger.logscheduler.MetricLogSchedulerModule import org.oppia.android.domain.oppialogger.loguploader.LogReportWorkerModule -import org.oppia.android.domain.platformparameter.PlatformParameterModule import org.oppia.android.domain.platformparameter.PlatformParameterSingletonModule +import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.question.QuestionModule +import org.oppia.android.domain.spotlight.SpotlightStateController import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 import org.oppia.android.domain.topic.TEST_STORY_ID_0 @@ -73,9 +83,11 @@ import org.oppia.android.testing.FakeAnalyticsEventLogger import org.oppia.android.testing.TestLogReportingModule import org.oppia.android.testing.firebase.TestAuthenticationModule import org.oppia.android.testing.junit.InitializeDefaultLocaleRule +import org.oppia.android.testing.platformparameter.TestPlatformParameterModule import org.oppia.android.testing.robolectric.RobolectricModule import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.caching.AssetModule @@ -113,15 +125,19 @@ class ExplorationActivityLocalTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + lateinit var fakeOppiaClock: FakeOppiaClock + + @Inject + lateinit var spotlightStateController: SpotlightStateController + + @Inject + lateinit var profileManagementController: ProfileManagementController + private lateinit var networkConnectionUtil: NetworkConnectionUtil private lateinit var explorationDataController: ExplorationDataController private val internalProfileId: Int = 0 - - @Before - fun setUp() { - setUpTestApplicationComponent() - testCoroutineDispatchers.registerIdlingResource() - } + private val afternoonUtcTimestampMillis = 1556101812000 @After fun tearDown() { @@ -130,6 +146,7 @@ class ExplorationActivityLocalTest { @Test fun testExploration_onLaunch_logsEvent() { + setUpTestApplicationComponent() getApplicationDependencies( internalProfileId, TEST_TOPIC_ID_0, @@ -155,6 +172,178 @@ class ExplorationActivityLocalTest { } } + @Test + fun testExplorationActivity_closeExploration_surveyGatingCriteriaMet_showsSurveyPopup() { + setUpTestWithNpsEnabled() + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + fakeOppiaClock.setCurrentTimeMs(afternoonUtcTimestampMillis) + + getApplicationDependencies( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + markAllSpotlightsSeen() + + launch( + createExplorationActivityIntent( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + ).use { + explorationDataController.startPlayingNewExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + testCoroutineDispatchers.runCurrent() + + fakeOppiaClock.setCurrentTimeMs(afternoonUtcTimestampMillis + 360_000L) + + onView(withContentDescription(R.string.nav_app_bar_navigate_up_description)) + .perform(click()) + onView(withText(R.string.stop_exploration_dialog_leave_button)) + .inRoot(isDialog()) + .perform(click()) + onView(withText(R.string.stop_exploration_dialog_leave_button)) + .inRoot(isDialog()) + testCoroutineDispatchers.runCurrent() + + onView(withText(R.string.survey_onboarding_title_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(R.string.survey_onboarding_message_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + } + + @Test + fun testExplorationActivity_closeExploration_surveyGatingCriteriaNotMet_noSurveyPopup() { + setUpTestWithNpsEnabled() + getApplicationDependencies( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + + markAllSpotlightsSeen() + + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + fakeOppiaClock.setCurrentTimeMs(afternoonUtcTimestampMillis) + + launch( + createExplorationActivityIntent( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + ).use { + explorationDataController.startPlayingNewExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + testCoroutineDispatchers.runCurrent() + + // Time not advanced to simulate minimum aggregate learning time not achieved. + onView(withContentDescription(R.string.nav_app_bar_navigate_up_description)) + .perform(click()) + onView(withText(R.string.stop_exploration_dialog_leave_button)) + .inRoot(isDialog()) + .perform(click()) + + onView(withText(R.string.survey_onboarding_title_text)) + .check(ViewAssertions.doesNotExist()) + } + } + + @Test + fun testExplorationActivity_updateGatingProvider_surveyGatingCriteriaMet_keepsSurveyDialog() { + setUpTestWithNpsEnabled() + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + fakeOppiaClock.setCurrentTimeMs(afternoonUtcTimestampMillis) + + getApplicationDependencies( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + markAllSpotlightsSeen() + + launch( + createExplorationActivityIntent( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + ).use { + explorationDataController.startPlayingNewExploration( + internalProfileId, + TEST_TOPIC_ID_0, + TEST_STORY_ID_0, + TEST_EXPLORATION_ID_2 + ) + testCoroutineDispatchers.runCurrent() + + fakeOppiaClock.setCurrentTimeMs(afternoonUtcTimestampMillis + 360_000L) + + onView(withContentDescription(R.string.nav_app_bar_navigate_up_description)) + .perform(click()) + onView(withText(R.string.stop_exploration_dialog_leave_button)) + .inRoot(isDialog()) + .perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(withText(R.string.survey_onboarding_title_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(R.string.survey_onboarding_message_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + + // Update the SurveyLastShownTimestamp to trigger an update in the data provider and notify + // subscribers of an update. + profileManagementController.updateSurveyLastShownTimestamp( + ProfileId.newBuilder().setInternalId(internalProfileId).build() + ) + + onView(withText(R.string.survey_onboarding_title_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + onView(withText(R.string.survey_onboarding_message_text)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + } + } + + private fun setUpTestWithNpsEnabled() { + TestPlatformParameterModule.forceEnableNpsSurvey(true) + setUpTestApplicationComponent() + } + + private fun markAllSpotlightsSeen() { + markSpotlightSeen(Spotlight.FeatureCase.LESSONS_BACK_BUTTON) + markSpotlightSeen(Spotlight.FeatureCase.VOICEOVER_PLAY_ICON) + markSpotlightSeen(Spotlight.FeatureCase.VOICEOVER_LANGUAGE_ICON) + } + + private fun markSpotlightSeen(feature: Spotlight.FeatureCase) { + val profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() + spotlightStateController.markSpotlightViewed(profileId, feature) + testCoroutineDispatchers.runCurrent() + } + private fun getApplicationDependencies( internalProfileId: Int, topicId: String, @@ -194,6 +383,7 @@ class ExplorationActivityLocalTest { private fun setUpTestApplicationComponent() { ApplicationProvider.getApplicationContext().inject(this) + testCoroutineDispatchers.registerIdlingResource() } // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. @@ -201,7 +391,7 @@ class ExplorationActivityLocalTest { @Component( modules = [ TestDispatcherModule::class, ApplicationModule::class, RobolectricModule::class, - PlatformParameterModule::class, PlatformParameterSingletonModule::class, + TestPlatformParameterModule::class, PlatformParameterSingletonModule::class, LoggerModule::class, ContinueModule::class, FractionInputModule::class, ItemSelectionInputModule::class, MultipleChoiceInputModule::class, NumberWithUnitsRuleModule::class, NumericInputRuleModule::class, TextInputRuleModule::class, diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt index 13ee41e96c9..ad76139194a 100644 --- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt +++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt @@ -8,7 +8,6 @@ import org.oppia.android.domain.oppialogger.exceptions.ExceptionsController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider import org.oppia.android.util.data.DataProviders -import org.oppia.android.util.data.DataProviders.Companion.combineWith import org.oppia.android.util.data.DataProviders.Companion.transform import java.util.UUID import javax.inject.Inject @@ -54,13 +53,7 @@ class SurveyController @Inject constructor( survey.mandatoryQuestionsList + survey.optionalQuestion } else survey.mandatoryQuestionsList } - - val beginSessionDataProvider = - surveyProgressController.beginSurveySession(surveyId, profileId, questionsListDataProvider) - - beginSessionDataProvider.combineWith( - createSurveyDataProvider, START_SURVEY_SESSION_PROVIDER_ID - ) { sessionResult, _ -> sessionResult } + surveyProgressController.beginSurveySession(surveyId, profileId, questionsListDataProvider) } catch (e: Exception) { exceptionsController.logNonFatalException(e) dataProviders.createInMemoryDataProviderAsync(START_SURVEY_SESSION_PROVIDER_ID) { diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt index fcb790a0245..d37e3afa32c 100644 --- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt +++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyGatingController.kt @@ -66,7 +66,7 @@ class SurveyGatingController @Inject constructor( val currentTimeStamp = oppiaClock.getCurrentTimeMs() val showNextTimestamp = lastShownTimestampMs + gracePeriodMillis - return currentTimeStamp > showNextTimestamp || currentTimeStamp == showNextTimestamp + return currentTimeStamp >= showNextTimestamp } private fun retrieveSurveyLastShownDate(profileId: ProfileId) = diff --git a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt index b1c412eca01..d997dcce25c 100644 --- a/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt +++ b/domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt @@ -382,8 +382,8 @@ class SurveyProgressController @Inject constructor( if (selectedAnswer.questionName == SurveyQuestionName.NPS) { // compute the feedback question before navigating to it progress.questionGraph.computeFeedbackQuestion( - currentQuestionId + 1, - selectedAnswer.npsScore + index = currentQuestionId + 1, + npsScore = selectedAnswer.npsScore ) } diff --git a/domain/src/test/java/org/oppia/android/domain/survey/SurveyGatingControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/survey/SurveyGatingControllerTest.kt index df46b018821..b757364aab3 100644 --- a/domain/src/test/java/org/oppia/android/domain/survey/SurveyGatingControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/survey/SurveyGatingControllerTest.kt @@ -49,6 +49,7 @@ import javax.inject.Singleton private const val SESSION_LENGTH_SHORT = 120000L private const val SESSION_LENGTH_LONG = 360000L +private const val SESSION_LENGTH_MINIMUM = 300000L /** Tests for [SurveyGatingController]. */ @RunWith(AndroidJUnit4::class) @@ -120,6 +121,22 @@ class SurveyGatingControllerTest { @Test fun testGating_lateNight_isPastGracePeriod_minimumAggregateTimeMet_returnsFalse() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(LATE_NIGHT_UTC_TIMESTAMP_MILLIS) + // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be + // more than the grace period days in the past, so no need to explicitly define + // surveyLastShownTimestamp here. + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isFalse() + } + + @Test + fun testGating_lateNight_isPastGracePeriod_minimumAggregateTimeExceeded_returnsFalse() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) oppiaClock.setCurrentTimeMs(LATE_NIGHT_UTC_TIMESTAMP_MILLIS) // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be @@ -168,6 +185,22 @@ class SurveyGatingControllerTest { @Test fun testGating_earlyMorning_isPastGracePeriod_minimumAggregateTimeMet_returnsFalse() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(EARLY_MORNING_UTC_TIMESTAMP_MILLIS) + // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be + // more than the grace period days in the past, so no need to explicitly define + // surveyLastShownTimestamp here. + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isFalse() + } + + @Test + fun testGating_earlyMorning_isPastGracePeriod_minimumAggregateTimeExceeded_returnsFalse() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) oppiaClock.setCurrentTimeMs(EARLY_MORNING_UTC_TIMESTAMP_MILLIS) // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be @@ -200,6 +233,22 @@ class SurveyGatingControllerTest { @Test fun testGating_midMorning_stillWithinGracePeriod_minimumAggregateTimeMet_returnsFalse() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(MID_MORNING_UTC_TIMESTAMP_MILLIS) + monitorFactory.ensureDataProviderExecutes( + profileManagementController.updateSurveyLastShownTimestamp(PROFILE_ID_0) + ) + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isFalse() + } + + @Test + fun testGating_midMorning_stillWithinGracePeriod_minimumAggregateTimeExceeded_returnsFalse() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) oppiaClock.setCurrentTimeMs(MID_MORNING_UTC_TIMESTAMP_MILLIS) monitorFactory.ensureDataProviderExecutes( @@ -232,6 +281,25 @@ class SurveyGatingControllerTest { @Test fun testGating_midMorning_isPastGracePeriod_minimumAggregateTimeMet_returnsTrue() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_UPTIME_MILLIS) + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(MID_MORNING_UTC_TIMESTAMP_MILLIS) + + // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be + // more than the grace period days in the past, so no need to explicitly define + // surveyLastShownTimestamp here. + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isTrue() + } + + @Test + fun testGating_midMorning_isPastGracePeriod_minimumAggregateTimeExceeded_returnsTrue() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_UPTIME_MILLIS) startAndEndExplorationSession(SESSION_LENGTH_LONG, PROFILE_ID_0, TEST_TOPIC_ID_0) @@ -266,7 +334,23 @@ class SurveyGatingControllerTest { } @Test - fun testGating_afternoon_stillWithinGracePeriod__minimumAggregateTimeMet_returnsFalse() { + fun testGating_afternoon_stillWithinGracePeriod_minimumAggregateTimeMet_returnsFalse() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) + monitorFactory.ensureDataProviderExecutes( + profileManagementController.updateSurveyLastShownTimestamp(PROFILE_ID_0) + ) + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isFalse() + } + + @Test + fun testGating_afternoon_stillWithinGracePeriod_minimumAggregateTimeExceeded_returnsFalse() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) monitorFactory.ensureDataProviderExecutes( @@ -301,6 +385,25 @@ class SurveyGatingControllerTest { @Test fun testGating_afternoon_isPastGracePeriod_minimumAggregateTimeMet_returnsTrue() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_UPTIME_MILLIS) + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) + + // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be + // more than the grace period days in the past, so no need to explicitly define + // surveyLastShownTimestamp here. + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isTrue() + } + + @Test + fun testGating_afternoon_isPastGracePeriod_minimumAggregateTimeExceeded_returnsTrue() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_UPTIME_MILLIS) startAndEndExplorationSession(SESSION_LENGTH_LONG, PROFILE_ID_0, TEST_TOPIC_ID_0) @@ -336,6 +439,22 @@ class SurveyGatingControllerTest { @Test fun testGating_evening_stillWithinGracePeriod_minimumAggregateTimeMet_returnsFalse() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS) + monitorFactory.ensureDataProviderExecutes( + profileManagementController.updateSurveyLastShownTimestamp(PROFILE_ID_0) + ) + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isFalse() + } + + @Test + fun testGating_evening_stillWithinGracePeriod_minimumAggregateTimeExceeded_returnsFalse() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS) monitorFactory.ensureDataProviderExecutes( @@ -370,6 +489,25 @@ class SurveyGatingControllerTest { @Test fun testGating_evening_isPastGracePeriod_minimumAggregateTimeMet_returnsTrue() { + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_UPTIME_MILLIS) + startAndEndExplorationSession(SESSION_LENGTH_MINIMUM, PROFILE_ID_0, TEST_TOPIC_ID_0) + + oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + oppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS) + + // The default surveyLastShownTimestamp is set to the beginning of epoch which will always be + // more than the grace period days in the past, so no need to explicitly define + // surveyLastShownTimestamp here. + + val gatingProvider = surveyGatingController.maybeShowSurvey(PROFILE_ID_0, TEST_TOPIC_ID_0) + + val result = monitorFactory.waitForNextSuccessfulResult(gatingProvider) + + assertThat(result).isTrue() + } + + @Test + fun testGating_evening_isPastGracePeriod_minimumAggregateTimeExceeded_returnsTrue() { oppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_UPTIME_MILLIS) startAndEndExplorationSession(SESSION_LENGTH_LONG, PROFILE_ID_0, TEST_TOPIC_ID_0) diff --git a/testing/src/main/java/org/oppia/android/testing/espresso/GenericViewMatchers.kt b/testing/src/main/java/org/oppia/android/testing/espresso/GenericViewMatchers.kt index ea1f8b15de4..efbb360d967 100644 --- a/testing/src/main/java/org/oppia/android/testing/espresso/GenericViewMatchers.kt +++ b/testing/src/main/java/org/oppia/android/testing/espresso/GenericViewMatchers.kt @@ -1,7 +1,9 @@ package org.oppia.android.testing.espresso import android.graphics.drawable.GradientDrawable +import android.os.Build import android.view.View +import androidx.annotation.RequiresApi import org.hamcrest.Description import org.hamcrest.Matcher import org.hamcrest.TypeSafeMatcher @@ -19,6 +21,7 @@ class GenericViewMatchers { * Returns a [Matcher] that verifies a view has a fully opaque background. The view is expected * to have a [GradientDrawable] background. */ + @RequiresApi(Build.VERSION_CODES.N) fun withOpaqueBackground(): Matcher = withColorBackgroundMatching( descriptionSuffix = "an opaque background" ) { color -> color?.extractAlpha() == 0xff } @@ -27,6 +30,7 @@ class GenericViewMatchers { * Returns a [Matcher] with the specified description suffix and color matcher, matching against * filled background colors of views. */ + @RequiresApi(Build.VERSION_CODES.N) private fun withColorBackgroundMatching( @Suppress("SameParameterValue") descriptionSuffix: String, colorMatcher: (Long?) -> Boolean diff --git a/testing/src/main/java/org/oppia/android/testing/junit/OppiaParameterizedTestRunner.kt b/testing/src/main/java/org/oppia/android/testing/junit/OppiaParameterizedTestRunner.kt index 4dd7b3427c7..ae5e3e5f55c 100644 --- a/testing/src/main/java/org/oppia/android/testing/junit/OppiaParameterizedTestRunner.kt +++ b/testing/src/main/java/org/oppia/android/testing/junit/OppiaParameterizedTestRunner.kt @@ -1,5 +1,7 @@ package org.oppia.android.testing.junit +import android.os.Build +import androidx.annotation.RequiresApi import org.junit.runner.Description import org.junit.runner.Runner import org.junit.runner.manipulation.Filter @@ -76,6 +78,7 @@ import kotlin.reflect.KClass * contain (thus they should be treated as undefined outside of tests that specific define their * value via [Iteration]). */ +@RequiresApi(Build.VERSION_CODES.N) class OppiaParameterizedTestRunner(private val testClass: Class<*>) : Suite(testClass, listOf()) { private val parameterizedMethods = computeParameterizedMethods() private val selectedRunnerClass by lazy { fetchSelectedRunnerPlatformClass() } @@ -95,6 +98,7 @@ class OppiaParameterizedTestRunner(private val testClass: Class<*>) : Suite(test override fun getChildren(): MutableList = childrenRunners.toMutableList() + @RequiresApi(Build.VERSION_CODES.N) private fun computeParameterizedMethods(): Map { val fieldsAndParsers = fetchParameterizedFields().map { field -> val valueParser = ParameterValue.createParserForField(field) @@ -184,12 +188,14 @@ class OppiaParameterizedTestRunner(private val testClass: Class<*>) : Suite(test }.associateBy { it.methodName } } + @RequiresApi(Build.VERSION_CODES.N) private fun fetchParameterizedFields(): List { return testClass.declaredFields.mapNotNull { field -> field.getDeclaredAnnotation(Parameter::class.java)?.let { field } } } + @RequiresApi(Build.VERSION_CODES.N) private fun fetchParameterizedMethodDeclarations(): List { return testClass.declaredMethods.mapNotNull { method -> method.getDeclaredAnnotationsByType(Iteration::class.java).map { parameters -> @@ -208,6 +214,7 @@ class OppiaParameterizedTestRunner(private val testClass: Class<*>) : Suite(test } } + @RequiresApi(Build.VERSION_CODES.N) private fun fetchSelectedRunnerPlatformClass(): Class<*> { return checkNotNull(testClass.getDeclaredAnnotation(SelectRunnerPlatform::class.java)) { "All suites using OppiaParameterizedTestRunner must declare their base platform runner" + diff --git a/testing/src/main/java/org/oppia/android/testing/threading/TestCoroutineDispatchersRobolectricImpl.kt b/testing/src/main/java/org/oppia/android/testing/threading/TestCoroutineDispatchersRobolectricImpl.kt index 3c93d9065bd..2f27175670b 100644 --- a/testing/src/main/java/org/oppia/android/testing/threading/TestCoroutineDispatchersRobolectricImpl.kt +++ b/testing/src/main/java/org/oppia/android/testing/threading/TestCoroutineDispatchersRobolectricImpl.kt @@ -1,5 +1,7 @@ package org.oppia.android.testing.threading +import android.os.Build +import androidx.annotation.RequiresApi import org.oppia.android.testing.time.FakeSystemClock import java.lang.reflect.Method import java.time.Duration @@ -34,6 +36,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } while (hasPendingCompletableTasks()) } + @RequiresApi(Build.VERSION_CODES.O) override fun advanceTimeBy(delayTimeMillis: Long) { var remainingDelayMillis = delayTimeMillis while (remainingDelayMillis > 0) { @@ -49,6 +52,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } } + @RequiresApi(Build.VERSION_CODES.O) override fun advanceUntilIdle() { // First, run through all tasks that are currently pending and can be run immediately. runCurrent() @@ -67,6 +71,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } } + @RequiresApi(Build.VERSION_CODES.O) private fun advanceToNextFutureTask( currentTimeMillis: Long, maxDelayMs: Long = Long.MAX_VALUE @@ -94,6 +99,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( } /** Returns whether any of the dispatchers have any tasks to run, including in the future. */ + @RequiresApi(Build.VERSION_CODES.O) private fun hasPendingTasks(): Boolean { return backgroundTestDispatcher.hasPendingTasks() || blockingTestDispatcher.hasPendingTasks() || @@ -107,6 +113,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( !uiTaskCoordinator.isIdle() } + @RequiresApi(Build.VERSION_CODES.O) private fun getNextFutureTaskTimeMillis(timeMillis: Long): Long? { val nextBackgroundFutureTaskTimeMills = backgroundTestDispatcher.getNextFutureTaskCompletionTimeMillis(timeMillis) @@ -120,6 +127,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( return futureTimes.firstOrNull() } + @RequiresApi(Build.VERSION_CODES.O) private fun getNextUiThreadFutureTaskTimeMillis(timeMillis: Long): Long? { return uiTaskCoordinator.getNextUiThreadFutureTaskTimeMillis(timeMillis) } @@ -139,6 +147,7 @@ class TestCoroutineDispatchersRobolectricImpl @Inject constructor( idleMethod.invoke(shadowUiLooper) } + @RequiresApi(Build.VERSION_CODES.O) fun getNextUiThreadFutureTaskTimeMillis(timeMillis: Long): Long? { val nextScheduledTime = nextScheduledTimeMethod.invoke(shadowUiLooper) as Duration val delayMs = nextScheduledTime.toMillis()