diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 38f59252976..7c5560c02f4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,6 +29,10 @@
android:name=".mydownloads.MyDownloadsActivity"
android:screenOrientation="portrait"
android:theme="@style/OppiaThemeWithoutActionBar" />
+
{ result ->
+ if (result.alreadyOnBoardedApp) {
+ val intent = Intent(activity, ProfileActivity::class.java)
+ activity.startActivity(intent)
+ activity.finish()
+ } else {
+ showOnBoardingActivity()
+ }
+ })
+ }
+
+ private fun getOnBoardingFlow(): LiveData {
+ // If there's an error loading the data, assume the default.
+ return Transformations.map(onBoardingFlowData, ::processOnBoardingFlowResult)
+ }
+
+ private fun processOnBoardingFlowResult(onBoardingResult: AsyncResult): OnBoardingFlow {
+ if (onBoardingResult.isFailure()) {
+ }
+ return onBoardingResult.getOrDefault(OnBoardingFlow.getDefaultInstance())
+ }
+}
diff --git a/app/src/main/res/layout/on_boarding_activity.xml b/app/src/main/res/layout/on_boarding_activity.xml
new file mode 100644
index 00000000000..031ce91cab2
--- /dev/null
+++ b/app/src/main/res/layout/on_boarding_activity.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt
index fbe2bca39fd..50b39e629f4 100644
--- a/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt
+++ b/app/src/sharedTest/java/org/oppia/app/splash/SplashActivityTest.kt
@@ -1,16 +1,45 @@
package org.oppia.app.splash
+import android.app.Application
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import androidx.test.core.app.ActivityScenario.launch
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.espresso.Espresso.onIdle
+import androidx.test.espresso.IdlingRegistry
+import androidx.test.espresso.idling.CountingIdlingResource
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.test.TestCoroutineDispatcher
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import org.oppia.app.onboarding.OnBoardingActivity
import org.oppia.app.profile.ProfileActivity
+import org.oppia.domain.OnBoardingFlowController
+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 java.util.concurrent.AbstractExecutorService
+import java.util.concurrent.TimeUnit
+import javax.inject.Qualifier
+import javax.inject.Singleton
/**
* Tests for [SplashActivity]. For context on the activity test rule setup see:
@@ -18,6 +47,18 @@ import org.oppia.app.profile.ProfileActivity
*/
@RunWith(AndroidJUnit4::class)
class SplashActivityTest {
+ @Before
+ fun setUp() {
+ Intents.init()
+ IdlingRegistry.getInstance().register(MainThreadExecutor.countingResource)
+ simulateNewAppInstance()
+ }
+
+ @After
+ fun tearDown() {
+ IdlingRegistry.getInstance().unregister(MainThreadExecutor.countingResource)
+ Intents.release()
+ }
// The initialTouchMode enables the activity to be launched in touch mode. The launchActivity is
// disabled to launch Activity explicitly within each test case.
@@ -26,19 +67,140 @@ class SplashActivityTest {
SplashActivity::class.java, /* initialTouchMode= */ true, /* launchActivity= */ false
)
- @Before
- fun setUp() {
- Intents.init()
+ @Test
+ fun testSplashActivity_initialOpen_routesToOnBoardingActivity() {
+ activityTestRule.launchActivity(null)
+ intended(hasComponent(OnBoardingActivity::class.java.name))
}
@Test
- fun testSplashActivity_initialOpen_routesToHomeActivity() {
- activityTestRule.launchActivity(null)
- intended(hasComponent(ProfileActivity::class.java.name))
+ fun testSplashActivity_secondOpen_routesToChooseProfileActivity() {
+ simulateAppAlreadyOnBoarded()
+ launch(SplashActivity::class.java).use {
+ intended(hasComponent(ProfileActivity::class.java.name))
+ }
}
- @After
- fun tearDown() {
- Intents.release()
+ private fun simulateNewAppInstance() {
+ // Simulate a fresh app install by clearing any potential on-disk caches using an isolated on-boarding flow controller.
+ createTestRootComponent().getOnBoardingFlowController().clearOnBoardingFlow()
+ onIdle()
+ }
+
+ private fun simulateAppAlreadyOnBoarded() {
+ // Simulate the app was already on-boarded by creating an isolated on-boarding flow controller and saving the on-boarding status
+ // on the system before the activity is opened.
+ createTestRootComponent().getOnBoardingFlowController().markOnBoardingFlowCompleted()
+ onIdle()
+ }
+
+ private fun createTestRootComponent(): TestApplicationComponent {
+ return DaggerSplashActivityTest_TestApplicationComponent.builder()
+ .setApplication(ApplicationProvider.getApplicationContext())
+ .build()
+ }
+
+ @Qualifier
+ annotation class TestDispatcher
+
+ @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
+ }
+
+ // 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
+
+ @Singleton
+ @Provides
+ @BlockingDispatcher
+ fun provideBlockingDispatcher(): CoroutineDispatcher {
+ return MainThreadExecutor.asCoroutineDispatcher()
+ }
+ }
+
+ @Singleton
+ @Component(modules = [TestModule::class])
+ interface TestApplicationComponent {
+ @Component.Builder
+ interface Builder {
+ @BindsInstance
+ fun setApplication(application: Application): Builder
+
+ fun build(): TestApplicationComponent
+ }
+
+ fun getOnBoardingFlowController(): OnBoardingFlowController
+ fun inject(splashActivityTest: SplashActivityTest)
+ }
+
+ // TODO(#59): Move this to a general-purpose testing library that replaces all CoroutineExecutors with an
+ // Espresso-enabled executor service. This service should also allow for background threads to run in both Espresso
+ // and Robolectric to help catch potential race conditions, rather than forcing parallel execution to be sequential
+ // and immediate.
+ // NB: This also blocks on #59 to be able to actually create a test-only library.
+ /**
+ * An executor service that schedules all [Runnable]s to run asynchronously on the main thread. This is based on:
+ * https://android.googlesource.com/platform/packages/apps/TV/+/android-live-tv/src/com/android/tv/util/MainThreadExecutor.java.
+ */
+ private object MainThreadExecutor : AbstractExecutorService() {
+ override fun isTerminated(): Boolean = false
+
+ private val handler = Handler(Looper.getMainLooper())
+ val countingResource = CountingIdlingResource("main_thread_executor_counting_idling_resource")
+
+ override fun execute(command: Runnable?) {
+ countingResource.increment()
+ handler.post {
+ try {
+ command?.run()
+ } finally {
+ countingResource.decrement()
+ }
+ }
+ }
+
+ override fun shutdown() {
+ throw UnsupportedOperationException()
+ }
+
+ override fun shutdownNow(): MutableList {
+ throw UnsupportedOperationException()
+ }
+
+ override fun isShutdown(): Boolean = false
+
+ override fun awaitTermination(timeout: Long, unit: TimeUnit?): Boolean {
+ throw UnsupportedOperationException()
+ }
}
}
diff --git a/domain/src/main/java/org/oppia/domain/OnBoardingFlowController.kt b/domain/src/main/java/org/oppia/domain/OnBoardingFlowController.kt
new file mode 100644
index 00000000000..ec24a6db6c7
--- /dev/null
+++ b/domain/src/main/java/org/oppia/domain/OnBoardingFlowController.kt
@@ -0,0 +1,61 @@
+package org.oppia.domain
+
+import androidx.lifecycle.LiveData
+import org.oppia.app.model.OnBoardingFlow
+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 user on-boarding information of the app. */
+@Singleton
+class OnBoardingFlowController @Inject constructor(
+ cacheStoreFactory: PersistentCacheStore.Factory,
+ private val dataProviders: DataProviders,
+ private val logger: Logger
+) {
+ private val onBoardingFlowStore = cacheStoreFactory.create("on_boarding_flow", OnBoardingFlow.getDefaultInstance())
+
+ init {
+ // Prime the cache ahead of time so that any existing history is read prior to any calls to markOnBoardingFlowCompleted().
+ onBoardingFlowStore.primeCacheAsync().invokeOnCompletion {
+ it?.let {
+ logger.e("DOMAIN", "Failed to prime cache ahead of LiveData conversion for user on-boarding data.", it)
+ }
+ }
+ }
+
+ /**
+ * Saves that the user has completed on-boarding the app. Note that this does not notify existing subscribers of the changed state,
+ * nor can future subscribers observe this state until app restart.
+ */
+ fun markOnBoardingFlowCompleted() {
+ onBoardingFlowStore.storeDataAsync(updateInMemoryCache = false) {
+ it.toBuilder().setAlreadyOnBoardedApp(true).build()
+ }.invokeOnCompletion {
+ it?.let {
+ logger.e("DOMAIN", "Failed when storing that the user already on-boarded the app.", it)
+ }
+ }
+ }
+
+ /** Clears any indication that the user has previously completed on-boarding the application. */
+ fun clearOnBoardingFlow() {
+ onBoardingFlowStore.clearCacheAsync().invokeOnCompletion {
+ it?.let {
+ logger.e("DOMAIN", "Failed to clear onBoarding flow.", it)
+ }
+ }
+ }
+
+ /**
+ * Returns a [LiveData] result indicating whether the user has on-boarded the app. This is guaranteed to
+ * provide the state of the store upon the creation of this controller even if [markOnBoardingFlowCompleted] has since been
+ * called.
+ */
+ fun getOnBoardingFlow(): LiveData> {
+ return dataProviders.convertToLiveData(onBoardingFlowStore)
+ }
+}
diff --git a/domain/src/test/java/org/oppia/domain/OnboardingFlowControllerTest.kt b/domain/src/test/java/org/oppia/domain/OnboardingFlowControllerTest.kt
new file mode 100644
index 00000000000..60f25b2557e
--- /dev/null
+++ b/domain/src/test/java/org/oppia/domain/OnboardingFlowControllerTest.kt
@@ -0,0 +1,233 @@
+package org.oppia.domain
+
+import android.app.Application
+import android.content.Context
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+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.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.ObsoleteCoroutinesApi
+import kotlinx.coroutines.newSingleThreadContext
+import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+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.OnBoardingFlow
+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 [OnBoardingFlowController]. */
+@RunWith(AndroidJUnit4::class)
+@Config(manifest = Config.NONE)
+class OnBoardingFlowControllerTest {
+ @Rule
+ @JvmField
+ val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Rule
+ @JvmField
+ val executorRule = InstantTaskExecutorRule()
+
+ @Inject lateinit var onBoardingFlowController: OnBoardingFlowController
+
+ @Inject
+ @field:TestDispatcher
+ lateinit var testDispatcher: CoroutineDispatcher
+
+ private val coroutineContext by lazy {
+ EmptyCoroutineContext + testDispatcher
+ }
+
+ @Mock
+ lateinit var mockOnBoardingObserver: Observer>
+
+ @Captor
+ lateinit var onBoardingResultCaptor: ArgumentCaptor>
+
+ // https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/
+ @ObsoleteCoroutinesApi
+ private val testThread = newSingleThreadContext("TestMain")
+
+ @Before
+ @ExperimentalCoroutinesApi
+ @ObsoleteCoroutinesApi
+ fun setUp() {
+ Dispatchers.setMain(testThread)
+ setUpTestApplicationComponent()
+ }
+
+ @After
+ @ExperimentalCoroutinesApi
+ @ObsoleteCoroutinesApi
+ fun tearDown() {
+ Dispatchers.resetMain()
+ testThread.close()
+ }
+
+ private fun setUpTestApplicationComponent() {
+ DaggerOnBoardingFlowControllerTest_TestApplicationComponent.builder()
+ .setApplication(ApplicationProvider.getApplicationContext())
+ .build()
+ .inject(this)
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_providesInitialLiveData_thatIndicatesUserHasNotOnBoardedTheApp() = runBlockingTest(coroutineContext) {
+ val onBoarding = onBoardingFlowController.getOnBoardingFlow()
+ advanceUntilIdle()
+ onBoarding.observeForever(mockOnBoardingObserver)
+
+ verify(mockOnBoardingObserver, atLeastOnce()).onChanged(onBoardingResultCaptor.capture())
+ assertThat(onBoardingResultCaptor.value.isSuccess()).isTrue()
+ assertThat(onBoardingResultCaptor.value.getOrThrow().alreadyOnBoardedApp).isFalse()
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testControllerObserver_observedAfterSettingAppOnBoarded_providesLiveData_userDidNotOnBoardApp() =
+ runBlockingTest(coroutineContext) {
+ val onBoarding = onBoardingFlowController.getOnBoardingFlow()
+
+ onBoarding.observeForever(mockOnBoardingObserver)
+ onBoardingFlowController.markOnBoardingFlowCompleted()
+ advanceUntilIdle()
+
+ // The result should not indicate that the user on-boarded the app because markUserOnBoardedApp does not notify observers
+ // of the change.
+ verify(mockOnBoardingObserver, atLeastOnce()).onChanged(onBoardingResultCaptor.capture())
+ assertThat(onBoardingResultCaptor.value.isSuccess()).isTrue()
+ assertThat(onBoardingResultCaptor.value.getOrThrow().alreadyOnBoardedApp).isFalse()
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_settingAppOnBoarded_observedNewController_userOnBoardedApp() = runBlockingTest(coroutineContext) {
+ onBoardingFlowController.markOnBoardingFlowCompleted()
+ advanceUntilIdle()
+
+ // Create the controller by creating another singleton graph and injecting it (simulating the app being recreated).
+ setUpTestApplicationComponent()
+ val onBoarding = onBoardingFlowController.getOnBoardingFlow()
+ onBoarding.observeForever(mockOnBoardingObserver)
+ advanceUntilIdle()
+
+ // The app should be considered on-boarded since a new LiveData instance was observed after marking the app as on-boarded.
+ verify(mockOnBoardingObserver, atLeastOnce()).onChanged(onBoardingResultCaptor.capture())
+ assertThat(onBoardingResultCaptor.value.isSuccess()).isTrue()
+ assertThat(onBoardingResultCaptor.value.getOrThrow().alreadyOnBoardedApp).isTrue()
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_onBoardedApp_cleared_observeNewController_userDidNotOnBoardApp() = runBlockingTest(coroutineContext) {
+ onBoardingFlowController.markOnBoardingFlowCompleted()
+ advanceUntilIdle()
+
+ // Clear, then recreate another controller.
+ onBoardingFlowController.clearOnBoardingFlow()
+ setUpTestApplicationComponent()
+ val onBoarding = onBoardingFlowController.getOnBoardingFlow()
+ onBoarding.observeForever(mockOnBoardingObserver)
+ advanceUntilIdle()
+
+ // The app should be considered not yet on-boarded since the previous history was cleared.
+ verify(mockOnBoardingObserver, atLeastOnce()).onChanged(onBoardingResultCaptor.capture())
+ assertThat(onBoardingResultCaptor.value.isSuccess()).isTrue()
+ assertThat(onBoardingResultCaptor.value.getOrThrow().alreadyOnBoardedApp).isFalse()
+ }
+
+ @Qualifier
+ annotation class TestDispatcher
+
+ // 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(@TestDispatcher testDispatcher: CoroutineDispatcher): CoroutineDispatcher {
+ return testDispatcher
+ }
+
+ // 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(onBoardingFlowControllerTest: OnBoardingFlowControllerTest)
+ }
+}
diff --git a/model/src/main/proto/example.proto b/model/src/main/proto/example.proto
index 0c68a3d68b4..ae1189b08f2 100644
--- a/model/src/main/proto/example.proto
+++ b/model/src/main/proto/example.proto
@@ -9,6 +9,10 @@ message UserAppHistory {
bool already_opened_app = 1;
}
+message OnBoardingFlow {
+ bool already_on_boarded_app = 1;
+}
+
message TestMessage {
int32 version = 1;
}