From cf4b52c3cc1a594609f954085f385ea58eb5d9cf Mon Sep 17 00:00:00 2001 From: James Xu Date: Sun, 6 Oct 2019 12:59:57 -0700 Subject: [PATCH] Fixes part of #123, #116, and #130: Cellular Data Controller (#194) * Created CellularDialogController * Started making tests for controller * Removed import * Fixed test cases and renamed to hideDialog * Fixed comments * Added use cellular data value and added test cases * Started adding test cases * Setup initial testing environment for state fragment * Add more test cases * Changed cellular data controller methods to always and never use cellular data --- app/src/main/AndroidManifest.xml | 1 + .../oppia/app/activity/ActivityComponent.kt | 2 + .../oppia/app/player/state/StateFragment.kt | 47 +--- .../player/state/StateFragmentPresenter.kt | 50 ++++ .../testing/StateFragmentTestActivity.kt | 17 ++ .../StateFragmentTestActivityPresenter.kt | 27 ++ .../layout/state_fragment_test_activity.xml | 8 + .../app/player/state/StateFragmentTest.kt | 193 ++++++++++++++ .../domain/audio/CellularDialogController.kt | 56 ++++ .../audio/CellularDialogControllerTest.kt | 240 ++++++++++++++++++ model/src/main/proto/topic.proto | 9 + 11 files changed, 608 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivity.kt create mode 100644 app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivityPresenter.kt create mode 100644 app/src/main/res/layout/state_fragment_test_activity.xml create mode 100644 app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt create mode 100644 domain/src/main/java/org/oppia/domain/audio/CellularDialogController.kt create mode 100644 domain/src/test/java/org/oppia/domain/audio/CellularDialogControllerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d7c9ef442f5..2702782cb68 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:theme="@style/OppiaTheme"> + diff --git a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt index d4d73e0e1c8..3d6eaaaef4c 100644 --- a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt +++ b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt @@ -6,6 +6,7 @@ import dagger.Subcomponent import org.oppia.app.fragment.FragmentComponent import org.oppia.app.home.HomeActivity import org.oppia.app.player.exploration.ExplorationActivity +import org.oppia.app.player.state.testing.StateFragmentTestActivity import org.oppia.app.topic.TopicActivity import javax.inject.Provider @@ -24,4 +25,5 @@ interface ActivityComponent { fun inject(explorationActivity: ExplorationActivity) fun inject(homeActivity: HomeActivity) fun inject(topicActivity: TopicActivity) + fun inject(stateFragmentTestActivity: StateFragmentTestActivity) } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt index 665d2dea6cb..5d0cf9b796a 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt @@ -6,58 +6,21 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import org.oppia.app.fragment.InjectableFragment -import org.oppia.app.player.audio.CellularDataDialogFragment import org.oppia.app.player.audio.CellularDataInterface import javax.inject.Inject -private const val TAG_CELLULAR_DATA_DIALOG = "CELLULAR_DATA_DIALOG" - /** Fragment that represents the current state of an exploration. */ class StateFragment : InjectableFragment(), CellularDataInterface { - @Inject - lateinit var stateFragmentPresenter: StateFragmentPresenter - // Control this boolean value from controllers in domain module. - private var showCellularDataDialog = true - init { - // TODO(#116): Code to control the value of showCellularDataDialog using AudioController. - } + @Inject lateinit var stateFragmentPresenter: StateFragmentPresenter override fun onAttach(context: Context?) { super.onAttach(context) fragmentComponent.inject(this) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return stateFragmentPresenter.handleCreateView(inflater, container) - } - - fun dummyButtonClicked() { - if (showCellularDataDialog) { - stateFragmentPresenter.setAudioFragmentVisible(false) - showCellularDataDialogFragment() - } else { - stateFragmentPresenter.setAudioFragmentVisible(true) - } - } - - private fun showCellularDataDialogFragment() { - val previousFragment = childFragmentManager.findFragmentByTag(TAG_CELLULAR_DATA_DIALOG) - if (previousFragment != null) { - childFragmentManager.beginTransaction().remove(previousFragment).commitNow() - } - val dialogFragment = CellularDataDialogFragment.newInstance() - dialogFragment.showNow(childFragmentManager, TAG_CELLULAR_DATA_DIALOG) - } - - override fun enableAudioWhileOnCellular(saveUserChoice: Boolean) { - stateFragmentPresenter.setAudioFragmentVisible(true) - // saveUserChoice -> true -> save this preference - // saveUserChoice -> false -> do not save this preference - } - - override fun disableAudioWhileOnCellular(saveUserChoice: Boolean) { - // saveUserChoice -> true -> save this preference - // saveUserChoice -> false -> do not save this preference - } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = stateFragmentPresenter.handleCreateView(inflater, container) + override fun enableAudioWhileOnCellular(saveUserChoice: Boolean) = stateFragmentPresenter.handleEnableAudio(saveUserChoice) + override fun disableAudioWhileOnCellular(saveUserChoice: Boolean) = stateFragmentPresenter.handleDisableAudio(saveUserChoice) + fun dummyButtonClicked() = stateFragmentPresenter.handleAudioClick() } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt index 6a6f020d30c..f5a2d663fd0 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt @@ -4,18 +4,39 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer import org.oppia.app.databinding.StateFragmentBinding import org.oppia.app.fragment.FragmentScope +import org.oppia.app.model.CellularDataPreference +import org.oppia.app.player.audio.CellularDataDialogFragment import org.oppia.app.viewmodel.ViewModelProvider +import org.oppia.domain.audio.CellularDialogController +import org.oppia.util.data.AsyncResult import javax.inject.Inject +private const val TAG_CELLULAR_DATA_DIALOG = "CELLULAR_DATA_DIALOG" + /** The presenter for [StateFragment]. */ @FragmentScope class StateFragmentPresenter @Inject constructor( private val fragment: Fragment, + private val cellularDialogController: CellularDialogController, private val viewModelProvider: ViewModelProvider ) { + + private var showCellularDataDialog = true + private var useCellularData = false + fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { + cellularDialogController.getCellularDataPreference() + .observe(fragment, Observer>{ + if (it.isSuccess()) { + val prefs = it.getOrDefault(CellularDataPreference.getDefaultInstance()) + showCellularDataDialog = !(prefs.hideDialog) + useCellularData = prefs.useCellularData + } + }) + val binding = StateFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) binding.let { it.stateFragment = fragment as StateFragment @@ -24,6 +45,35 @@ class StateFragmentPresenter @Inject constructor( return binding.root } + fun handleAudioClick() { + if (showCellularDataDialog) { + setAudioFragmentVisible(false) + showCellularDataDialogFragment() + } else { + setAudioFragmentVisible(useCellularData) + } + } + + fun handleEnableAudio(saveUserChoice: Boolean) { + setAudioFragmentVisible(true) + if (saveUserChoice) + cellularDialogController.setAlwaysUseCellularDataPreference() + } + + fun handleDisableAudio(saveUserChoice: Boolean) { + if (saveUserChoice) + cellularDialogController.setNeverUseCellularDataPreference() + } + + private fun showCellularDataDialogFragment() { + val previousFragment = fragment.childFragmentManager.findFragmentByTag(TAG_CELLULAR_DATA_DIALOG) + if (previousFragment != null) { + fragment.childFragmentManager.beginTransaction().remove(previousFragment).commitNow() + } + val dialogFragment = CellularDataDialogFragment.newInstance() + dialogFragment.showNow(fragment.childFragmentManager, TAG_CELLULAR_DATA_DIALOG) + } + private fun getStateViewModel(): StateViewModel { return viewModelProvider.getForFragment(fragment, StateViewModel::class.java) } diff --git a/app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivity.kt b/app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivity.kt new file mode 100644 index 00000000000..cae76a016f8 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivity.kt @@ -0,0 +1,17 @@ +package org.oppia.app.player.state.testing + +import android.os.Bundle +import org.oppia.app.activity.InjectableAppCompatActivity +import javax.inject.Inject + +/** Test Activity used for testing StateFragment */ +class StateFragmentTestActivity : InjectableAppCompatActivity() { + + @Inject lateinit var stateFragmentTestActivityController: StateFragmentTestActivityPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activityComponent.inject(this) + stateFragmentTestActivityController.handleOnCreate() + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivityPresenter.kt b/app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivityPresenter.kt new file mode 100644 index 00000000000..47bb5962473 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/testing/StateFragmentTestActivityPresenter.kt @@ -0,0 +1,27 @@ +package org.oppia.app.player.state.testing + +import androidx.appcompat.app.AppCompatActivity +import org.oppia.app.R +import org.oppia.app.activity.ActivityScope +import org.oppia.app.player.state.StateFragment +import javax.inject.Inject + +/** The presenter for [StateFragmentTestActivity] */ +@ActivityScope +class StateFragmentTestActivityPresenter @Inject constructor( + private val activity: AppCompatActivity +) { + fun handleOnCreate() { + activity.setContentView(R.layout.state_fragment_test_activity) + if (getStateFragment() == null) { + activity.supportFragmentManager.beginTransaction().add( + R.id.state_fragment_placeholder, + StateFragment() + ).commitNow() + } + } + + private fun getStateFragment(): StateFragment? { + return activity.supportFragmentManager.findFragmentById(R.id.state_fragment_placeholder) as StateFragment? + } +} diff --git a/app/src/main/res/layout/state_fragment_test_activity.xml b/app/src/main/res/layout/state_fragment_test_activity.xml new file mode 100644 index 00000000000..b0b43d91a72 --- /dev/null +++ b/app/src/main/res/layout/state_fragment_test_activity.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt new file mode 100644 index 00000000000..cc3f30144b2 --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/app/player/state/StateFragmentTest.kt @@ -0,0 +1,193 @@ +package org.oppia.app.player.state + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +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.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import org.hamcrest.CoreMatchers.not +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.app.R +import org.oppia.app.player.state.testing.StateFragmentTestActivity +import org.oppia.util.threading.BackgroundDispatcher +import org.oppia.util.threading.BlockingDispatcher +import javax.inject.Singleton + +/** Tests for [StateFragment]. */ +@RunWith(AndroidJUnit4::class) +class StateFragmentTest { + + @Test + fun testStateFragmentTestActivity_loadStateFragment_hasDummyButton() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).check(matches(withText("Dummy Audio Button"))) + } + } + + @Test + fun testStateFragment_clickDummyButton_showsCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(matches(withText("Don\'t show this message again"))) + } + } + + @Test + fun testStateFragment_clickDummyButton_clickPositive_showsAudioFragment() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withText("OK")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + Espresso.onView(withId(R.id.audio_fragment)).check(matches((isDisplayed()))) + } + } + + @Test + fun testStateFragment_clickDummyButton_clickNegative_doesNotShowAudioFragment() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withText("CANCEL")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + Espresso.onView(withId(R.id.audio_fragment)).check(matches(not(isDisplayed()))) + } + } + + @Test + fun testStateFragment_clickPositive_clickDummyButton_showsCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withText("OK")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(matches(withText("Don\'t show this message again"))) + } + } + + @Test + fun testStateFragment_clickNegative_clickDummyButton_showsCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withText("CANCEL")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(matches(withText("Don\'t show this message again"))) + } + } + + @Test + fun testStateFragment_clickCheckBoxAndPositive_clickDummyButton_doesNotShowCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).perform(click()) + Espresso.onView(withText("OK")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(doesNotExist()) + } + } + + @Test + fun testStateFragment_clickCheckBoxAndNegative_clickDummyButton_doesNotShowCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).perform(click()) + Espresso.onView(withText("CANCEL")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(doesNotExist()) + } + } + + @Test + fun testStateFragment_clickPositive_restartActivity_clickDummyButton_showsCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withText("OK")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + } + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(matches(isDisplayed())) + } + } + + @Test + fun testStateFragment_clickNegative_restartActivity_clickDummyButton_showsCellularDialog() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withText("CANCEL")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + } + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(matches(isDisplayed())) + } + } + + + + @Test + fun testStateFragment_clickCheckBoxAndPositive_restartActivity_clickDummyButton_doesNotShowCellularDialogAndShowsAudioFragment() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).perform(click()) + Espresso.onView(withText("OK")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + } + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(doesNotExist()) + Espresso.onView(withId(R.id.audio_fragment)).check(matches(isDisplayed())) + } + } + + @Test + fun testStateFragment_clickCheckBoxAndNegative_restartActivity_clickDummyButton_doesNotShowCellularDialogAndAudioFragment() { + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).perform(click()) + Espresso.onView(withText("CANCEL")).inRoot(isDialog()).check(matches(isDisplayed())).perform(click()) + } + ActivityScenario.launch(StateFragmentTestActivity::class.java).use { + Espresso.onView(withId(R.id.dummy_audio_button)).perform(click()) + Espresso.onView(withId(R.id.cellular_data_dialog_checkbox)).check(doesNotExist()) + Espresso.onView(withId(R.id.audio_fragment)).check(matches(not(isDisplayed()))) + } + } + + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + // TODO(#89): Introduce a proper IdlingResource for background dispatchers to ensure they all complete before + // proceeding in an Espresso test. This solution should also be interoperative with Robolectric contexts by using a + // test coroutine dispatcher. + + @Singleton + @Provides + @BackgroundDispatcher + fun provideBackgroundDispatcher(@BlockingDispatcher blockingDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return blockingDispatcher + } + } + + @Singleton + @Component(modules = [TestModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + } +} diff --git a/domain/src/main/java/org/oppia/domain/audio/CellularDialogController.kt b/domain/src/main/java/org/oppia/domain/audio/CellularDialogController.kt new file mode 100644 index 00000000000..6d029e8e46f --- /dev/null +++ b/domain/src/main/java/org/oppia/domain/audio/CellularDialogController.kt @@ -0,0 +1,56 @@ +package org.oppia.domain.audio + +import androidx.lifecycle.LiveData +import org.oppia.app.model.CellularDataPreference +import org.oppia.data.persistence.PersistentCacheStore +import org.oppia.util.data.AsyncResult +import org.oppia.util.data.DataProviders +import org.oppia.util.logging.Logger +import javax.inject.Inject +import javax.inject.Singleton + +/** Controller for persisting and retrieving the cellular data preference. */ +@Singleton +class CellularDialogController @Inject constructor( + cacheStoreFactory: PersistentCacheStore.Factory, private val dataProviders: DataProviders, + private val logger: Logger +) { + private val cellularDataStore = cacheStoreFactory.create("cellular_data_preference", CellularDataPreference.getDefaultInstance()) + + fun setNeverUseCellularDataPreference() { + setHideDialogPreference(true) + setUseCellularDataPreference(false) + } + + fun setAlwaysUseCellularDataPreference() { + setHideDialogPreference(true) + setUseCellularDataPreference(true) + } + + /** Saves that the user's preference on whether to hide the dialog. */ + private fun setHideDialogPreference(hideDialog: Boolean) { + cellularDataStore.storeDataAsync(updateInMemoryCache = true) { + it.toBuilder().setHideDialog(hideDialog).build() + }.invokeOnCompletion { + it?.let { + logger.e("DOMAIN", "Failed when storing the user's preference to hide cellular data dialog.", it) + } + } + } + + /** Saves that the user's preference on whether to use cellular data. */ + private fun setUseCellularDataPreference(useData: Boolean) { + cellularDataStore.storeDataAsync(updateInMemoryCache = true) { + it.toBuilder().setUseCellularData(useData).build() + }.invokeOnCompletion { + it?.let { + logger.e("DOMAIN", "Failed when storing the user's preference to use cellular data.", it) + } + } + } + + /** Returns a [LiveData] result indicating the user's cellular data preferences. */ + fun getCellularDataPreference(): LiveData> { + return dataProviders.convertToLiveData(cellularDataStore) + } +} diff --git a/domain/src/test/java/org/oppia/domain/audio/CellularDialogControllerTest.kt b/domain/src/test/java/org/oppia/domain/audio/CellularDialogControllerTest.kt new file mode 100644 index 00000000000..08d11638e9f --- /dev/null +++ b/domain/src/test/java/org/oppia/domain/audio/CellularDialogControllerTest.kt @@ -0,0 +1,240 @@ +package org.oppia.domain.audio + +import org.oppia.domain.UserAppHistoryController +import android.app.Application +import android.content.Context +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.app.model.CellularDataPreference +import org.oppia.util.data.AsyncResult +import org.oppia.util.logging.EnableConsoleLog +import org.oppia.util.logging.EnableFileLog +import org.oppia.util.logging.GlobalLogLevel +import org.oppia.util.logging.LogLevel +import org.oppia.util.threading.BackgroundDispatcher +import org.oppia.util.threading.BlockingDispatcher +import org.robolectric.annotation.Config +import javax.inject.Inject +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlin.coroutines.EmptyCoroutineContext + +/** Tests for [UserAppHistoryController]. */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class CellularDialogControllerTest { + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Inject + lateinit var cellularDialogController: CellularDialogController + + @Inject + @field:TestDispatcher + lateinit var testDispatcher: CoroutineDispatcher + + @Inject + @field:TestBlockingDispatcher + lateinit var testBlockingDispatcher: TestCoroutineDispatcher + + private val coroutineContext by lazy { + EmptyCoroutineContext + testDispatcher + } + + @Mock + lateinit var mockCellularDataObserver: Observer> + + @Captor + lateinit var cellularDataResultCaptor: ArgumentCaptor> + + @Before + @ExperimentalCoroutinesApi + @ObsoleteCoroutinesApi + fun setUp() { + setUpTestApplicationComponent() + // Separate dispatcher is needed for PersistentCacheStore to behave similar to production. + testBlockingDispatcher.pauseDispatcher() + } + + private fun setUpTestApplicationComponent() { + DaggerCellularDialogControllerTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + @Test + @ExperimentalCoroutinesApi + fun testController_providesInitialLiveData_indicatesToNotHideDialogAndNotUseCellularData() + = runBlockingTest(coroutineContext) { + val cellularDataPreference = cellularDialogController.getCellularDataPreference() + cellularDataPreference.observeForever(mockCellularDataObserver) + testBlockingDispatcher.advanceUntilIdle() + + verify(mockCellularDataObserver, atLeastOnce()).onChanged(cellularDataResultCaptor.capture()) + assertThat(cellularDataResultCaptor.value.isSuccess()).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().hideDialog).isFalse() + assertThat(cellularDataResultCaptor.value.getOrThrow().useCellularData).isFalse() + } + + @Test + @ExperimentalCoroutinesApi + fun testController_setNeverUseCellularDataPref_providesLiveData_indicatesToHideDialogAndNotUseCellularData() + = runBlockingTest(coroutineContext) { + val appHistory = cellularDialogController.getCellularDataPreference() + + appHistory.observeForever(mockCellularDataObserver) + cellularDialogController.setNeverUseCellularDataPreference() + testBlockingDispatcher.advanceUntilIdle() + + verify(mockCellularDataObserver, atLeastOnce()).onChanged(cellularDataResultCaptor.capture()) + assertThat(cellularDataResultCaptor.value.isSuccess()).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().hideDialog).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().useCellularData).isFalse() + } + + @Test + @ExperimentalCoroutinesApi + fun testController_setAlwaysUseCellularDataPref_providesLiveData_indicatesToHideDialogAndUseCellularData() + = runBlockingTest(coroutineContext) { + val appHistory = cellularDialogController.getCellularDataPreference() + + appHistory.observeForever(mockCellularDataObserver) + cellularDialogController.setAlwaysUseCellularDataPreference() + testBlockingDispatcher.advanceUntilIdle() + + verify(mockCellularDataObserver, atLeastOnce()).onChanged(cellularDataResultCaptor.capture()) + assertThat(cellularDataResultCaptor.value.getOrThrow().hideDialog).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().useCellularData).isTrue() + } + + @Test + @ExperimentalCoroutinesApi + fun testController_setNeverUseCellularDataPref_observedNewController_indicatesToHideDialogAndNotUseCellularData() + = runBlockingTest(coroutineContext) { + cellularDialogController.setNeverUseCellularDataPreference() + testBlockingDispatcher.advanceUntilIdle() + + setUpTestApplicationComponent() + val appHistory = cellularDialogController.getCellularDataPreference() + appHistory.observeForever(mockCellularDataObserver) + testBlockingDispatcher.advanceUntilIdle() + + verify(mockCellularDataObserver, atLeastOnce()).onChanged(cellularDataResultCaptor.capture()) + assertThat(cellularDataResultCaptor.value.isSuccess()).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().hideDialog).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().useCellularData).isFalse() + } + + @Test + @ExperimentalCoroutinesApi + fun testController_setAlwaysUseCellularDataPref_observedNewController_indicatesToHideDialogAndUseCellularData() + = runBlockingTest(coroutineContext) { + cellularDialogController.setAlwaysUseCellularDataPreference() + testBlockingDispatcher.advanceUntilIdle() + + setUpTestApplicationComponent() + val appHistory = cellularDialogController.getCellularDataPreference() + appHistory.observeForever(mockCellularDataObserver) + testBlockingDispatcher.advanceUntilIdle() + + verify(mockCellularDataObserver, atLeastOnce()).onChanged(cellularDataResultCaptor.capture()) + assertThat(cellularDataResultCaptor.value.isSuccess()).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().hideDialog).isTrue() + assertThat(cellularDataResultCaptor.value.getOrThrow().useCellularData).isTrue() + } + + @Qualifier annotation class TestDispatcher + @Qualifier annotation class TestBlockingDispatcher + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @TestDispatcher + fun provideTestDispatcher(): CoroutineDispatcher { + return TestCoroutineDispatcher() + } + + @Singleton + @Provides + @BackgroundDispatcher + fun provideBackgroundDispatcher(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + @Singleton + @Provides + @BlockingDispatcher + fun provideBlockingDispatcher(@TestBlockingDispatcher testDispatcher: TestCoroutineDispatcher): CoroutineDispatcher { + return testDispatcher + } + + @ExperimentalCoroutinesApi + @Singleton + @Provides + @TestBlockingDispatcher + fun provideTestBlockingDispatcher(): TestCoroutineDispatcher { + return TestCoroutineDispatcher() + } + + // TODO(#59): Either isolate these to their own shared test module, or use the real logging + // module in tests to avoid needing to specify these settings for tests. + @EnableConsoleLog + @Provides + fun provideEnableConsoleLog(): Boolean = true + + @EnableFileLog + @Provides + fun provideEnableFileLog(): Boolean = false + + @GlobalLogLevel + @Provides + fun provideGlobalLogLevel(): LogLevel = LogLevel.VERBOSE + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component(modules = [TestModule::class]) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + fun build(): TestApplicationComponent + } + + fun inject(cellularDataControllerTest: CellularDialogControllerTest) + } +} diff --git a/model/src/main/proto/topic.proto b/model/src/main/proto/topic.proto index e3f358bc0aa..f48481174fa 100644 --- a/model/src/main/proto/topic.proto +++ b/model/src/main/proto/topic.proto @@ -228,3 +228,12 @@ message ChapterProgress { // Corresponds to whether this chapter is playable or has been started. ChapterPlayState play_state = 2; } + +// Represents user's preference for using cellular data +message CellularDataPreference { + // Preference on whether to hide CellularDataDialogFragment + bool hide_dialog = 1; + + // Preference on whether to use cellular data + bool use_cellular_data = 2; +}