diff --git a/.gitignore b/.gitignore
index 4eb2f7645e2..c26873b6b36 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
/.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 c26bd1cd7a5..ef06a053e64 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -13,7 +13,13 @@
+
+
+
+
+
+
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 80feec202be..d456ac543b2 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,6 +9,7 @@
+
diff --git a/app/build.gradle b/app/build.gradle
index ccc495cd449..1f1db6277fe 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,17 +1,27 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
+apply plugin: 'kotlin-kapt'
android {
- compileSdkVersion 29
+ compileSdkVersion 28
buildToolsVersion "29.0.1"
defaultConfig {
applicationId "org.oppia.app"
- minSdkVersion 16
+ minSdkVersion 19
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 {
@@ -23,6 +33,8 @@ android {
enabled = true
}
testOptions {
+ // https://developer.android.com/training/testing/junit-runner#ato-gradle
+ execution 'ANDROIDX_TEST_ORCHESTRATOR'
unitTests {
includeAndroidResources = true
}
@@ -49,6 +61,7 @@ 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(
@@ -65,6 +78,18 @@ 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 a89ec503898..a25c9ab2754 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,13 +4,14 @@
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
deleted file mode 100644
index 0fda85beb37..00000000000
--- a/app/src/main/java/org/oppia/app/HomeActivity.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index 87ccfa40df1..00000000000
--- a/app/src/main/java/org/oppia/app/HomeFragment.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-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
deleted file mode 100644
index 80232d34dbd..00000000000
--- a/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
new file mode 100644
index 00000000000..067c44381bb
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 00000000000..87eeb2d2215
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/activity/ActivityModule.kt
@@ -0,0 +1,7 @@
+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
new file mode 100644
index 00000000000..47d22227c5b
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/activity/ActivityScope.kt
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 00000000000..03797994c77
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/activity/InjectableAppCompatActivity.kt
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 00000000000..745e69d7ec0
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/application/ApplicationComponent.kt
@@ -0,0 +1,22 @@
+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
new file mode 100644
index 00000000000..cd3f7fec258
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/application/ApplicationContext.kt
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 00000000000..c2ae637e845
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/application/ApplicationModule.kt
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 00000000000..30d83017884
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/application/OppiaApplication.kt
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 00000000000..d56fdf0c26c
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt
@@ -0,0 +1,19 @@
+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
new file mode 100644
index 00000000000..ad58085be05
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/fragment/FragmentScope.kt
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 00000000000..25c3628f07a
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 00000000000..d5fe94ca133
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/home/HomeActivity.kt
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 00000000000..9fd3b0e7f81
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/home/HomeActivityController.kt
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 00000000000..e3bc454853f
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/home/HomeFragment.kt
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 00000000000..d8f19de8685
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/home/HomeFragmentController.kt
@@ -0,0 +1,37 @@
+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
new file mode 100644
index 00000000000..8e9b614fd79
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/home/UserAppHistoryViewModel.kt
@@ -0,0 +1,33 @@
+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
new file mode 100644
index 00000000000..ed6c2ede81b
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/viewmodel/ViewModelBridgeFactory.kt
@@ -0,0 +1,27 @@
+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
new file mode 100644
index 00000000000..d8dbc644bfa
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/viewmodel/ViewModelProvider.kt
@@ -0,0 +1,17 @@
+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 eb47e32defe..86fdf2ff0a0 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=".HomeActivity" />
+ tools:context=".home.HomeActivity" />
diff --git a/app/src/main/res/layout/home_fragment.xml b/app/src/main/res/layout/home_fragment.xml
index 285d570bdbb..45e60f50205 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.home.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
new file mode 100644
index 00000000000..7d1ae604563
--- /dev/null
+++ b/data/build.gradle
@@ -0,0 +1,62 @@
+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
new file mode 100644
index 00000000000..f1b424510da
--- /dev/null
+++ b/data/proguard-rules.pro
@@ -0,0 +1,21 @@
+# 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
new file mode 100644
index 00000000000..fdb94b1021d
--- /dev/null
+++ b/data/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/data/src/main/java/org/oppia/data/persistence/PersistentCacheStore.kt b/data/src/main/java/org/oppia/data/persistence/PersistentCacheStore.kt
new file mode 100644
index 00000000000..490ad060a98
--- /dev/null
+++ b/data/src/main/java/org/oppia/data/persistence/PersistentCacheStore.kt
@@ -0,0 +1,222 @@
+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
new file mode 100644
index 00000000000..f2077c0aef3
--- /dev/null
+++ b/data/src/test/java/org/oppia/data/persistence/PersistentCacheStoreTest.kt
@@ -0,0 +1,584 @@
+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 5e199af2141..f8615018a63 100644
--- a/domain/build.gradle
+++ b/domain/build.gradle
@@ -1,18 +1,28 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
+apply plugin: 'kotlin-kapt'
android {
- compileSdkVersion 29
+ compileSdkVersion 28
buildToolsVersion "29.0.1"
defaultConfig {
- minSdkVersion 16
+ 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
@@ -32,21 +42,33 @@ 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'
+ 'org.robolectric:robolectric:4.3',
+ )
+ kapt(
+ 'com.google.dagger:dagger-compiler:2.24'
+ )
+ kaptTest(
+ 'com.google.dagger:dagger-compiler:2.24'
)
- implementation project(":model")
- implementation project(":utility")
+ // 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')
}
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 fe61256adf0..ffbe1d488cb 100644
--- a/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
+++ b/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
@@ -1,128 +1,59 @@
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.util.data.AsyncDataSource
+import org.oppia.data.persistence.PersistentCacheStore
import org.oppia.util.data.AsyncResult
-import kotlin.coroutines.CoroutineContext
-import kotlin.coroutines.EmptyCoroutineContext
+import org.oppia.util.data.DataProviders
+import javax.inject.Inject
+import javax.inject.Singleton
/** Controller for persisting and retrieving the previous user history of using the app. */
-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 consumers that the change was made.
- */
- fun markUserOpenedApp() {
- userOpenedApp = true
- }
-
- /** Returns a [LiveData] result indicating whether the user has previously opened the app. */
- fun getUserAppHistory(): LiveData> {
- return NotifiableAsyncLiveData(coroutineContext) {
- createUserAppHistoryDataSource().executePendingOperation()
- }
- }
-
- private fun createUserAppHistoryDataSource(): AsyncDataSource {
- return object : AsyncDataSource {
- override suspend fun executePendingOperation(): UserAppHistory {
- return UserAppHistory.newBuilder().setAlreadyOpenedApp(userOpenedApp).build()
+@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)
}
}
}
- // TODO(#71): Move this to the correct module once the architecture for data sources is determined.
/**
- * 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.
+ * 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.
*/
- 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
- }
+ 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)
}
}
}
- // 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()
- }
+ /** 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)
}
}
+ }
- override fun onInactive() {
- super.onInactive()
- runningJob?.cancel()
- }
+ /**
+ * 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.
+ */
+ fun getUserAppHistory(): LiveData> {
+ return dataProviders.convertToLiveData(appHistoryStore)
}
}
diff --git a/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt b/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
index 9d470d4510a..c2de37d99ea 100644
--- a/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
+++ b/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
@@ -1,13 +1,22 @@
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
@@ -25,9 +34,17 @@ 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
@@ -37,6 +54,17 @@ 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>
@@ -52,6 +80,7 @@ class UserAppHistoryControllerTest {
@ObsoleteCoroutinesApi
fun setUp() {
Dispatchers.setMain(testThread)
+ setUpTestApplicationComponent()
}
@After
@@ -62,25 +91,16 @@ class UserAppHistoryControllerTest {
testThread.close()
}
- @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()
+ private fun setUpTestApplicationComponent() {
+ DaggerUserAppHistoryControllerTest_TestApplicationComponent.builder()
+ .setApplication(ApplicationProvider.getApplicationContext())
+ .build()
+ .inject(this)
}
@Test
@ExperimentalCoroutinesApi
- fun testController_providesInitialLiveData_thatIndicatesUserHasNotOpenedTheApp() = runBlockingTest {
- val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
-
+ fun testController_providesInitialLiveData_thatIndicatesUserHasNotOpenedTheApp() = runBlockingTest(coroutineContext) {
val appHistory = userAppHistoryController.getUserAppHistory()
advanceUntilIdle()
appHistory.observeForever(mockAppHistoryObserver)
@@ -92,13 +112,13 @@ class UserAppHistoryControllerTest {
@Test
@ExperimentalCoroutinesApi
- fun testControllerObserver_observedBeforeSettingAppOpened_providesLiveData_userDidNotOpenApp() = runBlockingTest {
- val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+ fun testControllerObserver_observedAfterSettingAppOpened_providesLiveData_userDidNotOpenApp()
+ = runBlockingTest(coroutineContext) {
val appHistory = userAppHistoryController.getUserAppHistory()
appHistory.observeForever(mockAppHistoryObserver)
- advanceUntilIdle()
userAppHistoryController.markUserOpenedApp()
+ advanceUntilIdle()
// The result should not indicate that the user opened the app because markUserOpenedApp does not notify observers
// of the change.
@@ -109,17 +129,87 @@ class UserAppHistoryControllerTest {
@Test
@ExperimentalCoroutinesApi
- fun testController_observedAfterSettingAppOpened_providesLiveData_userOpenedApp() = runBlockingTest {
- val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+ 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()
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 open since observation began after marking the app as opened.
+ // The app should be considered not yet opened since the previous history was cleared.
verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
assertThat(appHistoryResultCaptor.value.isSuccess()).isTrue()
- assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).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)
}
}
diff --git a/model/build.gradle b/model/build.gradle
index 6e30a513334..69d27b52c39 100644
--- a/model/build.gradle
+++ b/model/build.gradle
@@ -28,9 +28,6 @@ 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 4d4465182b0..15e27e9a7b1 100644
--- a/model/src/main/proto/example.proto
+++ b/model/src/main/proto/example.proto
@@ -8,3 +8,7 @@ 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 70fa49dac04..4253dbdcae1 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app', ':model', ':utility', ':domain'
+include ':app', ':model', ':utility', ':domain', ':data'
diff --git a/utility/build.gradle b/utility/build.gradle
index a997ffdd681..a53440609e1 100644
--- a/utility/build.gradle
+++ b/utility/build.gradle
@@ -1,18 +1,32 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
+apply plugin: 'kotlin-kapt'
android {
- compileSdkVersion 29
+ compileSdkVersion 28
buildToolsVersion "29.0.1"
defaultConfig {
- minSdkVersion 16
- targetSdkVersion 29
+ 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
+ }
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
}
buildTypes {
@@ -27,10 +41,25 @@ 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
deleted file mode 100644
index b13ad899c73..00000000000
--- a/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-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
new file mode 100644
index 00000000000..67adb7bc92e
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/data/AsyncDataSubscriptionManager.kt
@@ -0,0 +1,68 @@
+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 b9b70041aa9..c9a343427d7 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,
- val error: Throwable? = null
+ private val error: Throwable? = null
) {
/** Represents the status of an asynchronous result. */
enum class Status {
@@ -55,6 +55,63 @@ 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 {
@@ -71,4 +128,7 @@ 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
new file mode 100644
index 00000000000..9c10374f987
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/data/DataProvider.kt
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 00000000000..94fe26441f9
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/data/DataProviders.kt
@@ -0,0 +1,241 @@
+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
new file mode 100644
index 00000000000..f84fb92092a
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/data/InMemoryBlockingCache.kt
@@ -0,0 +1,142 @@
+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
new file mode 100644
index 00000000000..17686df8638
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/threading/BackgroundDispatcher.kt
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 00000000000..40aa4199d6f
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/threading/BlockingDispatcher.kt
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 00000000000..107a86b1be0
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/threading/ConcurrentCollections.kt
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 00000000000..1250489ddab
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/threading/DispatcherModule.kt
@@ -0,0 +1,29 @@
+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 219fc1913b7..668eb86e5bc 100644
--- a/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
+++ b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
@@ -1,6 +1,8 @@
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
@@ -63,6 +65,69 @@ 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
@@ -114,6 +179,117 @@ 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
@@ -164,4 +340,88 @@ 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
new file mode 100644
index 00000000000..c1eedae7589
--- /dev/null
+++ b/utility/src/test/java/org/oppia/util/data/DataProvidersTest.kt
@@ -0,0 +1,956 @@
+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
new file mode 100644
index 00000000000..d5f52b2a033
--- /dev/null
+++ b/utility/src/test/java/org/oppia/util/data/InMemoryBlockingCacheTest.kt
@@ -0,0 +1,798 @@
+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)
+ }
+}