diff --git a/.gitignore b/.gitignore
index c26873b6b36..4eb2f7645e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,6 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
app/build
-data/build
domain/build
model/build
utility/build
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index ef06a053e64..c26bd1cd7a5 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -13,13 +13,7 @@
-
-
-
-
-
-
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index d456ac543b2..80feec202be 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,7 +9,6 @@
-
diff --git a/app/build.gradle b/app/build.gradle
index 1f1db6277fe..ccc495cd449 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,27 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
-apply plugin: 'kotlin-kapt'
android {
- compileSdkVersion 28
+ compileSdkVersion 29
buildToolsVersion "29.0.1"
defaultConfig {
applicationId "org.oppia.app"
- minSdkVersion 19
+ minSdkVersion 16
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- // https://developer.android.com/training/testing/junit-runner#ato-gradle
- testInstrumentationRunnerArguments clearPackageData: 'true'
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_1_8
}
buildTypes {
release {
@@ -33,8 +23,6 @@ android {
enabled = true
}
testOptions {
- // https://developer.android.com/training/testing/junit-runner#ato-gradle
- execution 'ANDROIDX_TEST_ORCHESTRATOR'
unitTests {
includeAndroidResources = true
}
@@ -61,7 +49,6 @@ dependencies {
'androidx.constraintlayout:constraintlayout:1.1.3',
'androidx.core:core-ktx:1.0.2',
'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03',
- 'com.google.dagger:dagger:2.24',
"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version",
)
testImplementation(
@@ -78,18 +65,6 @@ dependencies {
'androidx.test:runner:1.2.0',
'com.google.truth:truth:0.43',
)
- androidTestUtil(
- 'androidx.test:orchestrator:1.2.0',
- )
- kapt(
- 'com.google.dagger:dagger-compiler:2.24'
- )
- kaptTest(
- 'com.google.dagger:dagger-compiler:2.24'
- )
- kaptAndroidTest(
- 'com.google.dagger:dagger-compiler:2.24'
- )
implementation project(":model")
implementation project(":domain")
implementation project(":utility")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a25c9ab2754..a89ec503898 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,14 +4,13 @@
package="org.oppia.app">
-
+
diff --git a/app/src/main/java/org/oppia/app/HomeActivity.kt b/app/src/main/java/org/oppia/app/HomeActivity.kt
new file mode 100644
index 00000000000..0fda85beb37
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/HomeActivity.kt
@@ -0,0 +1,14 @@
+package org.oppia.app
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+
+/** The central activity for all users entering the app. */
+class HomeActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.home_activity)
+ supportFragmentManager.beginTransaction().add(R.id.home_fragment_placeholder, HomeFragment()).commitNow()
+ }
+}
diff --git a/app/src/main/java/org/oppia/app/HomeFragment.kt b/app/src/main/java/org/oppia/app/HomeFragment.kt
new file mode 100644
index 00000000000..87ccfa40df1
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/HomeFragment.kt
@@ -0,0 +1,55 @@
+package org.oppia.app
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
+import androidx.lifecycle.ViewModelProviders
+import org.oppia.app.databinding.HomeFragmentBinding
+import org.oppia.app.model.UserAppHistory
+import org.oppia.domain.UserAppHistoryController
+import org.oppia.util.data.AsyncResult
+
+/** Fragment that contains an introduction to the app. */
+class HomeFragment : Fragment() {
+ private val userAppHistoryController = UserAppHistoryController()
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ val binding = HomeFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+ val viewModel = getUserAppHistoryViewModel()
+ val appUserHistory = getUserAppHistory()
+ viewModel.userAppHistoryLiveData = appUserHistory
+ // NB: Both the view model and lifecycle owner must be set in order to correctly bind LiveData elements to
+ // data-bound view models.
+ binding.let {
+ it.viewModel = viewModel
+ it.lifecycleOwner = this
+ }
+
+ // TODO(#70): Mark that the user opened the app once it's persisted to disk.
+
+ return binding.root
+ }
+
+ private fun getUserAppHistoryViewModel(): UserAppHistoryViewModel {
+ return ViewModelProviders.of(this).get(UserAppHistoryViewModel::class.java)
+ }
+
+ private fun getUserAppHistory(): LiveData {
+ // If there's an error loading the data, assume the default.
+ return Transformations.map(
+ userAppHistoryController.getUserAppHistory()
+ ) { result: AsyncResult -> processUserAppHistoryResult(result) }
+ }
+
+ private fun processUserAppHistoryResult(appHistoryResult: AsyncResult): UserAppHistory {
+ if (appHistoryResult.isFailure()) {
+ Log.e("HomeFragment", "Failed to retrieve user app history", appHistoryResult.error)
+ }
+ return appHistoryResult.getOrDefault(UserAppHistory.getDefaultInstance())
+ }
+}
diff --git a/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt b/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt
new file mode 100644
index 00000000000..80232d34dbd
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt
@@ -0,0 +1,10 @@
+package org.oppia.app
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import org.oppia.app.model.UserAppHistory
+
+/** [ViewModel] for user app usage history. */
+class UserAppHistoryViewModel: ViewModel() {
+ var userAppHistoryLiveData: LiveData? = null
+}
diff --git a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt
deleted file mode 100644
index 067c44381bb..00000000000
--- a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.oppia.app.activity
-
-import androidx.appcompat.app.AppCompatActivity
-import dagger.BindsInstance
-import dagger.Subcomponent
-import org.oppia.app.fragment.FragmentComponent
-import org.oppia.app.home.HomeActivity
-import javax.inject.Provider
-
-/** Root subcomponent for all activities. */
-@Subcomponent(modules = [ActivityModule::class])
-@ActivityScope
-interface ActivityComponent {
- @Subcomponent.Builder
- interface Builder {
- @BindsInstance fun setActivity(appCompatActivity: AppCompatActivity): Builder
- fun build(): ActivityComponent
- }
-
- fun getFragmentComponentBuilderProvider(): Provider
-
- fun inject(homeActivity: HomeActivity)
-}
diff --git a/app/src/main/java/org/oppia/app/activity/ActivityModule.kt b/app/src/main/java/org/oppia/app/activity/ActivityModule.kt
deleted file mode 100644
index 87eeb2d2215..00000000000
--- a/app/src/main/java/org/oppia/app/activity/ActivityModule.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.oppia.app.activity
-
-import dagger.Module
-import org.oppia.app.fragment.FragmentComponent
-
-/** Root activity module. */
-@Module(subcomponents = [FragmentComponent::class]) class ActivityModule
diff --git a/app/src/main/java/org/oppia/app/activity/ActivityScope.kt b/app/src/main/java/org/oppia/app/activity/ActivityScope.kt
deleted file mode 100644
index 47d22227c5b..00000000000
--- a/app/src/main/java/org/oppia/app/activity/ActivityScope.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.oppia.app.activity
-
-import javax.inject.Scope
-
-/** A custom scope corresponding to dependencies that should be recreated for each activity. */
-@Scope annotation class ActivityScope
diff --git a/app/src/main/java/org/oppia/app/activity/InjectableAppCompatActivity.kt b/app/src/main/java/org/oppia/app/activity/InjectableAppCompatActivity.kt
deleted file mode 100644
index 03797994c77..00000000000
--- a/app/src/main/java/org/oppia/app/activity/InjectableAppCompatActivity.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package org.oppia.app.activity
-
-import android.os.Bundle
-import android.os.PersistableBundle
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import org.oppia.app.application.OppiaApplication
-import org.oppia.app.fragment.FragmentComponent
-
-/**
- * An [AppCompatActivity] that facilitates field injection to child activities and constituent fragments that extend
- * [org.oppia.app.fragment.InjectableFragment].
- */
-abstract class InjectableAppCompatActivity: AppCompatActivity() {
- /**
- * The [ActivityComponent] corresponding to this activity. This cannot be used before [onCreate] is called, and can be
- * used to inject lateinit fields in child activities during activity creation (which is recommended to be done in an
- * override of [onCreate]).
- */
- lateinit var activityComponent: ActivityComponent
-
- override fun onCreate(savedInstanceState: Bundle?) {
- // Note that the activity component must be initialized before onCreate() since it's possible for onCreate() to
- // synchronously attach fragments (e.g. during a configuration change), which requires the activity component for
- // createFragmentComponent(). This means downstream dependencies should not perform any major operations to the
- // injected activity since it's not yet fully created.
- initializeActivityComponent()
- super.onCreate(savedInstanceState)
- }
-
- override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
- super.onCreate(savedInstanceState, persistentState)
- initializeActivityComponent()
- }
-
- private fun initializeActivityComponent() {
- activityComponent = (application as OppiaApplication).createActivityComponent(this)
- }
-
- fun createFragmentComponent(fragment: Fragment): FragmentComponent {
- return activityComponent.getFragmentComponentBuilderProvider().get().setFragment(fragment).build()
- }
-}
diff --git a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt
deleted file mode 100644
index 745e69d7ec0..00000000000
--- a/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.oppia.app.application
-
-import android.app.Application
-import dagger.BindsInstance
-import dagger.Component
-import org.oppia.app.activity.ActivityComponent
-import org.oppia.util.threading.DispatcherModule
-import javax.inject.Provider
-import javax.inject.Singleton
-
-/** Root Dagger component for the application. All application-scoped modules should be included in this component. */
-@Singleton
-@Component(modules = [ApplicationModule::class, DispatcherModule::class])
-interface ApplicationComponent {
- @Component.Builder
- interface Builder {
- @BindsInstance fun setApplication(application: Application): Builder
- fun build(): ApplicationComponent
- }
-
- fun getActivityComponentBuilderProvider(): Provider
-}
diff --git a/app/src/main/java/org/oppia/app/application/ApplicationContext.kt b/app/src/main/java/org/oppia/app/application/ApplicationContext.kt
deleted file mode 100644
index cd3f7fec258..00000000000
--- a/app/src/main/java/org/oppia/app/application/ApplicationContext.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.oppia.app.application
-
-import javax.inject.Qualifier
-
-/** Qualifier for injecting the application context. */
-@Qualifier annotation class ApplicationContext
diff --git a/app/src/main/java/org/oppia/app/application/ApplicationModule.kt b/app/src/main/java/org/oppia/app/application/ApplicationModule.kt
deleted file mode 100644
index c2ae637e845..00000000000
--- a/app/src/main/java/org/oppia/app/application/ApplicationModule.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.oppia.app.application
-
-import android.app.Application
-import android.content.Context
-import dagger.Module
-import dagger.Provides
-import org.oppia.app.activity.ActivityComponent
-import javax.inject.Singleton
-
-/** Provides core infrastructure needed to support all other dependencies in the app. */
-@Module(subcomponents = [ActivityComponent::class])
-class ApplicationModule {
- @Provides
- @Singleton
- @ApplicationContext
- fun provideApplicationContext(application: Application): Context {
- return application
- }
-
- // TODO(#59): Remove this provider once all modules have access to the @ApplicationContext qualifier.
- @Provides
- @Singleton
- fun provideContext(@ApplicationContext context: Context): Context {
- return context
- }
-}
diff --git a/app/src/main/java/org/oppia/app/application/OppiaApplication.kt b/app/src/main/java/org/oppia/app/application/OppiaApplication.kt
deleted file mode 100644
index 30d83017884..00000000000
--- a/app/src/main/java/org/oppia/app/application/OppiaApplication.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.oppia.app.application
-
-import android.app.Application
-import androidx.appcompat.app.AppCompatActivity
-import org.oppia.app.activity.ActivityComponent
-
-/** The root [Application] of the Oppia app. */
-class OppiaApplication: Application() {
- /** The root [ApplicationComponent]. */
- private val component: ApplicationComponent by lazy {
- DaggerApplicationComponent.builder()
- .setApplication(this)
- .build()
- }
-
- /**
- * Returns a new [ActivityComponent] for the specified activity. This should only be used by
- * [org.oppia.app.activity.InjectableAppCompatActivity].
- */
- fun createActivityComponent(activity: AppCompatActivity): ActivityComponent {
- return component.getActivityComponentBuilderProvider().get().setActivity(activity).build()
- }
-}
diff --git a/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt b/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt
deleted file mode 100644
index d56fdf0c26c..00000000000
--- a/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.oppia.app.fragment
-
-import androidx.fragment.app.Fragment
-import dagger.BindsInstance
-import dagger.Subcomponent
-import org.oppia.app.home.HomeFragment
-
-/** Root subcomponent for all fragments. */
-@Subcomponent
-@FragmentScope
-interface FragmentComponent {
- @Subcomponent.Builder
- interface Builder {
- @BindsInstance fun setFragment(fragment: Fragment): Builder
- fun build(): FragmentComponent
- }
-
- fun inject(homeFragment: HomeFragment)
-}
diff --git a/app/src/main/java/org/oppia/app/fragment/FragmentScope.kt b/app/src/main/java/org/oppia/app/fragment/FragmentScope.kt
deleted file mode 100644
index ad58085be05..00000000000
--- a/app/src/main/java/org/oppia/app/fragment/FragmentScope.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.oppia.app.fragment
-
-import javax.inject.Scope
-
-/** A custom scope corresponding to dependencies that should be recreated for each fragment. */
-@Scope annotation class FragmentScope
diff --git a/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt b/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt
deleted file mode 100644
index 25c3628f07a..00000000000
--- a/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.oppia.app.fragment
-
-import android.content.Context
-import androidx.fragment.app.Fragment
-import org.oppia.app.activity.InjectableAppCompatActivity
-
-/**
- * A fragment that facilitates field injection to children. This fragment can only be used with
- * [InjectableAppCompatActivity] contexts.
- */
-abstract class InjectableFragment: Fragment() {
- /**
- * The [FragmentComponent] corresponding to this fragment. This cannot be used before [onAttach] is called, and can be
- * used to inject lateinit fields in child fragments during fragment attachment (which is recommended to be done in an
- * override of [onAttach]).
- */
- lateinit var fragmentComponent: FragmentComponent
-
- override fun onAttach(context: Context?) {
- super.onAttach(context)
- fragmentComponent = (requireActivity() as InjectableAppCompatActivity).createFragmentComponent(this)
- }
-}
diff --git a/app/src/main/java/org/oppia/app/home/HomeActivity.kt b/app/src/main/java/org/oppia/app/home/HomeActivity.kt
deleted file mode 100644
index d5fe94ca133..00000000000
--- a/app/src/main/java/org/oppia/app/home/HomeActivity.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.oppia.app.home
-
-import android.os.Bundle
-import org.oppia.app.activity.InjectableAppCompatActivity
-import javax.inject.Inject
-
-/** The central activity for all users entering the app. */
-class HomeActivity : InjectableAppCompatActivity() {
- @Inject lateinit var homeActivityController: HomeActivityController
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- activityComponent.inject(this)
- homeActivityController.handleOnCreate()
- }
-}
diff --git a/app/src/main/java/org/oppia/app/home/HomeActivityController.kt b/app/src/main/java/org/oppia/app/home/HomeActivityController.kt
deleted file mode 100644
index 9fd3b0e7f81..00000000000
--- a/app/src/main/java/org/oppia/app/home/HomeActivityController.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.oppia.app.home
-
-import androidx.appcompat.app.AppCompatActivity
-import org.oppia.app.R
-import org.oppia.app.activity.ActivityScope
-import javax.inject.Inject
-
-/** The controller for [HomeActivity]. */
-@ActivityScope
-class HomeActivityController @Inject constructor(private val activity: AppCompatActivity) {
- fun handleOnCreate() {
- activity.setContentView(R.layout.home_activity)
- activity.supportFragmentManager.beginTransaction().add(
- R.id.home_fragment_placeholder,
- HomeFragment()
- ).commitNow()
- }
-}
diff --git a/app/src/main/java/org/oppia/app/home/HomeFragment.kt b/app/src/main/java/org/oppia/app/home/HomeFragment.kt
deleted file mode 100644
index e3bc454853f..00000000000
--- a/app/src/main/java/org/oppia/app/home/HomeFragment.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.oppia.app.home
-
-import android.content.Context
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import org.oppia.app.fragment.InjectableFragment
-import javax.inject.Inject
-
-/** Fragment that contains an introduction to the app. */
-class HomeFragment : InjectableFragment() {
- @Inject lateinit var homeFragmentController: HomeFragmentController
-
- override fun onAttach(context: Context?) {
- super.onAttach(context)
- fragmentComponent.inject(this)
- }
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
- return homeFragmentController.handleCreateView(inflater, container)
- }
-}
diff --git a/app/src/main/java/org/oppia/app/home/HomeFragmentController.kt b/app/src/main/java/org/oppia/app/home/HomeFragmentController.kt
deleted file mode 100644
index d8f19de8685..00000000000
--- a/app/src/main/java/org/oppia/app/home/HomeFragmentController.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.oppia.app.home
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.fragment.app.Fragment
-import org.oppia.app.databinding.HomeFragmentBinding
-import org.oppia.app.fragment.FragmentScope
-import org.oppia.app.viewmodel.ViewModelProvider
-import org.oppia.domain.UserAppHistoryController
-import javax.inject.Inject
-
-/** The controller for [HomeFragment]. */
-@FragmentScope
-class HomeFragmentController @Inject constructor(
- private val fragment: Fragment,
- private val viewModelProvider: ViewModelProvider,
- private val userAppHistoryController: UserAppHistoryController
-) {
- fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? {
- val binding = HomeFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
- // NB: Both the view model and lifecycle owner must be set in order to correctly bind LiveData elements to
- // data-bound view models.
- binding.let {
- it.viewModel = getUserAppHistoryViewModel()
- it.lifecycleOwner = fragment
- }
-
- userAppHistoryController.markUserOpenedApp()
-
- return binding.root
- }
-
- private fun getUserAppHistoryViewModel(): UserAppHistoryViewModel {
- return viewModelProvider.getForFragment(fragment, UserAppHistoryViewModel::class.java)
- }
-}
diff --git a/app/src/main/java/org/oppia/app/home/UserAppHistoryViewModel.kt b/app/src/main/java/org/oppia/app/home/UserAppHistoryViewModel.kt
deleted file mode 100644
index 8e9b614fd79..00000000000
--- a/app/src/main/java/org/oppia/app/home/UserAppHistoryViewModel.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package org.oppia.app.home
-
-import android.util.Log
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.Transformations
-import androidx.lifecycle.ViewModel
-import org.oppia.app.fragment.FragmentScope
-import org.oppia.app.model.UserAppHistory
-import org.oppia.domain.UserAppHistoryController
-import org.oppia.util.data.AsyncResult
-import javax.inject.Inject
-
-/** [ViewModel] for user app usage history. */
-@FragmentScope
-class UserAppHistoryViewModel @Inject constructor(
- private val userAppHistoryController: UserAppHistoryController
-): ViewModel() {
- val userAppHistoryLiveData: LiveData? by lazy {
- getUserAppHistory()
- }
-
- private fun getUserAppHistory(): LiveData? {
- // If there's an error loading the data, assume the default.
- return Transformations.map(userAppHistoryController.getUserAppHistory(), ::processUserAppHistoryResult)
- }
-
- private fun processUserAppHistoryResult(appHistoryResult: AsyncResult): UserAppHistory {
- if (appHistoryResult.isFailure()) {
- Log.e("HomeFragment", "Failed to retrieve user app history", appHistoryResult.getErrorOrNull())
- }
- return appHistoryResult.getOrDefault(UserAppHistory.getDefaultInstance())
- }
-}
diff --git a/app/src/main/java/org/oppia/app/viewmodel/ViewModelBridgeFactory.kt b/app/src/main/java/org/oppia/app/viewmodel/ViewModelBridgeFactory.kt
deleted file mode 100644
index ed6c2ede81b..00000000000
--- a/app/src/main/java/org/oppia/app/viewmodel/ViewModelBridgeFactory.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package org.oppia.app.viewmodel
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import javax.inject.Inject
-import javax.inject.Provider
-
-/**
- * Provides a Dagger bridge to facilitate [ViewModel]s supporting @Inject constructors. Adapted from:
- * https://proandroiddev.com/dagger-2-on-android-the-simple-way-f706a2c597e9 and
- * https://github.com/tfcporciuncula/dagger-simple-way.
- */
-class ViewModelBridgeFactory @Inject constructor(
- private val viewModelProvider: Provider
-): ViewModelProvider.Factory {
- override fun create(modelClass: Class): T {
- val viewModel = viewModelProvider.get()
- // Check whether the user accidentally switched the types during provider retrieval. ViewModelProvider is meant to
- // guard against this from happening by ensuring the two types remain the same.
- check(modelClass.isAssignableFrom(viewModel.javaClass)) {
- "Cannot convert between injected generic type and runtime assumed generic type for bridge factory."
- }
- // Ensure the compiler that the type casting is correct and intentional here. A cast failure should result in a
- // runtime crash.
- return modelClass.cast(viewModel)!!
- }
-}
diff --git a/app/src/main/java/org/oppia/app/viewmodel/ViewModelProvider.kt b/app/src/main/java/org/oppia/app/viewmodel/ViewModelProvider.kt
deleted file mode 100644
index d8dbc644bfa..00000000000
--- a/app/src/main/java/org/oppia/app/viewmodel/ViewModelProvider.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.oppia.app.viewmodel
-
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProviders
-import javax.inject.Inject
-
-/**
- * Provider for a specific type of [ViewModel] that supports @Inject construction. This class is automatically bound to
- * the narrowest scope and component in which it's used.
- */
-class ViewModelProvider @Inject constructor(private val bridgeFactory: ViewModelBridgeFactory) {
- /** Retrieves a new instance of the [ViewModel] of type [V] scoped to the specified fragment. */
- fun getForFragment(fragment: Fragment, clazz: Class): V {
- return ViewModelProviders.of(fragment, bridgeFactory).get(clazz)
- }
-}
diff --git a/app/src/main/res/layout/home_activity.xml b/app/src/main/res/layout/home_activity.xml
index 86fdf2ff0a0..eb47e32defe 100644
--- a/app/src/main/res/layout/home_activity.xml
+++ b/app/src/main/res/layout/home_activity.xml
@@ -5,4 +5,4 @@
android:id="@+id/home_fragment_placeholder"
android:layout_width="match_parent"
android:layout_height="match_parent"
- tools:context=".home.HomeActivity" />
+ tools:context=".HomeActivity" />
diff --git a/app/src/main/res/layout/home_fragment.xml b/app/src/main/res/layout/home_fragment.xml
index 45e60f50205..285d570bdbb 100644
--- a/app/src/main/res/layout/home_fragment.xml
+++ b/app/src/main/res/layout/home_fragment.xml
@@ -7,7 +7,7 @@
+ type="org.oppia.app.UserAppHistoryViewModel"/>
): ViewInteraction {
- return onView(isRoot()).perform(waitForMatch(viewMatcher, 30000L))
- }
-
- // TODO(#59): Remove these waits once we can ensure that the production executors are not depended on in tests.
- // Sleeping is really bad practice in Espresso tests, and can lead to test flakiness. It shouldn't be necessary if we
- // use a test executor service with a counting idle resource, but right now Gradle mixes dependencies such that both
- // the test and production blocking executors are being used. The latter cannot be updated to notify Espresso of any
- // active coroutines, so the test attempts to assert state before it's ready. This artificial delay in the Espresso
- // thread helps to counter that.
- /**
- * Perform action of waiting for a specific matcher to finish. Adapted from:
- * https://stackoverflow.com/a/22563297/3689782.
- */
- private fun waitForMatch(viewMatcher: Matcher, millis: Long): ViewAction {
- return object : ViewAction {
- override fun getDescription(): String {
- return "wait for a specific view with matcher <$viewMatcher> during $millis millis."
- }
-
- override fun getConstraints(): Matcher {
- return isRoot()
- }
-
- override fun perform(uiController: UiController?, view: View?) {
- checkNotNull(uiController)
- uiController.loopMainThreadUntilIdle()
- val startTime = System.currentTimeMillis()
- val endTime = startTime + millis
-
- do {
- if (TreeIterables.breadthFirstViewTraversal(view).any { viewMatcher.matches(it) }) {
- return
- }
- uiController.loopMainThreadForAtLeast(50)
- } while (System.currentTimeMillis() < endTime)
-
- // Couldn't match in time.
- throw PerformException.Builder()
- .withActionDescription(description)
- .withViewDescription(HumanReadables.describe(view))
- .withCause(TimeoutException())
- .build()
- }
- }
- }
-
- @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
- @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 getUserAppHistoryController(): UserAppHistoryController
- }
-
- // 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/data/build.gradle b/data/build.gradle
deleted file mode 100644
index 7d1ae604563..00000000000
--- a/data/build.gradle
+++ /dev/null
@@ -1,62 +0,0 @@
-apply plugin: 'com.android.library'
-apply plugin: 'kotlin-android'
-apply plugin: 'kotlin-android-extensions'
-apply plugin: 'kotlin-kapt'
-
-android {
- compileSdkVersion 28
- buildToolsVersion "29.0.1"
-
- defaultConfig {
- minSdkVersion 19
- targetSdkVersion 28
- versionCode 1
- versionName "1.0"
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_1_8
- }
-
- testOptions {
- unitTests {
- includeAndroidResources = true
- }
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
-}
-
-dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar'])
- implementation(
- 'androidx.appcompat:appcompat:1.0.2',
- 'com.google.dagger:dagger:2.24',
- 'com.google.protobuf:protobuf-lite:3.0.0',
- 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2',
- )
- testImplementation(
- 'androidx.test.ext:junit:1.1.1',
- 'com.google.dagger:dagger:2.24',
- 'com.google.truth:truth:0.43',
- 'junit:junit:4.12',
- 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2',
- 'org.mockito:mockito-core:2.19.0',
- 'org.robolectric:robolectric:4.3',
- )
- kaptTest(
- 'com.google.dagger:dagger-compiler:2.24'
- )
- implementation project(":utility")
- testImplementation project(":model")
-}
diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro
deleted file mode 100644
index f1b424510da..00000000000
--- a/data/proguard-rules.pro
+++ /dev/null
@@ -1,21 +0,0 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml
deleted file mode 100644
index fdb94b1021d..00000000000
--- a/data/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/data/src/main/java/org/oppia/data/persistence/PersistentCacheStore.kt b/data/src/main/java/org/oppia/data/persistence/PersistentCacheStore.kt
deleted file mode 100644
index 490ad060a98..00000000000
--- a/data/src/main/java/org/oppia/data/persistence/PersistentCacheStore.kt
+++ /dev/null
@@ -1,222 +0,0 @@
-package org.oppia.data.persistence
-
-import android.content.Context
-import androidx.annotation.GuardedBy
-import com.google.protobuf.MessageLite
-import kotlinx.coroutines.Deferred
-import org.oppia.util.data.AsyncDataSubscriptionManager
-import org.oppia.util.data.AsyncResult
-import org.oppia.util.data.DataProvider
-import org.oppia.util.data.InMemoryBlockingCache
-import java.io.File
-import java.io.FileInputStream
-import java.io.FileOutputStream
-import java.io.IOException
-import java.util.concurrent.locks.ReentrantLock
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.concurrent.withLock
-
-/**
- * An on-disk persistent cache for proto messages that ensures reads and writes happen in a well-defined order. Note
- * that if this cache is used like a [DataProvider], there is a race condition between the initial store's data being
- * retrieved and any early writes to the store (writes generally win). If this is not ideal, callers should use
- * [primeCacheAsync] to synchronously kick-off a read update to the store that is guaranteed to complete before any writes.
- * This will be reflected in the first time the store's state is delivered to a subscriber to a LiveData version of this
- * data provider.
- *
- * Note that this is a fast-response data provider, meaning it will provide a pending [AsyncResult] to subscribers
- * immediately until the actual store is retrieved from disk.
- */
-class PersistentCacheStore private constructor(
- context: Context, cacheFactory: InMemoryBlockingCache.Factory,
- private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, cacheName: String, private val initialValue: T
-) : DataProvider {
- private val cacheFileName = "$cacheName.cache"
- private val providerId = PersistentCacheStoreId(cacheFileName)
- private val failureLock = ReentrantLock()
-
- private val cacheFile = File(context.filesDir, cacheFileName)
- @GuardedBy("failureLock") private var deferredLoadCacheFailure: Throwable? = null
- private val cache = cacheFactory.create(CachePayload(state = CacheState.UNLOADED, value = initialValue))
-
- override fun getId(): Any {
- return providerId
- }
-
- override suspend fun retrieveData(): AsyncResult {
- cache.readIfPresentAsync().await().let { cachePayload ->
- // First, determine whether the current cache has been attempted to be retrieved from disk.
- if (cachePayload.state == CacheState.UNLOADED) {
- deferLoadFileAndNotify()
- return AsyncResult.pending()
- }
-
- // Second, check if a previous deferred read failed. The store stays in a failed state until the next storeData()
- // call to avoid hitting the same failure again. Eventually, the class could be updated with some sort of retry or
- // recovery mechanism if failures show up in real use cases.
- failureLock.withLock {
- deferredLoadCacheFailure?.let {
- // A previous read failed.
- return AsyncResult.failed(it)
- }
- }
-
- // Finally, check if there's an in-memory cached value that can be loaded now.
- // Otherwise, there should be a guaranteed in-memory value to use, instead.
- return AsyncResult.success(cachePayload.value)
- }
- }
-
- /**
- * Kicks off a read operation to update the in-memory cache. This operation blocks against calls to [storeDataAsync]
- * and deferred calls to [retrieveData].
- *
- * @param forceUpdate indicates whether to force a reset of the in-memory cache. Note that this only forces a load; if
- * the load fails then the store will remain in its same state. If this value is false (the default), it will only
- * perform file I/O if the cache is not already loaded into memory.
- * @returns a [Deferred] that completes upon the completion of priming the cache, or failure to do so with the failed
- * exception. Note that the failure reason will not be propagated to a LiveData-converted version of this data
- * provider, so it must be handled at the callsite for this method.
- */
- fun primeCacheAsync(forceUpdate: Boolean = false): Deferred {
- return cache.updateIfPresentAsync { cachePayload ->
- if (forceUpdate || cachePayload.state == CacheState.UNLOADED) {
- // Store the retrieved on-disk cache, if it's present (otherwise set up state such that retrieveData() does not
- // attempt to load the file from disk again since the attempt was made here).
- loadFileCache(cachePayload)
- } else {
- // Otherwise, keep the cache the same.
- cachePayload
- }
- }
- }
-
- /**
- * Calls the specified value with the current on-disk contents and saves the result of the function to disk. Note that
- * the function used here should be non-blocking, thread-safe, and should have no side effects.
- *
- * @param updateInMemoryCache indicates whether this change to the on-disk store should also update the in-memory
- * store, and propagate that change to all subscribers to this data provider. This may be ideal if callers want to
- * control "snapshots" of the store that subscribers have access to, however it's recommended to keep all store
- * calls consistent in whether they update the in-memory cache to avoid complex potential in-memory/on-disk sync
- * issues.
- */
- fun storeDataAsync(updateInMemoryCache: Boolean = true, update: (T) -> T): Deferred {
- return cache.updateIfPresentAsync { cachedPayload ->
- // Although it's odd to notify before the change is made, the single threaded nature of the blocking cache ensures
- // nothing can read from it until this update completes.
- asyncDataSubscriptionManager.notifyChange(providerId)
- val updatedPayload = storeFileCache(cachedPayload, update)
- if (updateInMemoryCache) updatedPayload else cachedPayload
- }
- }
-
- /**
- * Returns a [Deferred] indicating when the cache was cleared and its on-disk file, removed. This does not notify
- * subscribers.
- */
- fun clearCacheAsync(): Deferred {
- return cache.updateIfPresentAsync {
- if (cacheFile.exists()) {
- cacheFile.delete()
- }
- failureLock.withLock {
- deferredLoadCacheFailure = null
- }
- // Always clear the in-memory cache and reset it to the initial value (the cache itself should never be fully
- // deleted since the rest of the store assumes a value is always present in it).
- CachePayload(state = CacheState.UNLOADED, value = initialValue)
- }
- }
-
- private fun deferLoadFileAndNotify() {
- // Schedule another update to the cache that actually loads the file from memory. Record any potential failures.
- cache.updateIfPresentAsync { cachePayload ->
- asyncDataSubscriptionManager.notifyChange(providerId)
- loadFileCache(cachePayload)
- }.invokeOnCompletion {
- failureLock.withLock {
- // Other failures should be captured for reporting.
- deferredLoadCacheFailure = it ?: deferredLoadCacheFailure
- }
- }
- }
-
- /**
- * Loads the file store from disk, and returns the most up-to-date cache payload. This should only be called from the
- * cache's update thread.
- */
- @Suppress("UNCHECKED_CAST") // Cast is ensured since root proto is initialValue with type T.
- private fun loadFileCache(currentPayload: CachePayload): CachePayload {
- if (!cacheFile.exists()) {
- // The store is not yet persisted on disk.
- return currentPayload.moveToState(CacheState.IN_MEMORY_ONLY)
- }
-
- val cacheBuilder = currentPayload.value.toBuilder()
- return try {
- CachePayload(
- state = CacheState.IN_MEMORY_AND_ON_DISK,
- value = FileInputStream(cacheFile).use { cacheBuilder.mergeFrom(it) }.build() as T
- )
- } catch (e: IOException) {
- failureLock.withLock {
- deferredLoadCacheFailure = e
- }
- // Update the cache to have an in-memory copy of the current payload since on-disk retrieval failed.
- CachePayload(
- state = CacheState.IN_MEMORY_ONLY,
- value = currentPayload.value
- )
- }
- }
-
- /**
- * Stores the file store to disk, and returns the persisted payload. This should only be called from the cache's
- * update thread.
- */
- private fun storeFileCache(currentPayload: CachePayload, update: (T) -> T): CachePayload {
- val updatedCacheValue = update(currentPayload.value)
- FileOutputStream(cacheFile).use { updatedCacheValue.writeTo(it) }
- return CachePayload(state = CacheState.IN_MEMORY_AND_ON_DISK, value = updatedCacheValue)
- }
-
- private data class PersistentCacheStoreId(private val id: String)
-
- /** Represents different states the cache store can be in. */
- private enum class CacheState {
- /** Indicates that the cache has not yet been attempted to be retrieved from disk. */
- UNLOADED,
-
- /** Indicates that the cache exists only in memory and not on disk. */
- IN_MEMORY_ONLY,
-
- /** Indicates that the cache exists both in memory and on disk. */
- IN_MEMORY_AND_ON_DISK
- }
-
- private data class CachePayload(val state: CacheState, val value: T) {
- /** Returns a copy of this payload with the new, specified [CacheState]. */
- fun moveToState(newState: CacheState): CachePayload {
- return CachePayload(state = newState, value = value)
- }
- }
-
- // TODO(#59): Use @ApplicationContext instead of Context once package dependencies allow for cross-module circular
- // dependencies. Currently, the data module cannot depend on the app module.
- /**
- * An injectable factory for [PersistentCacheStore]s. The stores themselves should be retrievable from central
- * controllers since they can't be placed directly in the Dagger graph.
- */
- @Singleton
- class Factory @Inject constructor(
- private val context: Context, private val cacheFactory: InMemoryBlockingCache.Factory,
- private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager
- ) {
- /** Returns a new [PersistentCacheStore] with the specified cache name and initial value. */
- fun create(cacheName: String, initialValue: T): PersistentCacheStore {
- return PersistentCacheStore(context, cacheFactory, asyncDataSubscriptionManager, cacheName, initialValue)
- }
- }
-}
diff --git a/data/src/test/java/org/oppia/data/persistence/PersistentCacheStoreTest.kt b/data/src/test/java/org/oppia/data/persistence/PersistentCacheStoreTest.kt
deleted file mode 100644
index f2077c0aef3..00000000000
--- a/data/src/test/java/org/oppia/data/persistence/PersistentCacheStoreTest.kt
+++ /dev/null
@@ -1,584 +0,0 @@
-package org.oppia.data.persistence
-
-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 com.google.protobuf.MessageLite
-import dagger.BindsInstance
-import dagger.Component
-import dagger.Module
-import dagger.Provides
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-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.reset
-import org.mockito.Mockito.verify
-import org.mockito.Mockito.verifyZeroInteractions
-import org.mockito.junit.MockitoJUnit
-import org.mockito.junit.MockitoRule
-import org.oppia.app.model.TestMessage
-import org.oppia.util.data.AsyncResult
-import org.oppia.util.data.DataProviders
-import org.oppia.util.threading.BackgroundDispatcher
-import org.oppia.util.threading.BlockingDispatcher
-import org.robolectric.annotation.Config
-import java.io.File
-import java.io.FileOutputStream
-import java.io.IOException
-import javax.inject.Inject
-import javax.inject.Qualifier
-import javax.inject.Singleton
-
-private const val CACHE_NAME_1 = "test_cache_1"
-private const val CACHE_NAME_2 = "test_cache_2"
-
-/** Tests for [PersistentCacheStore]. */
-@RunWith(AndroidJUnit4::class)
-@Config(manifest = Config.NONE)
-class PersistentCacheStoreTest {
- private companion object {
- private val TEST_MESSAGE_VERSION_1 = TestMessage.newBuilder().setVersion(1).build()
- private val TEST_MESSAGE_VERSION_2 = TestMessage.newBuilder().setVersion(2).build()
- }
-
- @Rule
- @JvmField
- val mockitoRule: MockitoRule = MockitoJUnit.rule()
-
- @Inject
- lateinit var cacheFactory: PersistentCacheStore.Factory
-
- @Inject
- lateinit var dataProviders: DataProviders
-
- @ExperimentalCoroutinesApi
- @Inject
- @field:TestDispatcher
- lateinit var testDispatcher: TestCoroutineDispatcher
-
- @Mock
- lateinit var mockUserAppHistoryObserver1: Observer>
-
- @Mock
- lateinit var mockUserAppHistoryObserver2: Observer>
-
- @Captor
- lateinit var userAppHistoryResultCaptor1: ArgumentCaptor>
-
- @Captor
- lateinit var userAppHistoryResultCaptor2: ArgumentCaptor>
-
- @Before
- @ExperimentalCoroutinesApi
- fun setUp() {
- setUpTestApplicationComponent()
- // The test dispatcher must be paused by default to ensure the blocking executor used by InMemoryCache works
- // correctly. The dispatcher's default state is to synchronously execute everything that's schedule, but this is not
- // an accurate simulation of production and results in strange bugs (like impossible, immediate in-place thread
- // hops that break thread separation assumptions).
- testDispatcher.pauseDispatcher()
- }
-
- // TODO(#59): Create a test-only proto for this test rather than needing to reuse a production-facing proto.
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_toLiveData_initialState_isPending() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.allValues[0].isPending()).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_toLiveData_loaded_providesInitialValue() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- // The initial cache state should be the default cache value.
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TestMessage.getDefaultInstance())
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_nonDefaultInitialState_toLiveData_loaded_providesCorrectInitialVal() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TEST_MESSAGE_VERSION_1)
-
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- // Caches can have non-default initial states.
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_registerObserver_updateAfter_observerNotifiedOfNewValue() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- observeCache(cacheStore, mockUserAppHistoryObserver1)
- reset(mockUserAppHistoryObserver1)
- val storeOp = cacheStore.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // The store operation should be completed, and the observer should be notified of the changed value.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_registerObserver_updateBefore_observesUpdatedStateInitially() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- val storeOp = cacheStore.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- // The store operation should be completed, and the observer's only call should be the updated state.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_noMemoryCacheUpdate_updateAfterReg_observerNotNotified() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- observeCache(cacheStore, mockUserAppHistoryObserver1)
- reset(mockUserAppHistoryObserver1)
- val storeOp = cacheStore.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // The store operation should be completed, but the observe will not be notified of changes since the in-memory
- // cache was not changed.
- assertThat(storeOp.isCompleted).isTrue()
- verifyZeroInteractions(mockUserAppHistoryObserver1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_noMemoryCacheUpdate_updateBeforeReg_observesUpdatedState() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- val storeOp = cacheStore.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- // The store operation should be completed, but the observer will receive the updated state since the cache wasn't
- // primed and no previous observers initialized it.
- // NB: This may not be ideal behavior long-term; the store may need to be updated to be more resilient to these
- // types of scenarios.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_updated_newCache_newObserver_observesNewValue() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore1.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Create a new cache with the same name.
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // The new cache should have the updated value since it points to the same file as the first cache. This is
- // simulating something closer to an app restart or non-UI Dagger component refresh since UI components should share
- // the same cache instance via an application-bound controller object.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_updated_noInMemoryCacheUpdate_newCache_newObserver_observesNewVal() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore1.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Create a new cache with the same name.
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // The new cache should have the updated value since it points to the same file as the first cache, even though the
- // update operation did not update the in-memory cache (the new cache has a separate in-memory cache). This is
- // simulating something closer to an app restart or non-UI Dagger component refresh since UI components should share
- // the same cache instance via an application-bound controller object.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testExistingDiskCache_newCacheObject_updateNoMemThenRead_receivesNewValue() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp1 = cacheStore1.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Create a new cache with the same name and update it, then observe it.
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp2 = cacheStore2.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_2 }
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // Both operations should be complete, and the observer will receive the latest value since the update was posted
- // before the read occurred.
- assertThat(storeOp1.isCompleted).isTrue()
- assertThat(storeOp2.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_2)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testExistingDiskCache_newObject_updateNoMemThenRead_primed_receivesPrevVal() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp1 = cacheStore1.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Create a new cache with the same name and update it, then observe it. However, first prime it.
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val primeOp = cacheStore2.primeCacheAsync()
- testDispatcher.advanceUntilIdle()
- val storeOp2 = cacheStore2.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_2 }
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // All operations should be complete, but the observer will receive the previous update rather than th elatest since
- // it wasn't updated in memory and the cache was pre-primed.
- assertThat(storeOp1.isCompleted).isTrue()
- assertThat(storeOp2.isCompleted).isTrue()
- assertThat(primeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testExistingDiskCache_newObject_updateMemThenRead_primed_receivesNewVal() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp1 = cacheStore1.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Create a new cache with the same name and update it, then observe it. However, first prime it.
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val primeOp = cacheStore2.primeCacheAsync()
- testDispatcher.advanceUntilIdle()
- val storeOp2 = cacheStore2.storeDataAsync { TEST_MESSAGE_VERSION_2 }
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // Similar to the previous test, except due to the in-memory update the observer will receive the latest result
- // regardless of the cache priming.
- assertThat(storeOp1.isCompleted).isTrue()
- assertThat(storeOp2.isCompleted).isTrue()
- assertThat(primeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_2)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_primed_afterStoreUpdateWithoutMemUpdate_notForced_observesOldValue() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- observeCache(cacheStore, mockUserAppHistoryObserver1) // Force initializing the store's in-memory cache
-
- val storeOp = cacheStore.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
- val primeOp = cacheStore.primeCacheAsync(forceUpdate = false)
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore, mockUserAppHistoryObserver2)
-
- // Both ops will succeed, and the observer will receive the old value due to the update not changing the in-memory
- // cache, and the prime no-oping due to the cache already being initialized.
- assertThat(storeOp.isCompleted).isTrue()
- assertThat(primeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver2, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TestMessage.getDefaultInstance())
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_primed_afterStoreUpdateWithoutMemoryUpdate_forced_observesNewValue() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- observeCache(cacheStore, mockUserAppHistoryObserver1) // Force initializing the store's in-memory cache
-
- val storeOp = cacheStore.storeDataAsync(updateInMemoryCache = false) { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
- val primeOp = cacheStore.primeCacheAsync(forceUpdate = true)
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore, mockUserAppHistoryObserver2)
-
- // The observer will receive the new value because the prime was forced. This ensures the store's in-memory cache is
- // now up-to-date with the on-disk representation.
- assertThat(storeOp.isCompleted).isTrue()
- assertThat(primeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver2, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_clear_initialState_keepsCacheStateTheSame() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
-
- val clearOp = cacheStore.clearCacheAsync()
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- // The new observer should observe the store with its default state since nothing needed to be cleared.
- assertThat(clearOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TestMessage.getDefaultInstance())
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_update_clear_resetsCacheToInitialState() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- val clearOp = cacheStore.clearCacheAsync()
- testDispatcher.advanceUntilIdle()
- observeCache(cacheStore, mockUserAppHistoryObserver1)
-
- // The new observer should observe that the store is cleared.
- assertThat(storeOp.isCompleted).isTrue()
- assertThat(clearOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TestMessage.getDefaultInstance())
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_update_existingObserver_clear_isNotNotifiedOfClear() = runBlockingTest(testDispatcher) {
- val cacheStore = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- observeCache(cacheStore, mockUserAppHistoryObserver1)
- reset(mockUserAppHistoryObserver1)
- val clearOp = cacheStore.clearCacheAsync()
- testDispatcher.advanceUntilIdle()
-
- // The observer should not be notified the cache was cleared.
- assertThat(storeOp.isCompleted).isTrue()
- assertThat(clearOp.isCompleted).isTrue()
- verifyZeroInteractions(mockUserAppHistoryObserver1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCache_update_newCache_observesInitialState() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore1.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
- val clearOp = cacheStore1.clearCacheAsync()
- testDispatcher.advanceUntilIdle()
-
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TEST_MESSAGE_VERSION_2)
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // The new observer should observe that there's no persisted on-disk store since it has a different default value
- // that would only be used if there wasn't already on-disk storage.
- assertThat(storeOp.isCompleted).isTrue()
- assertThat(clearOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_2)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMultipleCaches_oneUpdates_newCacheSameNameDiffInit_observesUpdatedValue() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore1.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TEST_MESSAGE_VERSION_2)
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // The new cache should observe the updated on-disk value rather than its new default since an on-disk value exists.
- // This isn't a very realistic test since all caches should use default proto instances for initialization, but it's
- // a possible edge case that should at least have established behavior that can be adjusted later if it isn't
- // desirable in some circumstances.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMultipleCaches_differentNames_oneUpdates_otherDoesNotObserveChange() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val cacheStore2 = cacheFactory.create(CACHE_NAME_2, TestMessage.getDefaultInstance())
-
- observeCache(cacheStore1, mockUserAppHistoryObserver1)
- observeCache(cacheStore2, mockUserAppHistoryObserver2)
- reset(mockUserAppHistoryObserver2)
- val storeOp = cacheStore1.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // The observer of the second store will be not notified of the change to the first store since they have different
- // names.
- assertThat(storeOp.isCompleted).isTrue()
- verifyZeroInteractions(mockUserAppHistoryObserver2)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMultipleCaches_diffNames_oneUpdates_cachesRecreated_onlyOneObservesVal() = runBlockingTest(testDispatcher) {
- val cacheStore1a = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- cacheFactory.create(CACHE_NAME_2, TestMessage.getDefaultInstance())
- val storeOp = cacheStore1a.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Recreate the stores and observe them.
- val cacheStore1b = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val cacheStore2b = cacheFactory.create(CACHE_NAME_2, TestMessage.getDefaultInstance())
- observeCache(cacheStore1b, mockUserAppHistoryObserver1)
- observeCache(cacheStore2b, mockUserAppHistoryObserver2)
-
- // Only the observer of the first cache should notice the update since they are different caches.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- verify(mockUserAppHistoryObserver2, atLeastOnce()).onChanged(userAppHistoryResultCaptor2.capture())
- assertThat(userAppHistoryResultCaptor1.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getOrThrow()).isEqualTo(TEST_MESSAGE_VERSION_1)
- assertThat(userAppHistoryResultCaptor2.value.isSuccess()).isTrue()
- assertThat(userAppHistoryResultCaptor2.value.getOrThrow()).isEqualTo(TestMessage.getDefaultInstance())
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testNewCache_fileCorrupted_providesError() = runBlockingTest(testDispatcher) {
- val cacheStore1 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- val storeOp = cacheStore1.storeDataAsync { TEST_MESSAGE_VERSION_1 }
- testDispatcher.advanceUntilIdle()
-
- // Simulate the file being corrupted & reopen the file in a new store.
- corruptFileCache(CACHE_NAME_1)
- val cacheStore2 = cacheFactory.create(CACHE_NAME_1, TestMessage.getDefaultInstance())
- observeCache(cacheStore2, mockUserAppHistoryObserver1)
-
- // The new observer should receive an IOException error when trying to read the file.
- assertThat(storeOp.isCompleted).isTrue()
- verify(mockUserAppHistoryObserver1, atLeastOnce()).onChanged(userAppHistoryResultCaptor1.capture())
- assertThat(userAppHistoryResultCaptor1.value.isFailure()).isTrue()
- assertThat(userAppHistoryResultCaptor1.value.getErrorOrNull()).isInstanceOf(IOException::class.java)
- }
-
- @ExperimentalCoroutinesApi
- private fun observeCache(cacheStore: PersistentCacheStore, observer: Observer>) {
- dataProviders.convertToLiveData(cacheStore).observeForever(observer)
- testDispatcher.advanceUntilIdle()
- }
-
- private fun corruptFileCache(cacheName: String) {
- // NB: This is unfortunately tied to the implementation details of PersistentCacheStore. If this ends up being an
- // issue, the store should be updated to call into a file path provider that can also be used in this test to
- // retrieve the file cache. This may also be needed for downstream profile work if per-profile data stores are done
- // via subdirectories or altered filenames.
- val cacheFileName = "$cacheName.cache"
- val cacheFile = File(ApplicationProvider.getApplicationContext().filesDir, cacheFileName)
- FileOutputStream(cacheFile).use {
- it.write(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9))
- }
- }
-
- private fun setUpTestApplicationComponent() {
- DaggerPersistentCacheStoreTest_TestApplicationComponent.builder()
- .setApplication(ApplicationProvider.getApplicationContext())
- .build()
- .inject(this)
- }
-
- @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(): TestCoroutineDispatcher {
- return TestCoroutineDispatcher()
- }
-
- @ExperimentalCoroutinesApi
- @Singleton
- @Provides
- @BackgroundDispatcher
- fun provideBackgroundDispatcher(@TestDispatcher testDispatcher: TestCoroutineDispatcher): CoroutineDispatcher {
- return testDispatcher
- }
-
- @ExperimentalCoroutinesApi
- @Singleton
- @Provides
- @BlockingDispatcher
- fun provideBlockingDispatcher(@TestDispatcher testDispatcher: TestCoroutineDispatcher): CoroutineDispatcher {
- return testDispatcher
- }
- }
-
- // 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(persistentCacheStoreTest: PersistentCacheStoreTest)
- }
-}
diff --git a/domain/build.gradle b/domain/build.gradle
index f8615018a63..5e199af2141 100644
--- a/domain/build.gradle
+++ b/domain/build.gradle
@@ -1,28 +1,18 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
-apply plugin: 'kotlin-kapt'
android {
- compileSdkVersion 28
+ compileSdkVersion 29
buildToolsVersion "29.0.1"
defaultConfig {
- minSdkVersion 19
+ minSdkVersion 16
targetSdkVersion 28
versionCode 1
versionName "1.0"
}
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_1_8
- }
-
testOptions {
unitTests {
includeAndroidResources = true
@@ -42,33 +32,21 @@ dependencies {
implementation(
'androidx.appcompat:appcompat:1.0.2',
'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03',
- 'com.google.dagger:dagger:2.24',
"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
)
testImplementation(
'android.arch.core:core-testing:1.1.1',
'androidx.test.espresso:espresso-core:3.2.0',
'androidx.test.ext:junit:1.1.1',
- 'com.google.dagger:dagger:2.24',
'com.google.truth:truth:0.43',
'junit:junit:4.12',
'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2',
'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2',
'org.mockito:mockito-core:2.19.0',
- 'org.robolectric:robolectric:4.3',
- )
- kapt(
- 'com.google.dagger:dagger-compiler:2.24'
- )
- kaptTest(
- 'com.google.dagger:dagger-compiler:2.24'
+ 'org.robolectric:robolectric:4.3'
)
- // TODO(#59): Avoid needing to expose data implementations to other modules in order to make Oppia symbols
- // sufficiently visible for generated Dagger code. This can be done more cleanly via Bazel since dependencies can be
- // controlled more directly than in Gradle.
- api project(':data')
- implementation project(':model')
- implementation project(':utility')
+ implementation project(":model")
+ implementation project(":utility")
}
repositories {
mavenCentral()
diff --git a/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt b/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
index ffbe1d488cb..fe61256adf0 100644
--- a/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
+++ b/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
@@ -1,59 +1,128 @@
package org.oppia.domain
-import android.util.Log
import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.MutableLiveData
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
import org.oppia.app.model.UserAppHistory
-import org.oppia.data.persistence.PersistentCacheStore
+import org.oppia.util.data.AsyncDataSource
import org.oppia.util.data.AsyncResult
-import org.oppia.util.data.DataProviders
-import javax.inject.Inject
-import javax.inject.Singleton
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
/** Controller for persisting and retrieving the previous user history of using the app. */
-@Singleton
-class UserAppHistoryController @Inject constructor(
- cacheStoreFactory: PersistentCacheStore.Factory, private val dataProviders: DataProviders
-) {
- private val appHistoryStore = cacheStoreFactory.create("user_app_history", UserAppHistory.getDefaultInstance())
-
- init {
- // Prime the cache ahead of time so that any existing history is read prior to any calls to markUserOpenedApp().
- appHistoryStore.primeCacheAsync().invokeOnCompletion {
- it?.let {
- Log.e("DOMAIN", "Failed to prime cache ahead of LiveData conversion for user app open history.", it)
- }
- }
- }
+class UserAppHistoryController(private val coroutineContext: CoroutineContext = EmptyCoroutineContext) {
+ // TODO(#70): Persist this value.
+ private var userOpenedApp = false
/**
- * Saves that the user has opened the app. Note that this does not notify existing subscribers of the changed state,
- * nor can future subscribers observe this state until app restart.
+ * Saves that the user has opened the app. Note that this does not notify existing consumers that the change was made.
*/
fun markUserOpenedApp() {
- appHistoryStore.storeDataAsync(updateInMemoryCache = false) {
- it.toBuilder().setAlreadyOpenedApp(true).build()
- }.invokeOnCompletion {
- it?.let {
- Log.e("DOMAIN", "Failed when storing that the user already opened the app.", it)
- }
+ userOpenedApp = true
+ }
+
+ /** Returns a [LiveData] result indicating whether the user has previously opened the app. */
+ fun getUserAppHistory(): LiveData> {
+ return NotifiableAsyncLiveData(coroutineContext) {
+ createUserAppHistoryDataSource().executePendingOperation()
}
}
- /** Clears any indication that the user has previously opened the application. */
- fun clearUserAppHistory() {
- appHistoryStore.clearCacheAsync().invokeOnCompletion {
- it?.let {
- Log.e("DOMAIN", "Failed to clear user app history.", it)
+ private fun createUserAppHistoryDataSource(): AsyncDataSource {
+ return object : AsyncDataSource {
+ override suspend fun executePendingOperation(): UserAppHistory {
+ return UserAppHistory.newBuilder().setAlreadyOpenedApp(userOpenedApp).build()
}
}
}
+ // TODO(#71): Move this to the correct module once the architecture for data sources is determined.
/**
- * Returns a [LiveData] result indicating whether the user has previously opened the app. This is guaranteed to
- * provide the state of the store upon the creation of this controller even if [markUserOpenedApp] has since been
- * called.
+ * A version of [LiveData] which can be notified to execute a specified coroutine if there is a pending update.
+ *
+ * This [LiveData] also reports the pending, succeeding, and failing state of the [AsyncResult]. Note that it will
+ * immediately execute the specified async function upon initialization, so it's recommended to only initialize this
+ * object upon when its result is actually needed to avoid kicking off many async tasks with results that may never be
+ * used.
*/
- fun getUserAppHistory(): LiveData> {
- return dataProviders.convertToLiveData(appHistoryStore)
+ private class NotifiableAsyncLiveData(
+ private val context: CoroutineContext = EmptyCoroutineContext,
+ private val function: suspend () -> T
+ ) : MediatorLiveData>() {
+ private val lock = Object()
+ private var pendingCoroutineLiveData: LiveData>? = null
+
+ init {
+ // Assume that the specified block is ready to execute immediately.
+ value = AsyncResult.pending()
+ enqueueAsyncFunctionAsLiveData()
+ }
+
+ /**
+ * Notifies this live data that it should re-run its asynchronous function and propagate any results.
+ *
+ * Note that if an existing operation is pending, it may complete but its results will not be propagated in favor
+ * of the run started by this call. Note also that regardless of the current [AsyncResult] value of this live data,
+ * the new value will overwrite it (e.g. it's possible to go from a failed to success state or vice versa).
+ */
+ fun notifyUpdate() {
+ synchronized(lock) {
+ if (pendingCoroutineLiveData != null) {
+ removeSource(pendingCoroutineLiveData!!)
+ pendingCoroutineLiveData = null
+ }
+ enqueueAsyncFunctionAsLiveData()
+ }
+ }
+
+ /**
+ * Enqueues the async function, but execution is based on whether this [LiveData] is active. See [MediatorLiveData]
+ * docs for context.
+ */
+ private fun enqueueAsyncFunctionAsLiveData() {
+ val coroutineLiveData = CoroutineLiveData(context) {
+ try {
+ AsyncResult.success(function())
+ } catch (t: Throwable) {
+ // Capture all failures for the downstream handler.
+ AsyncResult.failed(t)
+ }
+ }
+ synchronized(lock) {
+ pendingCoroutineLiveData = coroutineLiveData
+ addSource(coroutineLiveData) { computedValue ->
+ value = computedValue
+ }
+ }
+ }
+ }
+
+ // TODO(#72): Replace this with AndroidX's CoroutineLiveData once the corresponding LiveData suspend job bug is fixed
+ // and available.
+ /** A [LiveData] whose value is derived from a suspended function. */
+ private class CoroutineLiveData(
+ private val context: CoroutineContext,
+ private val function: suspend () -> T
+ ) : MutableLiveData() {
+ private var runningJob: Job? = null
+
+ override fun onActive() {
+ super.onActive()
+ if (runningJob == null) {
+ val scope = CoroutineScope(Dispatchers.Main + context)
+ runningJob = scope.launch {
+ value = function()
+ }
+ }
+ }
+
+ override fun onInactive() {
+ super.onInactive()
+ runningJob?.cancel()
+ }
}
}
diff --git a/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt b/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
index c2de37d99ea..9d470d4510a 100644
--- a/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
+++ b/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
@@ -1,22 +1,13 @@
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
@@ -34,17 +25,9 @@ import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.oppia.app.model.UserAppHistory
import org.oppia.util.data.AsyncResult
-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 UserAppHistoryControllerTest {
@Rule
@JvmField
@@ -54,17 +37,6 @@ class UserAppHistoryControllerTest {
@JvmField
val executorRule = InstantTaskExecutorRule()
- @Inject
- lateinit var userAppHistoryController: UserAppHistoryController
-
- @Inject
- @field:TestDispatcher
- lateinit var testDispatcher: CoroutineDispatcher
-
- private val coroutineContext by lazy {
- EmptyCoroutineContext + testDispatcher
- }
-
@Mock
lateinit var mockAppHistoryObserver: Observer>
@@ -80,7 +52,6 @@ class UserAppHistoryControllerTest {
@ObsoleteCoroutinesApi
fun setUp() {
Dispatchers.setMain(testThread)
- setUpTestApplicationComponent()
}
@After
@@ -91,16 +62,25 @@ class UserAppHistoryControllerTest {
testThread.close()
}
- private fun setUpTestApplicationComponent() {
- DaggerUserAppHistoryControllerTest_TestApplicationComponent.builder()
- .setApplication(ApplicationProvider.getApplicationContext())
- .build()
- .inject(this)
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_providesInitialLiveData_thatIsPendingBeforeResultIsPosted() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+
+ // Observe with a paused dispatcher to ensure the actual user app history value is not provided before assertion.
+ val appHistory = userAppHistoryController.getUserAppHistory()
+ pauseDispatcher()
+ appHistory.observeForever(mockAppHistoryObserver)
+
+ verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
+ assertThat(appHistoryResultCaptor.value.isPending()).isTrue()
}
@Test
@ExperimentalCoroutinesApi
- fun testController_providesInitialLiveData_thatIndicatesUserHasNotOpenedTheApp() = runBlockingTest(coroutineContext) {
+ fun testController_providesInitialLiveData_thatIndicatesUserHasNotOpenedTheApp() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+
val appHistory = userAppHistoryController.getUserAppHistory()
advanceUntilIdle()
appHistory.observeForever(mockAppHistoryObserver)
@@ -112,13 +92,13 @@ class UserAppHistoryControllerTest {
@Test
@ExperimentalCoroutinesApi
- fun testControllerObserver_observedAfterSettingAppOpened_providesLiveData_userDidNotOpenApp()
- = runBlockingTest(coroutineContext) {
+ fun testControllerObserver_observedBeforeSettingAppOpened_providesLiveData_userDidNotOpenApp() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
val appHistory = userAppHistoryController.getUserAppHistory()
appHistory.observeForever(mockAppHistoryObserver)
- userAppHistoryController.markUserOpenedApp()
advanceUntilIdle()
+ userAppHistoryController.markUserOpenedApp()
// The result should not indicate that the user opened the app because markUserOpenedApp does not notify observers
// of the change.
@@ -129,87 +109,17 @@ class UserAppHistoryControllerTest {
@Test
@ExperimentalCoroutinesApi
- fun testController_settingAppOpened_observedNewController_userOpenedApp()
- = runBlockingTest(coroutineContext) {
- userAppHistoryController.markUserOpenedApp()
- advanceUntilIdle()
-
- // Create the controller by creating another singleton graph and injecting it (simulating the app being recreated).
- setUpTestApplicationComponent()
+ fun testController_observedAfterSettingAppOpened_providesLiveData_userOpenedApp() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
val appHistory = userAppHistoryController.getUserAppHistory()
- appHistory.observeForever(mockAppHistoryObserver)
- advanceUntilIdle()
- // The app should be considered open since a new LiveData instance was observed after marking the app as opened.
- verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
- assertThat(appHistoryResultCaptor.value.isSuccess()).isTrue()
- assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testController_openedApp_cleared_observeNewController_userDidNotOpenApp() = runBlockingTest(coroutineContext) {
userAppHistoryController.markUserOpenedApp()
- advanceUntilIdle()
-
- // Clear, then recreate another controller.
- userAppHistoryController.clearUserAppHistory()
- setUpTestApplicationComponent()
- val appHistory = userAppHistoryController.getUserAppHistory()
appHistory.observeForever(mockAppHistoryObserver)
advanceUntilIdle()
- // The app should be considered not yet opened since the previous history was cleared.
+ // The app should be considered open since observation began after marking the app as opened.
verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
assertThat(appHistoryResultCaptor.value.isSuccess()).isTrue()
- assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).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(#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(userAppHistoryControllerTest: UserAppHistoryControllerTest)
+ assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).isTrue()
}
}
diff --git a/model/build.gradle b/model/build.gradle
index 69d27b52c39..6e30a513334 100644
--- a/model/build.gradle
+++ b/model/build.gradle
@@ -28,6 +28,9 @@ dependencies {
compile 'com.google.protobuf:protobuf-lite:3.0.0'
}
+sourceCompatibility = "8"
+targetCompatibility = "8"
+
sourceSets {
main.java.srcDirs += "${protobuf.generatedFilesBaseDir}/main/javalite"
main.java.srcDirs += "$projectDir/src/main/proto"
diff --git a/model/src/main/proto/example.proto b/model/src/main/proto/example.proto
index 15e27e9a7b1..4d4465182b0 100644
--- a/model/src/main/proto/example.proto
+++ b/model/src/main/proto/example.proto
@@ -8,7 +8,3 @@ option java_multiple_files = true;
message UserAppHistory {
bool already_opened_app = 1;
}
-
-message TestMessage {
- int32 version = 1;
-}
diff --git a/settings.gradle b/settings.gradle
index 4253dbdcae1..70fa49dac04 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app', ':model', ':utility', ':domain', ':data'
+include ':app', ':model', ':utility', ':domain'
diff --git a/utility/build.gradle b/utility/build.gradle
index a53440609e1..a997ffdd681 100644
--- a/utility/build.gradle
+++ b/utility/build.gradle
@@ -1,32 +1,18 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
-apply plugin: 'kotlin-kapt'
android {
- compileSdkVersion 28
+ compileSdkVersion 29
buildToolsVersion "29.0.1"
defaultConfig {
- minSdkVersion 19
- targetSdkVersion 28
+ minSdkVersion 16
+ targetSdkVersion 29
versionCode 1
versionName "1.0"
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_1_8
- }
- testOptions {
- unitTests {
- includeAndroidResources = true
- }
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@@ -41,25 +27,10 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation(
'androidx.appcompat:appcompat:1.0.2',
- 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03',
- 'com.google.dagger:dagger:2.24',
- "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
)
testImplementation(
- 'androidx.test.ext:junit:1.1.1',
- 'com.google.dagger:dagger:2.24',
'com.google.truth:truth:0.43',
'junit:junit:4.12',
- "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version",
"org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version",
- 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2',
- 'org.mockito:mockito-core:2.19.0',
- 'org.robolectric:robolectric:4.3',
- )
- kapt(
- 'com.google.dagger:dagger-compiler:2.24'
- )
- kaptTest(
- 'com.google.dagger:dagger-compiler:2.24'
)
}
diff --git a/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt b/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt
new file mode 100644
index 00000000000..b13ad899c73
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt
@@ -0,0 +1,12 @@
+package org.oppia.util.data
+
+/**
+ * Represents a source of data that can be delivered and changed asynchronously.
+ *
+ * @param The type of data being provided by this data source.
+ */
+interface AsyncDataSource {
+ // TODO(#6): Finalize the interfaces for this API beyond a basic prototype for the initial project intro.
+
+ suspend fun executePendingOperation(): T
+}
diff --git a/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt b/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt
deleted file mode 100644
index 67adb7bc92e..00000000000
--- a/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package org.oppia.util.data
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import org.oppia.util.threading.ConcurrentQueueMap
-import org.oppia.util.threading.dequeue
-import org.oppia.util.threading.enqueue
-import org.oppia.util.threading.getQueue
-import javax.inject.Inject
-import javax.inject.Singleton
-
-internal typealias ObserveAsyncChange = suspend () -> Unit
-
-/**
- * A subscription manager for all [DataProvider]s. This should only be used outside of this package for notifying
- * changes to custom [DataProvider]s.
- */
-@Singleton
-class AsyncDataSubscriptionManager @Inject constructor() {
- private val subscriptionMap = ConcurrentQueueMap()
- private val associatedIds = ConcurrentQueueMap()
-
- /** Subscribes the specified callback function to the specified [DataProvider] ID. */
- internal fun subscribe(id: Any, observeChange: ObserveAsyncChange) {
- subscriptionMap.enqueue(id, observeChange)
- }
-
- /** Unsubscribes the specified callback function from the specified [DataProvider] ID. */
- internal fun unsubscribe(id: Any, observeChange: ObserveAsyncChange): Boolean {
- // TODO(#91): Determine a way to safely fully remove the queue once it's empty. This may require a custom data
- // structure or external locking for proper thread safety (e.g. to handle the case where multiple
- // subscribes/notifies happen shortly after the queue is removed).
- return subscriptionMap.dequeue(id, observeChange)
- }
-
- /**
- * Creates an association such that change notifications via [notifyChange] to the parent ID will also notify
- * observers of the child ID.
- */
- internal fun associateIds(childId: Any, parentId: Any) {
- // TODO(#6): Ensure this graph is acyclic to avoid infinite recursion during notification. Compile-time deps should
- // make this impossible in practice unless data provider users try to use the same key for multiple inter-dependent
- // data providers.
- // TODO(#6): Find a way to determine parent-child ID associations during subscription time to avoid needing to store
- // long-lived references to IDs prior to subscriptions.
- associatedIds.enqueue(parentId, childId)
- }
-
- /**
- * Notifies all subscribers of the specified [DataProvider] id that the provider has been changed and should be
- * re-queried for its latest state.
- */
- @Suppress("DeferredResultUnused") // Exceptions on the main thread will cause app crashes. No action needed.
- suspend fun notifyChange(id: Any) {
- // Ensure observed changes are called specifically on the main thread since that's what NotifiableAsyncLiveData
- // expects.
- // TODO(#90): Update NotifiableAsyncLiveData so that observeChange() can occur on background threads to avoid any
- // load on the UI thread until the final data value is ready for delivery.
- val scope = CoroutineScope(Dispatchers.Main)
- scope.async {
- subscriptionMap.getQueue(id).forEach { observeChange -> observeChange() }
- }
-
- // Also notify all children observing this parent.
- associatedIds.getQueue(id).forEach { childId -> notifyChange(childId) }
- }
-}
diff --git a/utility/src/main/java/org/oppia/util/data/AsyncResult.kt b/utility/src/main/java/org/oppia/util/data/AsyncResult.kt
index c9a343427d7..b9b70041aa9 100644
--- a/utility/src/main/java/org/oppia/util/data/AsyncResult.kt
+++ b/utility/src/main/java/org/oppia/util/data/AsyncResult.kt
@@ -4,7 +4,7 @@ package org.oppia.util.data
class AsyncResult private constructor(
private val status: Status,
private val value: T? = null,
- private val error: Throwable? = null
+ val error: Throwable? = null
) {
/** Represents the status of an asynchronous result. */
enum class Status {
@@ -55,63 +55,6 @@ class AsyncResult private constructor(
return if (isFailure()) error else null
}
- /**
- * Returns a version of this result that retains its pending and failed states, but transforms its success state
- * according to the specified transformation function.
- *
- * Note that if the current result is a failure, the transformed result's failure will be a chained exception with
- * this result's failure as the root cause to preserve this transformation in the exception's stacktrace.
- *
- * Note also that the specified transformation function should have no side effects, and be non-blocking.
- */
- fun transform(transformFunction: (T) -> O): AsyncResult {
- return when(status) {
- Status.PENDING -> pending()
- Status.FAILED -> failed(ChainedFailureException(error!!))
- Status.SUCCEEDED -> success(transformFunction(value!!))
- }
- }
-
- /**
- * Returns a transformed version of this result in the same way as [transform] except it supports using a blocking
- * transformation function instead of a non-blocking one. Note that the transform function is only used if the current
- * result is a success, at which case the function's result becomes the new transformed result.
- */
- suspend fun transformAsync(transformFunction: suspend (T) -> AsyncResult): AsyncResult {
- return when(status) {
- Status.PENDING -> pending()
- Status.FAILED -> failed(ChainedFailureException(error!!))
- Status.SUCCEEDED -> transformFunction(value!!)
- }
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) {
- return true
- }
- if (other == null || other.javaClass != javaClass) {
- return false
- }
- val otherResult = other as AsyncResult<*>
- return otherResult.status == status && otherResult.error == error && otherResult.value == value
- }
-
- override fun hashCode(): Int {
- // Automatically generated hashCode() function that has parity with equals().
- var result = status.hashCode()
- result = 31 * result + (value?.hashCode() ?: 0)
- result = 31 * result + (error?.hashCode() ?: 0)
- return result
- }
-
- override fun toString(): String {
- return when(status) {
- Status.PENDING -> "AsyncResult[status=PENDING]"
- Status.FAILED -> "AsyncResult[status=FAILED, error=$error]"
- Status.SUCCEEDED -> "AsyncResult[status=SUCCESS, value=$value]"
- }
- }
-
companion object {
/** Returns a pending result. */
fun pending(): AsyncResult {
@@ -128,7 +71,4 @@ class AsyncResult private constructor(
return AsyncResult(status = Status.FAILED, error = error)
}
}
-
- /** A chained exception to preserve failure stacktraces for [transform] and [transformAsync]. */
- class ChainedFailureException(cause: Throwable): Exception(cause)
}
diff --git a/utility/src/main/java/org/oppia/util/data/DataProvider.kt b/utility/src/main/java/org/oppia/util/data/DataProvider.kt
deleted file mode 100644
index 9c10374f987..00000000000
--- a/utility/src/main/java/org/oppia/util/data/DataProvider.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.oppia.util.data
-
-/**
- * Represents a provider of data that can be delivered and changed asynchronously.
- *
- * @param The type of data being provided.
- */
-interface DataProvider {
- // TODO(#6): Finalize the interfaces for this API beyond a basic prototype for the initial project intro.
-
- /**
- * Returns a unique identifier that corresponds to this data provider. This should be a trivially copyable and
- * immutable object. This ID is used to determine which data provider subscribers should be notified of changes to the
- * data.
- */
- fun getId(): Any
-
- /**
- * Returns the latest copy of data available by the provider, potentially performing a blocking call in order to
- * retrieve the data. It's up to the implementation to decide how caching should work. Implementations should remain
- * agnostic of the specific subscribers associated with them (ie, they should not perform logic corresponding to a
- * particular subscription since this can be highly error-prone when considering that subscribers may be bound to
- * Android UI component lifecycles).
- */
- suspend fun retrieveData(): AsyncResult
-}
diff --git a/utility/src/main/java/org/oppia/util/data/DataProviders.kt b/utility/src/main/java/org/oppia/util/data/DataProviders.kt
deleted file mode 100644
index 94fe26441f9..00000000000
--- a/utility/src/main/java/org/oppia/util/data/DataProviders.kt
+++ /dev/null
@@ -1,241 +0,0 @@
-package org.oppia.util.data
-
-import androidx.annotation.GuardedBy
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MediatorLiveData
-import androidx.lifecycle.MutableLiveData
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import org.oppia.util.threading.BackgroundDispatcher
-import java.util.concurrent.locks.ReentrantLock
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.concurrent.withLock
-
-/**
- * Various functions to create or manipulate [DataProvider]s.
- *
- * It's recommended to transform providers rather than [LiveData] since the latter occurs on the main thread, and the
- * former can occur safely on background threads to reduce UI lag and user perceived latency.
- */
-@Singleton
-class DataProviders @Inject constructor(
- @BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher,
- private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager
-) {
- /**
- * Returns a new [DataProvider] that applies the specified function each time new data is available to it, and
- * provides it to its own subscribers.
- *
- * Notifications to the original data provider will also notify subscribers to the transformed data provider of
- * changes, but not vice versa.
- *
- * Note that the input transformation function should be non-blocking, have no side effects, and be thread-safe since
- * it may be called on different background threads at different times. It should perform no UI operations or
- * otherwise interact with UI components.
- */
- fun transform(newId: Any, dataProvider: DataProvider, function: (T1) -> T2): DataProvider {
- asyncDataSubscriptionManager.associateIds(newId, dataProvider.getId())
- return object: DataProvider {
- override fun getId(): Any {
- return newId
- }
-
- override suspend fun retrieveData(): AsyncResult {
- try {
- return dataProvider.retrieveData().transform(function)
- } catch (t: Throwable) {
- return AsyncResult.failed(t)
- }
- }
- }
- }
-
- /**
- * Returns a transformed [DataProvider] in the same way as [transform] except the transformation function can be
- * blocking.
- */
- fun transformAsync(
- newId: Any, dataProvider: DataProvider, function: suspend (T1) -> AsyncResult
- ): DataProvider {
- asyncDataSubscriptionManager.associateIds(newId, dataProvider.getId())
- return object: DataProvider {
- override fun getId(): Any {
- return newId
- }
-
- override suspend fun retrieveData(): AsyncResult {
- return dataProvider.retrieveData().transformAsync(function)
- }
- }
- }
-
- /**
- * Returns a new in-memory [DataProvider] with the specified function being called each time the provider's data is
- * retrieved, and the specified identifier.
- *
- * Note that the loadFromMemory function should be non-blocking, and have no side effects. It should also be thread
- * safe since it can be called from different background threads. It also should never interact with UI components or
- * perform UI operations.
- *
- * Changes to the returned data provider can be propagated using calls to [AsyncDataSubscriptionManager.notifyChange]
- * with the in-memory provider's identifier.
- */
- fun createInMemoryDataProvider(id: Any, loadFromMemory: () -> T): DataProvider {
- return object: DataProvider {
- override fun getId(): Any {
- return id
- }
-
- override suspend fun retrieveData(): AsyncResult {
- return try {
- AsyncResult.success(loadFromMemory())
- } catch (t: Throwable) {
- AsyncResult.failed(t)
- }
- }
- }
- }
-
- /**
- * Returns a new in-memory [DataProvider] in the same way as [createInMemoryDataProvider] except the load function can
- * be blocking.
- */
- fun createInMemoryDataProviderAsync(id: Any, loadFromMemoryAsync: suspend () -> AsyncResult): DataProvider {
- return object: DataProvider {
- override fun getId(): Any {
- return id
- }
-
- override suspend fun retrieveData(): AsyncResult {
- return loadFromMemoryAsync()
- }
- }
- }
-
- /**
- * Converts a [DataProvider] to [LiveData]. This will use a background executor to handle processing of the coroutine,
- * but [LiveData] guarantees that final delivery of the result will happen on the main thread.
- */
- fun convertToLiveData(dataProvider: DataProvider): LiveData> {
- return NotifiableAsyncLiveData(backgroundDispatcher, asyncDataSubscriptionManager, dataProvider)
- }
-
- /**
- * A version of [LiveData] which can be notified to execute a specified coroutine if there is a pending update.
- *
- * This [LiveData] also reports the pending, succeeding, and failing state of the [AsyncResult]. Note that it will
- * immediately execute the specified async function upon initialization, so it's recommended to only initialize this
- * object upon when its result is actually needed to avoid kicking off many async tasks with results that may never be
- * used.
- */
- private class NotifiableAsyncLiveData(
- private val dispatcher: CoroutineDispatcher,
- private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager,
- private val dataProvider: DataProvider
- ) : MediatorLiveData>() {
- private val coroutineLiveDataLock = ReentrantLock()
- @GuardedBy("coroutineLiveDataLock") private var pendingCoroutineLiveData: LiveData>? = null
- @GuardedBy("coroutineLiveDataLock") private var cachedValue: AsyncResult? = null
-
- // This field is only access on the main thread, so no additional locking is necessary.
- private var dataProviderSubscriber: ObserveAsyncChange? = null
-
- init {
- // Schedule to retrieve data from the provider immediately.
- enqueueAsyncFunctionAsLiveData()
- }
-
- override fun onActive() {
- if (dataProviderSubscriber == null) {
- val subscriber: ObserveAsyncChange = {
- notifyUpdate()
- }
- asyncDataSubscriptionManager.subscribe(dataProvider.getId(), subscriber)
- dataProviderSubscriber = subscriber
- }
- super.onActive()
- }
-
- override fun onInactive() {
- super.onInactive()
- dataProviderSubscriber?.let {
- asyncDataSubscriptionManager.unsubscribe(dataProvider.getId(), it)
- dataProviderSubscriber = null
- }
- dequeuePendingCoroutineLiveData()
- }
-
- /**
- * Notifies this live data that it should re-run its asynchronous function and propagate any results.
- *
- * Note that if an existing operation is pending, it may complete but its results will not be propagated in favor
- * of the run started by this call. Note also that regardless of the current [AsyncResult] value of this live data,
- * the new value will overwrite it (e.g. it's possible to go from a failed to success state or vice versa).
- *
- * This needs to be run on the main thread due to [LiveData] limitations.
- */
- private fun notifyUpdate() {
- dequeuePendingCoroutineLiveData()
- enqueueAsyncFunctionAsLiveData()
- }
-
- /**
- * Enqueues the async function, but execution is based on whether this [LiveData] is active. See [MediatorLiveData]
- * docs for context.
- */
- private fun enqueueAsyncFunctionAsLiveData() {
- val coroutineLiveData = CoroutineLiveData(dispatcher) {
- dataProvider.retrieveData()
- }
- coroutineLiveDataLock.withLock {
- pendingCoroutineLiveData = coroutineLiveData
- addSource(coroutineLiveData) { computedValue ->
- // Only notify LiveData subscriptions if the value is actually different.
- if (cachedValue != computedValue) {
- value = computedValue
- cachedValue = value
- }
- }
- }
- }
-
- private fun dequeuePendingCoroutineLiveData() {
- coroutineLiveDataLock.withLock {
- pendingCoroutineLiveData?.let {
- removeSource(it)
- pendingCoroutineLiveData = null
- }
- }
- }
- }
-
- // TODO(#72): Replace this with AndroidX's CoroutineLiveData once the corresponding LiveData suspend job bug is fixed
- // and available.
- /** A [LiveData] whose value is derived from a suspended function. */
- private class CoroutineLiveData(
- private val dispatcher: CoroutineDispatcher,
- private val function: suspend () -> T
- ) : MutableLiveData() {
- private var runningJob: Job? = null
-
- override fun onActive() {
- super.onActive()
- if (runningJob == null) {
- val scope = CoroutineScope(dispatcher)
- runningJob = scope.launch {
- postValue(function())
- runningJob = null
- }
- }
- }
-
- override fun onInactive() {
- super.onInactive()
- runningJob?.cancel()
- runningJob = null
- }
- }
-}
diff --git a/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt b/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt
deleted file mode 100644
index f84fb92092a..00000000000
--- a/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package org.oppia.util.data
-
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.async
-import org.oppia.util.threading.BlockingDispatcher
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * An in-memory cache that provides blocking CRUD operations such that each operation is guaranteed to operate exactly
- * after any prior started operations began, and before any future operations. This class is thread-safe. Note that it's
- * safe to execute long-running operations in lambdas passed into the methods of this class.
- */
-class InMemoryBlockingCache private constructor(blockingDispatcher: CoroutineDispatcher, initialValue: T?) {
- private val blockingScope = CoroutineScope(blockingDispatcher)
-
- /**
- * The value of the cache. Note that this does not require a lock since it's only ever accessed via the blocking
- * dispatcher's single thread.
- */
- private var value: T? = initialValue
-
- /**
- * Returns a [Deferred] that, upon completion, guarantees that the cache has been recreated and initialized to the
- * specified value. The [Deferred] will be passed the most up-to-date state of the cache.
- */
- fun createAsync(newValue: T): Deferred {
- return blockingScope.async {
- value = newValue
- newValue
- }
- }
-
- /**
- * Returns a [Deferred] that provides the most-up-to-date value of the cache, after either retrieving the current
- * state (if defined), or calling the provided generator to create a new state and initialize the cache to that state.
- * The provided function must be thread-safe and should have no side effects.
- */
- fun createIfAbsentAsync(generate: suspend () -> T): Deferred {
- return blockingScope.async {
- val initedValue = value ?: generate()
- value = initedValue
- initedValue
- }
- }
-
- /**
- * Returns a [Deferred] that will provide the most-up-to-date value stored in the cache, or null if it's not yet
- * initialized.
- */
- fun readAsync(): Deferred {
- return blockingScope.async {
- value
- }
- }
-
- /**
- * Returns a [Deferred] similar to [readAsync], except this assumes the cache to have been created already otherwise
- * an exception will be thrown.
- */
- fun readIfPresentAsync(): Deferred {
- return blockingScope.async {
- checkNotNull(value) { "Expected to read the cache only after it's been created" }
- }
- }
-
- /**
- * Returns a [Deferred] that provides the most-up-to-date value of the cache, after atomically updating it based on
- * the specified update function. Note that the update function provided here must be thread-safe and should have no
- * side effects. This function is safe to call regardless of whether the cache has been created, meaning it can be
- * used also to initialize the cache.
- */
- fun updateAsync(update: suspend (T?) -> T): Deferred {
- return blockingScope.async {
- val updatedValue = update(value)
- value = updatedValue
- updatedValue
- }
- }
-
- /**
- * Returns a [Deferred] in the same way as [updateAsync], excepted this update is expected to occur after cache
- * creation otherwise an exception will be thrown.
- */
- fun updateIfPresentAsync(update: suspend (T) -> T): Deferred {
- return blockingScope.async {
- val updatedValue = update(checkNotNull(value) { "Expected to update the cache only after it's been created" })
- value = updatedValue
- updatedValue
- }
- }
-
- /**
- * Returns a [Deferred] that executes when this cache has been fully cleared, or if it's already been cleared.
- */
- fun deleteAsync(): Deferred {
- return blockingScope.async {
- value = null
- }
- }
-
- /**
- * Returns a [Deferred] that executes when checking the specified function on whether this cache should be deleted,
- * and returns whether it was deleted.
- *
- * Note that the provided function will not be called if the cache is already cleared.
- */
- fun maybeDeleteAsync(shouldDelete: suspend (T) -> Boolean): Deferred {
- return blockingScope.async {
- val valueSnapshot = value
- if (valueSnapshot != null && shouldDelete(valueSnapshot)) {
- value = null
- true
- } else false
- }
- }
-
- /**
- * Returns a [Deferred] in the same way as [maybeDeleteAsync], except the deletion function provided is guaranteed to
- * be called regardless of the state of the cache, and whose return value will be returned in this method's
- * [Deferred].
- */
- fun maybeForceDeleteAsync(shouldDelete: suspend (T?) -> Boolean): Deferred {
- return blockingScope.async {
- if (shouldDelete(value)) {
- value = null
- true
- } else false
- }
- }
-
- /** An injectable factory for [InMemoryBlockingCache]es. */
- @Singleton
- class Factory @Inject constructor(@BlockingDispatcher private val blockingDispatcher: CoroutineDispatcher) {
- /** Returns a new [InMemoryBlockingCache] with, optionally, the specified initial value. */
- fun create(initialValue: T? = null): InMemoryBlockingCache {
- return InMemoryBlockingCache(blockingDispatcher, initialValue)
- }
- }
-}
diff --git a/utility/src/main/java/org/oppia/util/threading/BackgroundDispatcher.kt b/utility/src/main/java/org/oppia/util/threading/BackgroundDispatcher.kt
deleted file mode 100644
index 17686df8638..00000000000
--- a/utility/src/main/java/org/oppia/util/threading/BackgroundDispatcher.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.oppia.util.threading
-
-import javax.inject.Qualifier
-
-/** Qualifier for injecting a coroutine executor that can be used for executing arbitrary background tasks. */
-@Qualifier annotation class BackgroundDispatcher
diff --git a/utility/src/main/java/org/oppia/util/threading/BlockingDispatcher.kt b/utility/src/main/java/org/oppia/util/threading/BlockingDispatcher.kt
deleted file mode 100644
index 40aa4199d6f..00000000000
--- a/utility/src/main/java/org/oppia/util/threading/BlockingDispatcher.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.oppia.util.threading
-
-import javax.inject.Qualifier
-
-/** Qualifier for injecting a coroutine executor that can be used for isolated, short blocking operations. */
-@Qualifier annotation class BlockingDispatcher
diff --git a/utility/src/main/java/org/oppia/util/threading/ConcurrentCollections.kt b/utility/src/main/java/org/oppia/util/threading/ConcurrentCollections.kt
deleted file mode 100644
index 107a86b1be0..00000000000
--- a/utility/src/main/java/org/oppia/util/threading/ConcurrentCollections.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package org.oppia.util.threading
-
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.ConcurrentLinkedQueue
-
-/** A custom [ConcurrentHashMap] of K to [ConcurrentLinkedQueue] with thread-safe enqueue and dequeue methods. */
-typealias ConcurrentQueueMap = ConcurrentHashMap>
-
-/** Enqueues the specified value into the queue corresponding to the specified key. */
-fun ConcurrentQueueMap.enqueue(key: K, value: V) {
- // Queue is guaranteed to be normalized at this point due to putIfAbsent being atomic. However, since this is not
- // synchronized with the map, it's possible for the queue to be removed from the map before making this addition.
- // Also, multiple threads accessing enqueue() at the same time can result in an arbitrary order of elements added to
- // the underlying queue.
- getQueue(key).add(value)
-}
-
-/**
- * Dequeues the specified value from the queue corresponding to the specified key, and returns whether it was
- * successful.
- */
-fun ConcurrentQueueMap.dequeue(key: K, value: V): Boolean {
- // See warning in enqueue() for scenarios when this deletion may fail.
- return getQueue(key).remove(value)
-}
-
-/** Returns the [ConcurrentLinkedQueue] corresponding to the specified key in a thread-safe way. */
-internal fun ConcurrentQueueMap.getQueue(key: K): ConcurrentLinkedQueue {
- // NB: This is a pre-24 compatible alternative to computeIfAbsent. See: https://stackoverflow.com/a/40665232.
- val queue: ConcurrentLinkedQueue? = get(key)
- if (queue == null) {
- val newQueue = ConcurrentLinkedQueue()
- return putIfAbsent(key, newQueue) ?: newQueue
- }
- return queue
-}
diff --git a/utility/src/main/java/org/oppia/util/threading/DispatcherModule.kt b/utility/src/main/java/org/oppia/util/threading/DispatcherModule.kt
deleted file mode 100644
index 1250489ddab..00000000000
--- a/utility/src/main/java/org/oppia/util/threading/DispatcherModule.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package org.oppia.util.threading
-
-import dagger.Module
-import dagger.Provides
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.asCoroutineDispatcher
-import java.util.concurrent.Executors
-import javax.inject.Singleton
-
-/**
- * Dagger [Module] that provides [CoroutineDispatcher]s that bind to [BackgroundDispatcher] and [BlockingDispatcher]
- * qualifiers.
- */
-@Module
-class DispatcherModule {
- @Provides
- @BackgroundDispatcher
- @Singleton
- fun provideBackgroundDispatcher(): CoroutineDispatcher {
- return Executors.newFixedThreadPool(/* nThreads= */ 4).asCoroutineDispatcher()
- }
-
- @Provides
- @BlockingDispatcher
- @Singleton
- fun provideBlockingDispatcher(): CoroutineDispatcher {
- return Executors.newSingleThreadExecutor().asCoroutineDispatcher()
- }
-}
diff --git a/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
index 668eb86e5bc..219fc1913b7 100644
--- a/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
+++ b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
@@ -1,8 +1,6 @@
package org.oppia.util.data
import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@@ -65,69 +63,6 @@ class AsyncResultTest {
assertThat(result.getErrorOrNull()).isNull()
}
- @Test
- fun testPendingAsyncResult_transformed_isStillPending() {
- val original = AsyncResult.pending()
-
- val transformed = original.transform { 0 }
-
- assertThat(transformed.isPending()).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testPendingAsyncResult_transformedAsync_isStillPending() = runBlockingTest {
- val original = AsyncResult.pending()
-
- val transformed = original.transformAsync { AsyncResult.success(0) }
-
- assertThat(transformed.isPending()).isTrue()
- }
-
- @Test
- fun testPendingResult_isEqualToAnotherPendingResult() {
- val result = AsyncResult.pending()
-
- // Two pending results are the same regardless of their types.
- assertThat(result).isEqualTo(AsyncResult.pending())
- }
-
- @Test
- fun testPendingResult_isNotEqualToFailedResult() {
- val result = AsyncResult.pending()
-
- assertThat(result).isNotEqualTo(AsyncResult.failed(UnsupportedOperationException()))
- }
-
- @Test
- fun testPendingResult_isNotEqualToSucceededResult() {
- val result = AsyncResult.pending()
-
- assertThat(result).isNotEqualTo(AsyncResult.success("Success"))
- }
-
- @Test
- fun testPendingResult_hashCode_isEqualToAnotherPendingResult() {
- val resultHash = AsyncResult.pending().hashCode()
-
- // Two pending results are the same regardless of their types.
- assertThat(resultHash).isEqualTo(AsyncResult.pending().hashCode())
- }
-
- @Test
- fun testPendingResult_hashCode_isNotEqualToSucceededResult() {
- val resultHash = AsyncResult.pending().hashCode()
-
- assertThat(resultHash).isNotEqualTo(AsyncResult.success("Success").hashCode())
- }
-
- @Test
- fun testPendingResult_hashCode_isNotEqualToFailedResult() {
- val resultHash = AsyncResult.pending().hashCode()
-
- assertThat(resultHash).isNotEqualTo(AsyncResult.failed(UnsupportedOperationException()).hashCode())
- }
-
/* Success tests. */
@Test
@@ -179,117 +114,6 @@ class AsyncResultTest {
assertThat(result.getErrorOrNull()).isNull()
}
- @Test
- fun testSucceededAsyncResult_transformed_hasTransformedValue() {
- val original = AsyncResult.success("value")
-
- val transformed = original.transform { 0 }
-
- assertThat(transformed.getOrThrow()).isEqualTo(0)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testSucceededAsyncResult_transformedAsyncPending_isPending() = runBlockingTest {
- val original = AsyncResult.success("value")
-
- val transformed = original.transformAsync { AsyncResult.pending() }
-
- assertThat(transformed.isPending()).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testSucceededAsyncResult_transformedAsyncSuccess_hasTransformedValue() = runBlockingTest {
- val original = AsyncResult.success("value")
-
- val transformed = original.transformAsync { AsyncResult.success(0) }
-
- assertThat(transformed.getOrThrow()).isEqualTo(0)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testSucceededAsyncResult_transformedAsyncFailed_isFailure() = runBlockingTest {
- val original = AsyncResult.success("value")
-
- val transformed = original.transformAsync { AsyncResult.failed(UnsupportedOperationException()) }
-
- // Note that the failure is not chained since the transform function was responsible for 'throwing' it.
- assertThat(transformed.getErrorOrNull()).isInstanceOf(UnsupportedOperationException::class.java)
- }
-
- @Test
- fun testSucceededResult_isNotEqualToPendingResult() {
- val result = AsyncResult.success("Success")
-
- assertThat(result).isNotEqualTo(AsyncResult.pending())
- }
-
- @Test
- fun testSucceededResult_isEqualToSameSucceededResult() {
- val result = AsyncResult.success("Success")
-
- assertThat(result).isEqualTo(AsyncResult.success("Success"))
- }
-
- @Test
- fun testSucceededResult_isNotEqualToDifferentSucceededResult() {
- val result = AsyncResult.success("Success")
-
- assertThat(result).isNotEqualTo(AsyncResult.success("Other value"))
- }
-
- @Test
- fun testSucceededResult_isNotEqualToDifferentTypedSucceededResult() {
- val result = AsyncResult.success("0")
-
- assertThat(result).isNotEqualTo(AsyncResult.success(0))
- }
-
- @Test
- fun testSucceededResult_isNotEqualToFailedResult() {
- val result = AsyncResult.success("Success")
-
- assertThat(result).isNotEqualTo(AsyncResult.failed(UnsupportedOperationException()))
- }
-
- @Test
- fun testSucceededResult_hashCode_isNotEqualToPendingResult() {
- val resultHash = AsyncResult.success("Success").hashCode()
-
- // Two pending results are the same regardless of their types.
- assertThat(resultHash).isNotEqualTo(AsyncResult.pending().hashCode())
- }
-
- @Test
- fun testSucceededResult_hashCode_isEqualToSameSucceededResult() {
- val resultHash = AsyncResult.success("Success").hashCode()
-
- assertThat(resultHash).isEqualTo(AsyncResult.success("Success").hashCode())
- }
-
- @Test
- fun testSucceededResult_hashCode_isNotEqualToDifferentSucceededResult() {
- val resultHash = AsyncResult.success("Success").hashCode()
-
- assertThat(resultHash).isNotEqualTo(AsyncResult.success("Other value").hashCode())
- }
-
- @Test
- fun testSucceededResult_hashCode_isNotEqualToDifferentTypedSucceededResult() {
- val resultHash = AsyncResult.success("0").hashCode()
-
- assertThat(resultHash).isNotEqualTo(AsyncResult.success(0))
- }
-
- @Test
- fun testSucceededResult_hashCode_isNotEqualToFailedResult() {
- val resultHash = AsyncResult.success("Success").hashCode()
-
- assertThat(resultHash).isNotEqualTo(AsyncResult.failed(UnsupportedOperationException()).hashCode())
- }
-
/* Failure tests. */
@Test
@@ -340,88 +164,4 @@ class AsyncResultTest {
assertThat(result.getErrorOrNull()).isInstanceOf(UnsupportedOperationException::class.java)
}
-
- @Test
- fun testFailedAsyncResult_transformed_throwsChainedFailureException_withCorrectRootCause() {
- val result = AsyncResult.failed(UnsupportedOperationException())
-
- val transformed = result.transform { 0 }
-
- assertThat(transformed.getErrorOrNull()).isInstanceOf(AsyncResult.ChainedFailureException::class.java)
- assertThat(transformed.getErrorOrNull()).hasCauseThat().isInstanceOf(UnsupportedOperationException::class.java)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testFailedAsyncResult_transformedAsync_throwsChainedFailureException_withCorrectRootCause() = runBlockingTest {
- val result = AsyncResult.failed(UnsupportedOperationException())
-
- val transformed = result.transformAsync { AsyncResult.success(0) }
-
- assertThat(transformed.getErrorOrNull()).isInstanceOf(AsyncResult.ChainedFailureException::class.java)
- assertThat(transformed.getErrorOrNull()).hasCauseThat().isInstanceOf(UnsupportedOperationException::class.java)
- }
-
- @Test
- fun testFailedResult_isNotEqualToPendingResult() {
- val result = AsyncResult.failed(UnsupportedOperationException("Reason"))
-
- assertThat(result).isNotEqualTo(AsyncResult.pending())
- }
-
- @Test
- fun testFailedResult_isNotEqualToSucceededResult() {
- val result = AsyncResult.failed(UnsupportedOperationException("Reason"))
-
- assertThat(result).isNotEqualTo(AsyncResult.success("Success"))
- }
-
- @Test
- fun testFailedResult_isEqualToFailedResultWithSameExceptionObject() {
- val failure = UnsupportedOperationException("Reason")
-
- val result = AsyncResult.failed(failure)
-
- assertThat(result).isEqualTo(AsyncResult.failed(failure))
- }
-
- @Test
- fun testFailedResult_isNotEqualToFailedResultWithDifferentInstanceOfSameExceptionType() {
- val result = AsyncResult.failed(UnsupportedOperationException("Reason"))
-
- // Different exceptions have different stack traces, so they can't be equal despite similar constructions.
- assertThat(result).isNotEqualTo(AsyncResult.failed(UnsupportedOperationException("Reason")))
- }
-
- @Test
- fun testFailedResult_hashCode_isNotEqualToPendingResult() {
- val resultHash = AsyncResult.failed(UnsupportedOperationException("Reason")).hashCode()
-
- // Two pending results are the same regardless of their types.
- assertThat(resultHash).isNotEqualTo(AsyncResult.pending().hashCode())
- }
-
- @Test
- fun testFailedResult_hashCode_isNotEqualToSucceededResult() {
- val resultHash = AsyncResult.failed(UnsupportedOperationException("Reason")).hashCode()
-
- assertThat(resultHash).isNotEqualTo(AsyncResult.success("Success").hashCode())
- }
-
- @Test
- fun testFailedResult_hashCode_isEqualToFailedResultWithSameExceptionObject() {
- val failure = UnsupportedOperationException("Reason")
-
- val resultHash = AsyncResult.failed(failure).hashCode()
-
- assertThat(resultHash).isEqualTo(AsyncResult.failed(failure).hashCode())
- }
-
- @Test
- fun testFailedResult_hashCode_isNotEqualToFailedResultWithDifferentInstanceOfSameExceptionType() {
- val resultHash = AsyncResult.failed(UnsupportedOperationException("Reason")).hashCode()
-
- // Different exceptions have different stack traces, so they can't be equal despite similar constructions.
- assertThat(resultHash).isNotEqualTo(AsyncResult.failed(UnsupportedOperationException("Reason")).hashCode())
- }
}
diff --git a/utility/src/test/java/org/oppia/util/data/DataProvidersTest.kt b/utility/src/test/java/org/oppia/util/data/DataProvidersTest.kt
deleted file mode 100644
index c1eedae7589..00000000000
--- a/utility/src/test/java/org/oppia/util/data/DataProvidersTest.kt
+++ /dev/null
@@ -1,956 +0,0 @@
-package org.oppia.util.data
-
-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.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.async
-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.reset
-import org.mockito.Mockito.verify
-import org.mockito.Mockito.verifyZeroInteractions
-import org.mockito.junit.MockitoJUnit
-import org.mockito.junit.MockitoRule
-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
-
-private const val BASE_PROVIDER_ID = "base_id"
-private const val OTHER_PROVIDER_ID = "other_id"
-private const val TRANSFORMED_PROVIDER_ID = "transformed_id"
-private const val FIRST_STR_VALUE = "first str value"
-private const val SECOND_STR_VALUE = "second and longer str value"
-private const val TRANSFORMED_FIRST_INT_VALUE = FIRST_STR_VALUE.length
-private const val TRANSFORMED_SECOND_INT_VALUE = SECOND_STR_VALUE.length
-
-/** Tests for [DataProviders], [DataProvider]s, and [AsyncDataSubscriptionManager]. */
-@RunWith(AndroidJUnit4::class)
-@Config(manifest = Config.NONE)
-class DataProvidersTest {
- @Rule
- @JvmField
- val mockitoRule: MockitoRule = MockitoJUnit.rule()
-
- @Inject
- lateinit var dataProviders: DataProviders
-
- @Inject
- lateinit var asyncDataSubscriptionManager: AsyncDataSubscriptionManager
-
- @Inject
- @field:TestDispatcher
- lateinit var testDispatcher: CoroutineDispatcher
-
- // TODO(#89): Remove the need for this custom scope by allowing tests to instead rely on rely background dispatchers.
- /**
- * A [CoroutineScope] with a dispatcher that ensures its corresponding task is run on a background thread rather than
- * synchronously on the test thread, allowing blocking operations.
- */
- @ExperimentalCoroutinesApi
- private val backgroundTestCoroutineScope by lazy {
- CoroutineScope(backgroundTestCoroutineDispatcher)
- }
-
- @ExperimentalCoroutinesApi
- private val backgroundTestCoroutineDispatcher by lazy {
- TestCoroutineDispatcher()
- }
-
- @Mock
- lateinit var mockStringLiveDataObserver: Observer>
-
- @Mock
- lateinit var mockIntLiveDataObserver: Observer>
-
- @Captor
- lateinit var stringResultCaptor: ArgumentCaptor>
-
- @Captor
- lateinit var intResultCaptor: ArgumentCaptor>
-
- private var inMemoryCachedStr: String? = null
-
- @Before
- @ExperimentalCoroutinesApi
- fun setUp() {
- setUpTestApplicationComponent()
- Dispatchers.setMain(testDispatcher)
- }
-
- @After
- @ExperimentalCoroutinesApi
- fun tearDown() {
- Dispatchers.resetMain()
- }
-
- // Note: custom data providers aren't explicitly tested since their interaction with the infrastructure is tested
- // through the providers created by DataProviders, and through other custom data providers in the stack.
-
- @Test
- fun testInMemoryDataProvider_toLiveData_deliversInMemoryValue() {
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testInMemoryDataProvider_toLiveData_notifies_doesNotDeliverSameValueAgain() = runBlockingTest(testDispatcher) {
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- advanceUntilIdle()
-
- reset(mockStringLiveDataObserver)
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- // The observer should not be notified again since the value hasn't changed.
- verifyZeroInteractions(mockStringLiveDataObserver)
- }
-
- @Test
- fun testInMemoryDataProvider_toLiveData_withChangedValue_beforeReg_deliversSecondValue() {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
-
- inMemoryCachedStr = SECOND_STR_VALUE
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(SECOND_STR_VALUE)
- }
-
- @Test
- fun testInMemoryDataProvider_toLiveData_withChangedValue_afterReg_deliversFirstValue() {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testInMemoryDataProvider_changedValueAfterReg_notified_deliversSecondValue() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(SECOND_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testInMemoryDataProvider_changedValue_notifiesDiffProvider_deliversFirstVal() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(OTHER_PROVIDER_ID)
- advanceUntilIdle()
-
- // The first value should be observed since a completely different provider was notified.
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- fun testInMemoryDataProvider_toLiveData_withObserver_doesCallFunction() {
- // It would be nice to use a mock of the lambda (e.g. https://stackoverflow.com/a/53306974/3689782), but this
- // apparently does not work with Robolectric: https://github.com/robolectric/robolectric/issues/3688.
- var fakeLoadMemoryCallbackCalled = false
- val fakeLoadMemoryCallback: () -> String = {
- fakeLoadMemoryCallbackCalled = true
- FIRST_STR_VALUE
- }
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID, fakeLoadMemoryCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- // With a LiveData observer, the load memory callback should be called.
- assertThat(fakeLoadMemoryCallbackCalled).isTrue()
- }
-
- @Test
- fun testInMemoryDataProvider_toLiveData_noObserver_doesNotCallFunction() {
- var fakeLoadMemoryCallbackCalled = false
- val fakeLoadMemoryCallback: () -> String = {
- fakeLoadMemoryCallbackCalled = true
- FIRST_STR_VALUE
- }
- val dataProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID, fakeLoadMemoryCallback)
-
- dataProviders.convertToLiveData(dataProvider)
-
- // Without a LiveData observer, the load memory callback should never be called.
- assertThat(fakeLoadMemoryCallbackCalled).isFalse()
- }
-
- @Test
- fun testInMemoryDataProvider_toLiveData_throwsException_deliversFailure() {
- val dataProvider: DataProvider =
- dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { throw IllegalStateException("Failed") }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isFailure()).isTrue()
- assertThat(stringResultCaptor.value.getErrorOrNull()).isInstanceOf(IllegalStateException::class.java)
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_deliversInMemoryValue() {
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.success(FIRST_STR_VALUE)
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_withChangedValue_beforeReg_deliversSecondValue() {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.success(inMemoryCachedStr!!)
- }
-
- inMemoryCachedStr = SECOND_STR_VALUE
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(SECOND_STR_VALUE)
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_withChangedValue_afterReg_deliversFirstValue() {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.success(inMemoryCachedStr!!)
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testAsyncInMemoryDataProvider_changedValueAfterReg_notified_deliversValueTwo() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.success(inMemoryCachedStr!!)
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(SECOND_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testAsyncInMemoryDataProvider_blockingFunction_doesNotDeliver() = runBlockingTest(testDispatcher) {
- // Ensure the suspend operation is initially blocked.
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { FIRST_STR_VALUE }
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.success(blockingOperation.await())
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- advanceUntilIdle()
-
- // The observer should never be called since the underlying async function hasn't yet completed.
- verifyZeroInteractions(mockStringLiveDataObserver)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testAsyncInMemoryDataProvider_blockingFunctionCompleted_deliversValue() = runBlockingTest(testDispatcher) {
- // Ensure the suspend operation is initially blocked.
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { FIRST_STR_VALUE }
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.success(blockingOperation.await())
- }
-
- // Start observing the provider, then complete its suspend function.
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
- // Finish the blocking operation.
- backgroundTestCoroutineDispatcher.advanceUntilIdle()
- advanceUntilIdle()
-
- // The provider will deliver a value immediately when the suspend function completes (no additional notification is
- // needed).
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_withObserver_doesCallFunction() {
- var fakeLoadMemoryCallbackCalled = false
- val fakeLoadMemoryCallback: suspend () -> AsyncResult = {
- fakeLoadMemoryCallbackCalled = true
- AsyncResult.success(FIRST_STR_VALUE)
- }
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID, fakeLoadMemoryCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- // With a LiveData observer, the load memory callback should be called.
- assertThat(fakeLoadMemoryCallbackCalled).isTrue()
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_noObserver_doesNotCallFunction() {
- var fakeLoadMemoryCallbackCalled = false
- val fakeLoadMemoryCallback: suspend () -> AsyncResult = {
- fakeLoadMemoryCallbackCalled = true
- AsyncResult.success(FIRST_STR_VALUE)
- }
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID, fakeLoadMemoryCallback)
-
- dataProviders.convertToLiveData(dataProvider)
-
- // Without a LiveData observer, the load memory callback should never be called.
- assertThat(fakeLoadMemoryCallbackCalled).isFalse()
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_pendingResult_deliversPendingResult() {
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) { AsyncResult.pending() }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isPending()).isTrue()
- }
-
- @Test
- fun testAsyncInMemoryDataProvider_toLiveData_failure_deliversFailure() {
- val dataProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.failed(IllegalStateException("Failure"))
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockStringLiveDataObserver)
-
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isFailure()).isTrue()
- assertThat(stringResultCaptor.value.getErrorOrNull()).isInstanceOf(IllegalStateException::class.java)
- }
-
- @Test
- fun testTransform_toLiveData_deliversTransformedValue() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_FIRST_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransform_toLiveData_differentValue_notifiesBase_deliversXformedValueTwo() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- // Notifying the base results in observers of the transformed provider also being called.
- verify(mockIntLiveDataObserver, atLeastOnce()).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_SECOND_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransform_toLiveData_differentValue_notifiesXform_deliversXformedValueTwo() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(TRANSFORMED_PROVIDER_ID)
- advanceUntilIdle()
-
- // Notifying the transformed provider has the same result as notifying the base provider.
- verify(mockIntLiveDataObserver, atLeastOnce()).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_SECOND_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransform_differentValue_notifiesBase_observeBase_deliversSecondValue() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(baseProvider).observeForever(mockStringLiveDataObserver)
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- // Having a transformed data provider with an observer does not change the base's notification behavior.
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(SECOND_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransform_differentValue_notifiesXformed_observeBase_deliversFirstValue() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(baseProvider).observeForever(mockStringLiveDataObserver)
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(TRANSFORMED_PROVIDER_ID)
- advanceUntilIdle()
-
- // However, notifying that the transformed provider has changed should not affect base subscriptions even if the
- // base has changed.
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- fun testTransform_toLiveData_basePending_deliversPending() {
- val baseProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) { AsyncResult.pending() }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isPending()).isTrue()
- }
-
- @Test
- fun testTransform_toLiveData_baseFailure_deliversFailure() {
- val baseProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.failed(IllegalStateException("Failed"))
- }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) { transformString(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isFailure()).isTrue()
- assertThat(intResultCaptor.value.getErrorOrNull()).isInstanceOf(AsyncResult.ChainedFailureException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasCauseThat().isInstanceOf(IllegalStateException::class.java)
- }
-
- @Test
- fun testTransform_toLiveData_withObserver_callsTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: (String) -> Int = {
- fakeTransformCallbackCalled = true
- transformString(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // A successful base provider with a LiveData observer should result in the transform function being called.
- assertThat(fakeTransformCallbackCalled).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransform_toLiveData_noObserver_doesNotCallTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: (String) -> Int = {
- fakeTransformCallbackCalled = true
- transformString(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider)
-
- // Without an observer, the transform method should not be called.
- assertThat(fakeTransformCallbackCalled).isFalse()
- }
-
- @Test
- fun testTransform_toLiveData_basePending_doesNotCallTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: (String) -> Int = {
- fakeTransformCallbackCalled = true
- transformString(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) { AsyncResult.pending() }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // The transform method shouldn't be called if the base provider is in a pending state.
- assertThat(fakeTransformCallbackCalled).isFalse()
- }
-
- @Test
- fun testTransform_toLiveData_baseFailure_doesNotCallTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: (String) -> Int = {
- fakeTransformCallbackCalled = true
- transformString(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) {
- AsyncResult.failed(IllegalStateException("Base failure"))
- }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // The transform method shouldn't be called if the base provider is in a failure state.
- assertThat(fakeTransformCallbackCalled).isFalse()
- }
-
- @Test
- fun testTransform_toLiveData_throwsException_deliversFailure() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) {
- throw IllegalStateException("Transform failure")
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // Note that the exception type here is not chained since the failure occurred in the transform function.
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isFailure()).isTrue()
- assertThat(intResultCaptor.value.getErrorOrNull()).isInstanceOf(IllegalStateException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasMessageThat().contains("Transform failure")
- }
-
- @Test
- fun testTransform_toLiveData_baseThrowsException_deliversFailure() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) {
- throw IllegalStateException("Base failure")
- }
- val dataProvider = dataProviders.transform(TRANSFORMED_PROVIDER_ID, baseProvider) {
- @Suppress("UNREACHABLE_CODE") // This is expected to be unreachable code for this test.
- transformString(it)
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isFailure()).isTrue()
- assertThat(intResultCaptor.value.getErrorOrNull()).isInstanceOf(AsyncResult.ChainedFailureException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasCauseThat().isInstanceOf(IllegalStateException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasCauseThat().hasMessageThat().contains("Base failure")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_deliversTransformedValue() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_FIRST_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_diffValue_notifiesBase_deliversXformedValueTwo() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- // Notifying the base results in observers of the transformed provider also being called.
- verify(mockIntLiveDataObserver, atLeastOnce()).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_SECOND_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_diffVal_notifiesXform_deliversXformedValueTwo() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(TRANSFORMED_PROVIDER_ID)
- advanceUntilIdle()
-
- // Notifying the transformed provider has the same result as notifying the base provider.
- verify(mockIntLiveDataObserver, atLeastOnce()).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_SECOND_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_differentValue_notifiesBase_observeBase_deliversSecondVal() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(baseProvider).observeForever(mockStringLiveDataObserver)
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(BASE_PROVIDER_ID)
- advanceUntilIdle()
-
- // Having a transformed data provider with an observer does not change the base's notification behavior.
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(SECOND_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_diffValue_notifiesXformed_observeBase_deliversFirstVal() = runBlockingTest(testDispatcher) {
- inMemoryCachedStr = FIRST_STR_VALUE
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { inMemoryCachedStr!! }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(baseProvider).observeForever(mockStringLiveDataObserver)
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- inMemoryCachedStr = SECOND_STR_VALUE
- asyncDataSubscriptionManager.notifyChange(TRANSFORMED_PROVIDER_ID)
- advanceUntilIdle()
-
- // However, notifying that the transformed provider has changed should not affect base subscriptions even if the
- // base has changed.
- verify(mockStringLiveDataObserver, atLeastOnce()).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_blockingFunction_doesNotDeliverValue() = runBlockingTest(testDispatcher) {
- // Block transformStringAsync().
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- advanceUntilIdle()
-
- // No value should be delivered since the async function is blocked.
- verifyZeroInteractions(mockIntLiveDataObserver)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_blockingFunction_completed_deliversXformedVal() = runBlockingTest(testDispatcher) {
- // Block transformStringAsync().
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- backgroundTestCoroutineDispatcher.advanceUntilIdle() // Run transformStringAsync()
- advanceUntilIdle()
-
- // The value should now be delivered since the async function was unblocked.
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isSuccess()).isTrue()
- assertThat(intResultCaptor.value.getOrThrow()).isEqualTo(TRANSFORMED_FIRST_INT_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_blockingFunction_baseObserved_deliversFirstVal() = runBlockingTest(testDispatcher) {
- // Block transformStringAsync().
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(baseProvider).observeForever(mockStringLiveDataObserver)
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
- advanceUntilIdle()
-
- // Verify that even though the transformed provider is blocked, the base can still properly publish changes.
- verify(mockStringLiveDataObserver).onChanged(stringResultCaptor.capture())
- assertThat(stringResultCaptor.value.isSuccess()).isTrue()
- assertThat(stringResultCaptor.value.getOrThrow()).isEqualTo(FIRST_STR_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_transformedPending_deliversPending() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) {
- AsyncResult.pending()
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // The transformation result yields a pending delivered result.
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isPending()).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_transformedFailure_deliversFailure() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) {
- AsyncResult.failed(IllegalStateException("Transform failure"))
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // Note that the failure exception in this case is not chained since the failure occurred in the transform function.
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isFailure()).isTrue()
- assertThat(intResultCaptor.value.getErrorOrNull()).isInstanceOf(IllegalStateException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasMessageThat().contains("Transform failure")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_basePending_deliversPending() {
- val baseProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) { AsyncResult.pending() }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) { transformStringAsync(it) }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // Since the base provider is pending, so is the transformed provider.
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isPending()).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_baseFailure_deliversFailure() {
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) {
- throw IllegalStateException("Base failure")
- }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider) {
- @Suppress("UNREACHABLE_CODE") // This code is intentionally unreachable for this test case.
- transformStringAsync(it)
- }
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // Note that the failure exception in this case is not chained since the failure occurred in the transform function.
- verify(mockIntLiveDataObserver).onChanged(intResultCaptor.capture())
- assertThat(intResultCaptor.value.isFailure()).isTrue()
- assertThat(intResultCaptor.value.getErrorOrNull()).isInstanceOf(AsyncResult.ChainedFailureException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasCauseThat().isInstanceOf(IllegalStateException::class.java)
- assertThat(intResultCaptor.value.getErrorOrNull()).hasCauseThat().hasMessageThat().contains("Base failure")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_withObserver_callsTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: suspend (String) -> AsyncResult = {
- fakeTransformCallbackCalled = true
- transformStringAsync(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // Since there's an observer, the transform method should be called.
- assertThat(fakeTransformCallbackCalled).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_noObserver_doesNotCallTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: suspend (String) -> AsyncResult = {
- fakeTransformCallbackCalled = true
- transformStringAsync(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) { FIRST_STR_VALUE }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider)
-
- // Without an observer, the transform method should not be called.
- assertThat(fakeTransformCallbackCalled).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_basePending_doesNotCallTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: suspend (String) -> AsyncResult = {
- fakeTransformCallbackCalled = true
- transformStringAsync(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProviderAsync(BASE_PROVIDER_ID) { AsyncResult.pending() }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // A pending base provider should result in the transform method not being called.
- assertThat(fakeTransformCallbackCalled).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testTransformAsync_toLiveData_baseFailure_doesNotCallTransform() {
- var fakeTransformCallbackCalled = false
- val fakeTransformCallback: suspend (String) -> AsyncResult = {
- fakeTransformCallbackCalled = true
- transformStringAsync(it)
- }
- val baseProvider = dataProviders.createInMemoryDataProvider(BASE_PROVIDER_ID) {
- throw IllegalStateException("Base failure")
- }
- val dataProvider = dataProviders.transformAsync(TRANSFORMED_PROVIDER_ID, baseProvider, fakeTransformCallback)
-
- dataProviders.convertToLiveData(dataProvider).observeForever(mockIntLiveDataObserver)
-
- // A base provider failure should result in the transform method not being called.
- assertThat(fakeTransformCallbackCalled).isFalse()
- }
-
- private fun transformString(str: String): Int {
- return str.length
- }
-
- /**
- * Transforms the specified string into an integer in the same way as [transformString], except in a blocking context
- * using [backgroundTestCoroutineDispatcher].
- */
- @ExperimentalCoroutinesApi
- private suspend fun transformStringAsync(str: String): AsyncResult {
- val deferred = backgroundTestCoroutineScope.async { transformString(str) }
- deferred.await()
- return AsyncResult.success(deferred.getCompleted())
- }
-
- private fun setUpTestApplicationComponent() {
- DaggerDataProvidersTest_TestApplicationComponent.builder()
- .setApplication(ApplicationProvider.getApplicationContext())
- .build()
- .inject(this)
- }
-
- @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(#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(dataProvidersTest: DataProvidersTest)
- }
-}
diff --git a/utility/src/test/java/org/oppia/util/data/InMemoryBlockingCacheTest.kt b/utility/src/test/java/org/oppia/util/data/InMemoryBlockingCacheTest.kt
deleted file mode 100644
index d5f52b2a033..00000000000
--- a/utility/src/test/java/org/oppia/util/data/InMemoryBlockingCacheTest.kt
+++ /dev/null
@@ -1,798 +0,0 @@
-package org.oppia.util.data
-
-import android.app.Application
-import android.content.Context
-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.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.async
-import kotlinx.coroutines.test.TestCoroutineDispatcher
-import kotlinx.coroutines.test.runBlockingTest
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.oppia.util.threading.BlockingDispatcher
-import org.robolectric.annotation.Config
-import java.lang.IllegalStateException
-import java.lang.NullPointerException
-import javax.inject.Inject
-import javax.inject.Qualifier
-import javax.inject.Singleton
-import kotlin.reflect.KClass
-import kotlin.reflect.full.cast
-import kotlin.test.fail
-
-private const val INITIALIZED_CACHE_VALUE = "inited cache value"
-private const val CREATED_CACHE_VALUE = "created cache value"
-private const val RECREATED_CACHE_VALUE = "recreated cache value"
-private const val CREATED_ASYNC_VALUE = "created async value"
-private const val UPDATED_ASYNC_VALUE = "updated async value"
-
-/** Tests for [InMemoryBlockingCache]. */
-@RunWith(AndroidJUnit4::class)
-@Config(manifest = Config.NONE)
-class InMemoryBlockingCacheTest {
- @Inject
- lateinit var cacheFactory: InMemoryBlockingCache.Factory
-
- @ExperimentalCoroutinesApi
- @Inject
- @field:TestDispatcher
- lateinit var testDispatcher: TestCoroutineDispatcher
-
- // TODO(#89): Remove the need for this custom scope by allowing tests to instead rely on rely background dispatchers.
- /**
- * A [CoroutineScope] with a dispatcher that ensures its corresponding task is run on a background thread rather than
- * synchronously on the test thread, allowing blocking operations.
- */
- @ExperimentalCoroutinesApi
- private val backgroundTestCoroutineScope by lazy {
- CoroutineScope(backgroundTestCoroutineDispatcher)
- }
-
- @ExperimentalCoroutinesApi
- private val backgroundTestCoroutineDispatcher by lazy {
- TestCoroutineDispatcher()
- }
-
- @Before
- @ExperimentalCoroutinesApi
- fun setUp() {
- setUpTestApplicationComponent()
- // Intentionally pause the test dispatcher to help test that the blocking cache's order is sequential even if
- // multiple operations are stacked up and executed in quick succession.
- testDispatcher.pauseDispatcher()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testReadCache_withoutInitialValue_providesNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val cachedValue = awaitCompletion(cache.readAsync())
-
- assertThat(cachedValue).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testReadCache_withInitialValue_providesInitialValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val cachedValue = awaitCompletion(cache.readAsync())
-
- assertThat(cachedValue).isEqualTo(INITIALIZED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateCache_withoutInitialValue_returnsCreatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val createResult = cache.createAsync(CREATED_CACHE_VALUE)
-
- assertThat(awaitCompletion(createResult)).isEqualTo(CREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateCache_withoutInitialValue_setsValueOfCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- doNotAwaitCompletion(cache.createAsync(CREATED_CACHE_VALUE))
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(CREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testRecreateCache_withInitialValue_returnsCreatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val createResult = cache.createAsync(RECREATED_CACHE_VALUE)
-
- assertThat(awaitCompletion(createResult)).isEqualTo(RECREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testRecreateCache_withInitialValue_setsValueOfCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.createAsync(RECREATED_CACHE_VALUE))
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(RECREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_withoutInitialValue_returnsCreatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val createResult = cache.createIfAbsentAsync { CREATED_ASYNC_VALUE }
-
- assertThat(awaitCompletion(createResult)).isEqualTo(CREATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_withoutInitialValue_setsValueOfCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- doNotAwaitCompletion(cache.createIfAbsentAsync { CREATED_ASYNC_VALUE })
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(CREATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_withInitialValue_returnsCurrentCacheValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val createResult = cache.createIfAbsentAsync { CREATED_ASYNC_VALUE }
-
- // Because the cache is already initialized, it's not recreated.
- assertThat(awaitCompletion(createResult)).isEqualTo(INITIALIZED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_withInitialValue_doesNotChangeCacheValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.createIfAbsentAsync { CREATED_ASYNC_VALUE })
-
- // Because the cache is already initialized, it's not recreated.
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(INITIALIZED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_emptyCache_blockingFunction_createIsNotComplete() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create()
- backgroundTestCoroutineDispatcher.pauseDispatcher()
-
- val blockingOperation = backgroundTestCoroutineScope.async { CREATED_ASYNC_VALUE }
- val createOperation = cache.createIfAbsentAsync { blockingOperation.await() }
-
- // The blocking operation should also block creation.
- assertThat(createOperation.isCompleted).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_emptyCache_blockingFunction_completed_createCompletes() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create()
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { CREATED_ASYNC_VALUE }
- val createOperation = cache.createIfAbsentAsync { blockingOperation.await() }
-
- backgroundTestCoroutineDispatcher.advanceUntilIdle()
-
- // Completing the blocking operation should complete creation.
- assertThat(createOperation.isCompleted).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testReadIfPresent_withInitialValue_providesInitialValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val cachedValue = awaitCompletion(cache.readIfPresentAsync())
-
- assertThat(cachedValue).isEqualTo(INITIALIZED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testReadIfPresent_afterCreate_providesCachedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
- doNotAwaitCompletion(cache.createAsync(CREATED_CACHE_VALUE))
-
- val cachedValue = awaitCompletion(cache.readIfPresentAsync())
-
- assertThat(cachedValue).isEqualTo(CREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testReadIfPresent_withoutInitialValue_throwsException() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val deferredRead = cache.readIfPresentAsync()
-
- val exception = assertThrowsAsync(IllegalStateException::class) { awaitCompletion(deferredRead) }
- assertThat(exception).hasMessageThat().contains("Expected to read the cache only after it's been created")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_withoutInitialValue_returnsUpdatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val returnedValue = awaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- assertThat(returnedValue).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_withoutInitialValue_changesCachedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- doNotAwaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_withInitialValue_returnsUpdatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val returnedValue = awaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- assertThat(returnedValue).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_withInitialValue_changesCachedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_blockingFunction_blocksUpdate() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create()
- backgroundTestCoroutineDispatcher.pauseDispatcher()
-
- val blockingOperation = backgroundTestCoroutineScope.async { UPDATED_ASYNC_VALUE }
- val updateOperation = cache.updateAsync { blockingOperation.await() }
-
- // The blocking operation should also block updating.
- assertThat(updateOperation.isCompleted).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_blockingFunction_completed_updateCompletes() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create()
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { UPDATED_ASYNC_VALUE }
- val updateOperation = cache.updateAsync { blockingOperation.await() }
-
- backgroundTestCoroutineDispatcher.advanceUntilIdle()
-
- // Completing the blocking operation should complete updating.
- assertThat(updateOperation.isCompleted).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateIfPresent_withInitialValue_returnsUpdatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val returnedValue = awaitCompletion(cache.updateIfPresentAsync { UPDATED_ASYNC_VALUE })
-
- // Since the cache is initialized, it should be updated.
- assertThat(returnedValue).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateIfPresent_withInitialValue_changesCachedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.updateIfPresentAsync { UPDATED_ASYNC_VALUE })
-
- // Since the cache is initialized, it should be updated.
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateIfPresent_withoutInitialValue_throwsException() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val deferredUpdate = cache.updateIfPresentAsync { UPDATED_ASYNC_VALUE }
-
- // The operation should fail since the method expects the cache to be initialized.
- val exception = assertThrowsAsync(IllegalStateException::class) { awaitCompletion(deferredUpdate) }
- assertThat(exception).hasMessageThat().contains("Expected to update the cache only after it's been created")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateIfPresent_initedCache_blockingFunction_blocksUpdate() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- backgroundTestCoroutineDispatcher.pauseDispatcher()
-
- val blockingOperation = backgroundTestCoroutineScope.async { UPDATED_ASYNC_VALUE }
- val updateOperation = cache.updateIfPresentAsync { blockingOperation.await() }
-
- // The blocking operation should also block updating.
- assertThat(updateOperation.isCompleted).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateIfPresent_initedCache_blockingFunction_completed_updateCompletes() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { UPDATED_ASYNC_VALUE }
- val updateOperation = cache.updateIfPresentAsync { blockingOperation.await() }
-
- backgroundTestCoroutineDispatcher.advanceUntilIdle()
-
- // Completing the blocking operation should complete updating.
- assertThat(updateOperation.isCompleted).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testDeleteAsync_withoutInitialValue_keepsCacheNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- doNotAwaitCompletion(cache.deleteAsync())
-
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testDeleteAsync_withInitialValue_setsCacheNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.deleteAsync())
-
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testDeleteAsync_withRecreatedValue_setsCacheNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.createAsync(RECREATED_CACHE_VALUE))
-
- doNotAwaitCompletion(cache.deleteAsync())
-
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testDeleteAsync_withUpdatedValue_setsCacheNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- doNotAwaitCompletion(cache.deleteAsync())
-
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testRecreateCache_afterDeletion_returnsCreatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- val createResult = cache.createAsync(RECREATED_CACHE_VALUE)
-
- assertThat(awaitCompletion(createResult)).isEqualTo(RECREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testRecreateCache_afterDeletion_setsValueOfCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- doNotAwaitCompletion(cache.createAsync(RECREATED_CACHE_VALUE))
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(RECREATED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_afterDeletion_returnsCreatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- val createResult = cache.createIfAbsentAsync { CREATED_ASYNC_VALUE }
-
- // Deleting the cache clears it to be recreated.
- assertThat(awaitCompletion(createResult)).isEqualTo(CREATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testCreateIfAbsent_afterDeletion_setsValueOfCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- doNotAwaitCompletion(cache.createIfAbsentAsync { CREATED_ASYNC_VALUE })
-
- // Deleting the cache clears it to be recreated.
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(CREATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testReadIfPresent_afterDeletion_throwsException() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- val deferredRead = cache.readIfPresentAsync()
-
- // Deleting the cache should result in readIfPresent()'s expectations to fail.
- val exception = assertThrowsAsync(IllegalStateException::class) { awaitCompletion(deferredRead) }
- assertThat(exception).hasMessageThat().contains("Expected to read the cache only after it's been created")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_afterDeletion_returnsUpdatedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- val returnedValue = awaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- assertThat(returnedValue).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateCache_afterDeletion_changesCachedValue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- doNotAwaitCompletion(cache.updateAsync { UPDATED_ASYNC_VALUE })
-
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(UPDATED_ASYNC_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testUpdateIfPresent_afterDeletion_throwsException() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- doNotAwaitCompletion(cache.deleteAsync())
-
- val deferredUpdate = cache.updateIfPresentAsync { UPDATED_ASYNC_VALUE }
-
- // The operation should fail since the method expects the cache to be initialized.
- val exception = assertThrowsAsync(IllegalStateException::class) { awaitCompletion(deferredUpdate) }
- assertThat(exception).hasMessageThat().contains("Expected to update the cache only after it's been created")
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_emptyCache_falsePredicate_returnsFalse() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val maybeDeleteResult = cache.maybeDeleteAsync { false }
-
- // An empty cache cannot be deleted.
- assertThat(awaitCompletion(maybeDeleteResult)).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_emptyCache_truePredicate_returnsFalse() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val maybeDeleteResult = cache.maybeDeleteAsync { true }
-
- // An empty cache cannot be deleted.
- assertThat(awaitCompletion(maybeDeleteResult)).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_emptyCache_keepsCacheNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- doNotAwaitCompletion(cache.maybeDeleteAsync { true })
-
- // The empty cache should stay empty.
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_nonEmptyCache_falsePredicate_returnsFalse() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val maybeDeleteResult = cache.maybeDeleteAsync { false }
-
- // The predicate's false return value should be piped up to the deletion result.
- assertThat(awaitCompletion(maybeDeleteResult)).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_nonEmptyCache_falsePredicate_keepsCacheNonEmpty() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.maybeDeleteAsync { false })
-
- // The cache should retain its value since the deletion predicate indicated it shouldn't be cleared.
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(INITIALIZED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_nonEmptyCache_truePredicate_returnsTrue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val maybeDeleteResult = cache.maybeDeleteAsync { true }
-
- // The predicate's true return value should be piped up to the deletion result.
- assertThat(awaitCompletion(maybeDeleteResult)).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_nonEmptyCache_truePredicate_emptiesCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.maybeDeleteAsync { true })
-
- // The cache should be emptied as indicated by the deletion predicate.
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_blockingFunction_blocksDeletion() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- backgroundTestCoroutineDispatcher.pauseDispatcher()
-
- val blockingOperation = backgroundTestCoroutineScope.async { true }
- val deleteOperation = cache.maybeDeleteAsync { blockingOperation.await() }
-
- // The blocking operation should also block deletion.
- assertThat(deleteOperation.isCompleted).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeDelete_blockingFunction_completed_deletionCompletes() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { true }
- val deleteOperation = cache.maybeDeleteAsync { blockingOperation.await() }
-
- backgroundTestCoroutineDispatcher.advanceUntilIdle()
-
- // Completing the blocking operation should complete deletion.
- assertThat(deleteOperation.isCompleted).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_emptyCache_falsePredicate_returnsFalse() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val maybeDeleteResult = cache.maybeForceDeleteAsync { false }
-
- // An empty cache cannot be deleted.
- assertThat(awaitCompletion(maybeDeleteResult)).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_emptyCache_truePredicate_returnsTrue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- val maybeDeleteResult = cache.maybeForceDeleteAsync { true }
-
- // An empty cache cannot be deleted, but with force deletion the state of the cache is not checked. It's assumed
- // that the cache was definitely cleared.
- assertThat(awaitCompletion(maybeDeleteResult)).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_emptyCache_keepsCacheNull() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create()
-
- doNotAwaitCompletion(cache.maybeForceDeleteAsync { true })
-
- // The empty cache should stay empty.
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_nonEmptyCache_falsePredicate_returnsFalse() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val maybeDeleteResult = cache.maybeForceDeleteAsync { false }
-
- // The predicate's false return value should be piped up to the deletion result.
- assertThat(awaitCompletion(maybeDeleteResult)).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_nonEmptyCache_falsePredicate_keepsCacheNonEmpty() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.maybeForceDeleteAsync { false })
-
- // The cache should retain its value since the deletion predicate indicated it shouldn't be cleared.
- assertThat(awaitCompletion(cache.readAsync())).isEqualTo(INITIALIZED_CACHE_VALUE)
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_nonEmptyCache_truePredicate_returnsTrue() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- val maybeDeleteResult = cache.maybeForceDeleteAsync { true }
-
- // The predicate's true return value should be piped up to the deletion result.
- assertThat(awaitCompletion(maybeDeleteResult)).isTrue()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_nonEmptyCache_truePredicate_emptiesCache() = runBlockingTest(testDispatcher) {
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
-
- doNotAwaitCompletion(cache.maybeForceDeleteAsync { true })
-
- // The cache should be emptied as indicated by the deletion predicate.
- assertThat(awaitCompletion(cache.readAsync())).isNull()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_blockingFunction_blocksDeletion() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- backgroundTestCoroutineDispatcher.pauseDispatcher()
-
- val blockingOperation = backgroundTestCoroutineScope.async { true }
- val deleteOperation = cache.maybeForceDeleteAsync { blockingOperation.await() }
-
- // The blocking operation should also block deletion.
- assertThat(deleteOperation.isCompleted).isFalse()
- }
-
- @Test
- @ExperimentalCoroutinesApi
- fun testMaybeForceDelete_blockingFunction_completed_deletionCompletes() = runBlockingTest(testDispatcher) {
- testDispatcher.resumeDispatcher() // Keep the test dispatcher active since this test is verifying blocking behavior.
- val cache = cacheFactory.create(INITIALIZED_CACHE_VALUE)
- backgroundTestCoroutineDispatcher.pauseDispatcher()
- val blockingOperation = backgroundTestCoroutineScope.async { true }
- val deleteOperation = cache.maybeForceDeleteAsync { blockingOperation.await() }
-
- backgroundTestCoroutineDispatcher.advanceUntilIdle()
-
- // Completing the blocking operation should complete deletion.
- assertThat(deleteOperation.isCompleted).isTrue()
- }
-
- /**
- * Silences the warning that [Deferred] is unused. This is okay for tests that ensure await() is called at the end of
- * the test since the cache guarantees sequential execution.
- */
- private fun doNotAwaitCompletion(@Suppress("UNUSED_PARAMETER") deferred: Deferred) {}
-
- /**
- * Waits for the specified deferred to execute after advancing test dispatcher. Without this function, results cannot
- * be observed from cache operations.
- */
- @ExperimentalCoroutinesApi
- private suspend fun awaitCompletion(deferred: Deferred): T {
- testDispatcher.advanceUntilIdle()
- return deferred.await()
- }
-
- // TODO(#89): Move to a common test library.
- /** A replacement to JUnit5's assertThrows() with Kotlin suspend coroutine support. */
- private suspend fun assertThrowsAsync(type: KClass, operation: suspend () -> Unit): T {
- try {
- operation()
- fail("Expected to encounter exception of $type")
- } catch (t: Throwable) {
- if (type.isInstance(t)) {
- return type.cast(t)
- }
- // Unexpected exception; throw it.
- throw t
- }
- }
-
- private fun setUpTestApplicationComponent() {
- DaggerInMemoryBlockingCacheTest_TestApplicationComponent.builder()
- .setApplication(ApplicationProvider.getApplicationContext())
- .build()
- .inject(this)
- }
-
- @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(): TestCoroutineDispatcher {
- return TestCoroutineDispatcher()
- }
-
- @ExperimentalCoroutinesApi
- @Singleton
- @Provides
- @BlockingDispatcher
- fun provideBlockingDispatcher(@TestDispatcher testDispatcher: TestCoroutineDispatcher): CoroutineDispatcher {
- return testDispatcher
- }
- }
-
- // 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(inMemoryBlockingCacheTest: InMemoryBlockingCacheTest)
- }
-}