diff --git a/.gitignore b/.gitignore
index 5a181851a8f..4eb2f7645e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
app/build
+domain/build
model/build
utility/build
.DS_Store
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 33d490d0a34..c26bd1cd7a5 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -16,6 +16,13 @@
+
+
+
+
+
+
+
@@ -39,6 +46,9 @@
+
+
+
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index b24ba3a9a74..80feec202be 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,6 +9,7 @@
+
diff --git a/app/build.gradle b/app/build.gradle
index 41e3e08da0c..ccc495cd449 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,65 +1,71 @@
apply plugin: 'com.android.application'
-
apply plugin: 'kotlin-android'
-
apply plugin: 'kotlin-android-extensions'
android {
- compileSdkVersion 29
- buildToolsVersion "29.0.1"
- defaultConfig {
- applicationId "org.oppia.app"
- minSdkVersion 16
- targetSdkVersion 28
- versionCode 1
- versionName "1.0"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ compileSdkVersion 29
+ buildToolsVersion "29.0.1"
+ defaultConfig {
+ applicationId "org.oppia.app"
+ minSdkVersion 16
+ targetSdkVersion 28
+ versionCode 1
+ versionName "1.0"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- testOptions {
- unitTests {
- includeAndroidResources = true
- }
+ }
+ dataBinding {
+ enabled = true
+ }
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
}
+ }
- // https://proandroiddev.com/isolated-fragments-unit-tests-that-run-both-instrumented-and-on-the-jvm-with-the-same-source-code-283db2e9be5d
- sourceSets {
- androidTest {
- java.srcDirs += "src/sharedTest/java"
- kotlin.srcDirs += "src/sharedTest/java"
- }
- test {
- java.srcDirs += "src/sharedTest/java"
- kotlin.srcDirs += "src/sharedTest/java"
- }
+ // https://proandroiddev.com/isolated-fragments-unit-tests-that-run-both-instrumented-and-on-the-jvm-with-the-same-source-code-283db2e9be5d
+ sourceSets {
+ androidTest {
+ java.srcDirs += "src/sharedTest/java"
+ kotlin.srcDirs += "src/sharedTest/java"
+ }
+ test {
+ java.srcDirs += "src/sharedTest/java"
+ kotlin.srcDirs += "src/sharedTest/java"
}
+ }
}
dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar'])
- implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation 'androidx.appcompat:appcompat:1.0.2'
- implementation 'androidx.core:core-ktx:1.0.2'
- implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
- api 'com.google.guava:guava:28.0-android'
-
- testImplementation 'androidx.test:core:1.2.0'
- testImplementation 'androidx.test.ext:junit:1.1.1'
- testImplementation 'org.robolectric:robolectric:4.3'
- testImplementation "com.google.truth:truth:0.43"
- testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
-
- androidTestImplementation 'androidx.test:core:1.2.0'
- androidTestImplementation 'androidx.test.ext:junit:1.1.1'
- androidTestImplementation 'androidx.test:runner:1.2.0'
- androidTestImplementation "com.google.truth:truth:0.43"
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
-
- implementation project(":model")
- implementation project(":utility")
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation(
+ 'android.arch.lifecycle:extensions:1.1.1',
+ 'androidx.appcompat:appcompat:1.0.2',
+ 'androidx.constraintlayout:constraintlayout:1.1.3',
+ 'androidx.core:core-ktx:1.0.2',
+ 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03',
+ "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version",
+ )
+ testImplementation(
+ 'androidx.test:core:1.2.0',
+ 'androidx.test.espresso:espresso-core:3.2.0',
+ 'androidx.test.ext:junit:1.1.1',
+ 'com.google.truth:truth:0.43',
+ 'org.robolectric:robolectric:4.3',
+ )
+ androidTestImplementation(
+ 'androidx.test:core:1.2.0',
+ 'androidx.test.espresso:espresso-core:3.2.0',
+ 'androidx.test.ext:junit:1.1.1',
+ 'androidx.test:runner:1.2.0',
+ 'com.google.truth:truth:0.43',
+ )
+ implementation project(":model")
+ implementation project(":domain")
+ implementation project(":utility")
}
diff --git a/app/src/main/java/org/oppia/app/HomeActivity.kt b/app/src/main/java/org/oppia/app/HomeActivity.kt
index 562ff10cf0d..0fda85beb37 100644
--- a/app/src/main/java/org/oppia/app/HomeActivity.kt
+++ b/app/src/main/java/org/oppia/app/HomeActivity.kt
@@ -1,15 +1,7 @@
package org.oppia.app
-import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
-import android.util.Log
-import android.widget.TextView
-import com.google.common.util.concurrent.FutureCallback
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
-import com.google.common.util.concurrent.MoreExecutors.directExecutor
-import org.oppia.app.model.UserAppHistory
-import org.oppia.util.data.AsyncDataSource
+import androidx.appcompat.app.AppCompatActivity
/** The central activity for all users entering the app. */
class HomeActivity : AppCompatActivity() {
@@ -17,36 +9,6 @@ class HomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.home_activity)
- }
-
- override fun onStart() {
- super.onStart()
- // TODO(BenHenning): Convert this to LiveData rather than risk an async operation completing outside of the
- // activity's lifecycle. Also, a directExecutor() in this context could be a really bad idea if it isn't the main
- // thread.
- Futures.addCallback(getUserAppHistory().pendingOperation, object: FutureCallback {
- override fun onSuccess(result: UserAppHistory?) {
- if (result != null && result.alreadyOpenedApp) {
- getWelcomeTextView().setText(R.string.welcome_back_text)
- }
- }
-
- override fun onFailure(t: Throwable) {
- // TODO(BenHenning): Replace this log statement with a clearer logging API.
- Log.e("HomeActivity", "Failed to retrieve user app history", t)
- }
- }, directExecutor())
- }
-
- private fun getWelcomeTextView(): TextView {
- return findViewById(R.id.welcome_text_view)
- }
-
- private fun getUserAppHistory(): AsyncDataSource {
- // TODO(BenHenning): Retrieve this from a domain provider.
- return object: AsyncDataSource {
- override val pendingOperation: ListenableFuture
- get() = Futures.immediateFuture(UserAppHistory.newBuilder().setAlreadyOpenedApp(false).build())
- }
+ supportFragmentManager.beginTransaction().add(R.id.home_fragment_placeholder, HomeFragment()).commitNow()
}
}
diff --git a/app/src/main/java/org/oppia/app/HomeFragment.kt b/app/src/main/java/org/oppia/app/HomeFragment.kt
new file mode 100644
index 00000000000..87ccfa40df1
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/HomeFragment.kt
@@ -0,0 +1,55 @@
+package org.oppia.app
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
+import androidx.lifecycle.ViewModelProviders
+import org.oppia.app.databinding.HomeFragmentBinding
+import org.oppia.app.model.UserAppHistory
+import org.oppia.domain.UserAppHistoryController
+import org.oppia.util.data.AsyncResult
+
+/** Fragment that contains an introduction to the app. */
+class HomeFragment : Fragment() {
+ private val userAppHistoryController = UserAppHistoryController()
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ val binding = HomeFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false)
+ val viewModel = getUserAppHistoryViewModel()
+ val appUserHistory = getUserAppHistory()
+ viewModel.userAppHistoryLiveData = appUserHistory
+ // NB: Both the view model and lifecycle owner must be set in order to correctly bind LiveData elements to
+ // data-bound view models.
+ binding.let {
+ it.viewModel = viewModel
+ it.lifecycleOwner = this
+ }
+
+ // TODO(#70): Mark that the user opened the app once it's persisted to disk.
+
+ return binding.root
+ }
+
+ private fun getUserAppHistoryViewModel(): UserAppHistoryViewModel {
+ return ViewModelProviders.of(this).get(UserAppHistoryViewModel::class.java)
+ }
+
+ private fun getUserAppHistory(): LiveData {
+ // If there's an error loading the data, assume the default.
+ return Transformations.map(
+ userAppHistoryController.getUserAppHistory()
+ ) { result: AsyncResult -> processUserAppHistoryResult(result) }
+ }
+
+ private fun processUserAppHistoryResult(appHistoryResult: AsyncResult): UserAppHistory {
+ if (appHistoryResult.isFailure()) {
+ Log.e("HomeFragment", "Failed to retrieve user app history", appHistoryResult.error)
+ }
+ return appHistoryResult.getOrDefault(UserAppHistory.getDefaultInstance())
+ }
+}
diff --git a/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt b/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt
new file mode 100644
index 00000000000..80232d34dbd
--- /dev/null
+++ b/app/src/main/java/org/oppia/app/UserAppHistoryViewModel.kt
@@ -0,0 +1,10 @@
+package org.oppia.app
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import org.oppia.app.model.UserAppHistory
+
+/** [ViewModel] for user app usage history. */
+class UserAppHistoryViewModel: ViewModel() {
+ var userAppHistoryLiveData: LiveData? = null
+}
diff --git a/app/src/main/res/layout/home_activity.xml b/app/src/main/res/layout/home_activity.xml
index c8687eceb33..eb47e32defe 100644
--- a/app/src/main/res/layout/home_activity.xml
+++ b/app/src/main/res/layout/home_activity.xml
@@ -1,20 +1,8 @@
-
-
-
-
-
+ tools:context=".HomeActivity" />
diff --git a/app/src/main/res/layout/home_fragment.xml b/app/src/main/res/layout/home_fragment.xml
new file mode 100644
index 00000000000..285d570bdbb
--- /dev/null
+++ b/app/src/main/res/layout/home_fragment.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.gradle b/build.gradle
index ca420b90e64..c7c1f436770 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,27 +1,27 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = '1.3.41'
- repositories {
- google()
- jcenter()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:3.4.2'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
+ ext.kotlin_version = '1.3.41'
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.4.2'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10'
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
}
allprojects {
- repositories {
- google()
- jcenter()
- }
+ repositories {
+ google()
+ jcenter()
+ }
}
task clean(type: Delete) {
- delete rootProject.buildDir
+ delete rootProject.buildDir
}
diff --git a/domain/build.gradle b/domain/build.gradle
new file mode 100644
index 00000000000..5e199af2141
--- /dev/null
+++ b/domain/build.gradle
@@ -0,0 +1,53 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
+
+android {
+ compileSdkVersion 29
+ buildToolsVersion "29.0.1"
+
+ defaultConfig {
+ minSdkVersion 16
+ targetSdkVersion 28
+ versionCode 1
+ versionName "1.0"
+ }
+
+ 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',
+ 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha03',
+ "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.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'
+ )
+ implementation project(":model")
+ implementation project(":utility")
+}
+repositories {
+ mavenCentral()
+}
diff --git a/domain/proguard-rules.pro b/domain/proguard-rules.pro
new file mode 100644
index 00000000000..f1b424510da
--- /dev/null
+++ b/domain/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/domain/src/main/AndroidManifest.xml b/domain/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..7d8459d5279
--- /dev/null
+++ b/domain/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt b/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
new file mode 100644
index 00000000000..fe61256adf0
--- /dev/null
+++ b/domain/src/main/java/org/oppia/domain/UserAppHistoryController.kt
@@ -0,0 +1,128 @@
+package org.oppia.domain
+
+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.util.data.AsyncResult
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+/** 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()
+ }
+ }
+ }
+
+ // 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.
+ */
+ private class NotifiableAsyncLiveData(
+ private val context: CoroutineContext = EmptyCoroutineContext,
+ private val function: suspend () -> T
+ ) : MediatorLiveData>() {
+ private val lock = Object()
+ private var pendingCoroutineLiveData: LiveData>? = null
+
+ init {
+ // Assume that the specified block is ready to execute immediately.
+ value = AsyncResult.pending()
+ enqueueAsyncFunctionAsLiveData()
+ }
+
+ /**
+ * Notifies this live data that it should re-run its asynchronous function and propagate any results.
+ *
+ * Note that if an existing operation is pending, it may complete but its results will not be propagated in favor
+ * of the run started by this call. Note also that regardless of the current [AsyncResult] value of this live data,
+ * the new value will overwrite it (e.g. it's possible to go from a failed to success state or vice versa).
+ */
+ fun notifyUpdate() {
+ synchronized(lock) {
+ if (pendingCoroutineLiveData != null) {
+ removeSource(pendingCoroutineLiveData!!)
+ pendingCoroutineLiveData = null
+ }
+ enqueueAsyncFunctionAsLiveData()
+ }
+ }
+
+ /**
+ * Enqueues the async function, but execution is based on whether this [LiveData] is active. See [MediatorLiveData]
+ * docs for context.
+ */
+ private fun enqueueAsyncFunctionAsLiveData() {
+ val coroutineLiveData = CoroutineLiveData(context) {
+ try {
+ AsyncResult.success(function())
+ } catch (t: Throwable) {
+ // Capture all failures for the downstream handler.
+ AsyncResult.failed(t)
+ }
+ }
+ synchronized(lock) {
+ pendingCoroutineLiveData = coroutineLiveData
+ addSource(coroutineLiveData) { computedValue ->
+ value = computedValue
+ }
+ }
+ }
+ }
+
+ // TODO(#72): Replace this with AndroidX's CoroutineLiveData once the corresponding LiveData suspend job bug is fixed
+ // and available.
+ /** A [LiveData] whose value is derived from a suspended function. */
+ private class CoroutineLiveData(
+ private val context: CoroutineContext,
+ private val function: suspend () -> T
+ ) : MutableLiveData() {
+ private var runningJob: Job? = null
+
+ override fun onActive() {
+ super.onActive()
+ if (runningJob == null) {
+ val scope = CoroutineScope(Dispatchers.Main + context)
+ runningJob = scope.launch {
+ value = function()
+ }
+ }
+ }
+
+ override fun onInactive() {
+ super.onInactive()
+ runningJob?.cancel()
+ }
+ }
+}
diff --git a/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt b/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
new file mode 100644
index 00000000000..9d470d4510a
--- /dev/null
+++ b/domain/src/test/java/org/oppia/domain/UserAppHistoryControllerTest.kt
@@ -0,0 +1,125 @@
+package org.oppia.domain
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Observer
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.ObsoleteCoroutinesApi
+import kotlinx.coroutines.newSingleThreadContext
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
+import org.mockito.Mock
+import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.verify
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.oppia.app.model.UserAppHistory
+import org.oppia.util.data.AsyncResult
+
+/** Tests for [UserAppHistoryController]. */
+@RunWith(AndroidJUnit4::class)
+class UserAppHistoryControllerTest {
+ @Rule
+ @JvmField
+ val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Rule
+ @JvmField
+ val executorRule = InstantTaskExecutorRule()
+
+ @Mock
+ lateinit var mockAppHistoryObserver: Observer>
+
+ @Captor
+ lateinit var appHistoryResultCaptor: ArgumentCaptor>
+
+ // https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/
+ @ObsoleteCoroutinesApi
+ private val testThread = newSingleThreadContext("TestMain")
+
+ @Before
+ @ExperimentalCoroutinesApi
+ @ObsoleteCoroutinesApi
+ fun setUp() {
+ Dispatchers.setMain(testThread)
+ }
+
+ @After
+ @ExperimentalCoroutinesApi
+ @ObsoleteCoroutinesApi
+ fun tearDown() {
+ Dispatchers.resetMain()
+ 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()
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_providesInitialLiveData_thatIndicatesUserHasNotOpenedTheApp() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+
+ val appHistory = userAppHistoryController.getUserAppHistory()
+ advanceUntilIdle()
+ appHistory.observeForever(mockAppHistoryObserver)
+
+ verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
+ assertThat(appHistoryResultCaptor.value.isSuccess()).isTrue()
+ assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).isFalse()
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testControllerObserver_observedBeforeSettingAppOpened_providesLiveData_userDidNotOpenApp() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+ val appHistory = userAppHistoryController.getUserAppHistory()
+
+ appHistory.observeForever(mockAppHistoryObserver)
+ advanceUntilIdle()
+ userAppHistoryController.markUserOpenedApp()
+
+ // The result should not indicate that the user opened the app because markUserOpenedApp does not notify observers
+ // of the change.
+ verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
+ assertThat(appHistoryResultCaptor.value.isSuccess()).isTrue()
+ assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).isFalse()
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testController_observedAfterSettingAppOpened_providesLiveData_userOpenedApp() = runBlockingTest {
+ val userAppHistoryController = UserAppHistoryController(this.coroutineContext)
+ val appHistory = userAppHistoryController.getUserAppHistory()
+
+ userAppHistoryController.markUserOpenedApp()
+ appHistory.observeForever(mockAppHistoryObserver)
+ advanceUntilIdle()
+
+ // The app should be considered open since observation began after marking the app as opened.
+ verify(mockAppHistoryObserver, atLeastOnce()).onChanged(appHistoryResultCaptor.capture())
+ assertThat(appHistoryResultCaptor.value.isSuccess()).isTrue()
+ assertThat(appHistoryResultCaptor.value.getOrThrow().alreadyOpenedApp).isTrue()
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index dbe0688ed9f..a29263a8801 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -25,3 +25,9 @@ android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+
+# Needed to enable Android data binding.
+android.databinding.enableV2=true
+
+# Needed to properly use binary resources in Robolectric.
+android.enableUnitTestBinaryResources = true
diff --git a/model/build.gradle b/model/build.gradle
index 0faea0981b3..6faa924a1fd 100644
--- a/model/build.gradle
+++ b/model/build.gradle
@@ -1,36 +1,35 @@
apply plugin: 'java-library'
-
apply plugin: 'com.google.protobuf'
protobuf {
- protoc {
- artifact = 'com.google.protobuf:protoc:3.0.0'
- }
- plugins {
- javalite {
- artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
- }
+ protoc {
+ artifact = 'com.google.protobuf:protoc:3.0.0'
+ }
+ plugins {
+ javalite {
+ artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0'
}
- generateProtoTasks {
- all().each { task ->
- task.builtins {
- remove java
- }
- task.plugins {
- javalite { }
- }
- }
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ remove java
+ }
+ task.plugins {
+ javalite {}
+ }
}
+ }
}
dependencies {
- compile 'com.google.protobuf:protobuf-lite:3.0.0'
+ implementation '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"
+ main.java.srcDirs += "${protobuf.generatedFilesBaseDir}/main/javalite"
+ main.java.srcDirs += "$projectDir/src/main/proto"
}
diff --git a/settings.gradle b/settings.gradle
index 89a5f6ce1f7..70fa49dac04 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app', ':model', ':utility'
+include ':app', ':model', ':utility', ':domain'
diff --git a/utility/build.gradle b/utility/build.gradle
index 1be4e9df225..a997ffdd681 100644
--- a/utility/build.gradle
+++ b/utility/build.gradle
@@ -1,39 +1,36 @@
apply plugin: 'com.android.library'
-
apply plugin: 'kotlin-android'
-
apply plugin: 'kotlin-android-extensions'
android {
- compileSdkVersion 29
- buildToolsVersion "29.0.1"
-
-
- defaultConfig {
- minSdkVersion 16
- targetSdkVersion 29
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
+ compileSdkVersion 29
+ buildToolsVersion "29.0.1"
+
+ defaultConfig {
+ minSdkVersion 16
+ targetSdkVersion 29
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
-
+ }
}
dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar'])
-
- api 'com.google.guava:guava:28.0-android'
- implementation 'androidx.appcompat:appcompat:1.0.2'
- testImplementation 'junit:junit:4.12'
- androidTestImplementation 'androidx.test:runner:1.2.0'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation(
+ 'androidx.appcompat:appcompat:1.0.2',
+ )
+ testImplementation(
+ 'com.google.truth:truth:0.43',
+ 'junit:junit:4.12',
+ "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version",
+ )
}
diff --git a/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt b/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt
index 86c3f8084ee..b13ad899c73 100644
--- a/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt
+++ b/utility/src/main/java/org/oppia/util/data/AsyncDataSource.kt
@@ -1,14 +1,12 @@
package org.oppia.util.data
-import com.google.common.util.concurrent.ListenableFuture
-
/**
* 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(BenHenning): Finalize the interfaces for this API beyond a basic prototype for the initial project intro.
+ // TODO(#6): Finalize the interfaces for this API beyond a basic prototype for the initial project intro.
- val pendingOperation: ListenableFuture
+ suspend fun executePendingOperation(): T
}
diff --git a/utility/src/main/java/org/oppia/util/data/AsyncResult.kt b/utility/src/main/java/org/oppia/util/data/AsyncResult.kt
new file mode 100644
index 00000000000..b9b70041aa9
--- /dev/null
+++ b/utility/src/main/java/org/oppia/util/data/AsyncResult.kt
@@ -0,0 +1,74 @@
+package org.oppia.util.data
+
+/** Represents the result from a single asynchronous function. */
+class AsyncResult private constructor(
+ private val status: Status,
+ private val value: T? = null,
+ val error: Throwable? = null
+) {
+ /** Represents the status of an asynchronous result. */
+ enum class Status {
+ /** Indicates that the asynchronous operation is not yet completed. */
+ PENDING,
+ /** Indicates that the asynchronous operation completed successfully and has a result. */
+ SUCCEEDED,
+ /** Indicates that the asynchronous operation failed and has an error. */
+ FAILED
+ }
+
+ /** Returns whether this result is still pending. */
+ fun isPending(): Boolean {
+ return status == Status.PENDING
+ }
+
+ /** Returns whether this result has completed successfully. */
+ fun isSuccess(): Boolean {
+ return status == Status.SUCCEEDED
+ }
+
+ /** Returns whether this result has completed unsuccessfully. */
+ fun isFailure(): Boolean {
+ return status == Status.FAILED
+ }
+
+ /** Returns whether this result has completed (successfully or unsuccessfully). */
+ fun isCompleted(): Boolean {
+ return isSuccess() || isFailure()
+ }
+
+ /** Returns the value of the result if it succeeded, otherwise the specified default value. */
+ fun getOrDefault(defaultValue: T): T {
+ return if (isSuccess()) value!! else defaultValue
+ }
+
+ /**
+ * Returns the value of the result if it succeeded, otherwise throws the underlying exception. Throws if this result
+ * is not yet completed.
+ */
+ fun getOrThrow(): T {
+ check(isCompleted()) { "Result is not yet completed." }
+ if (isSuccess()) return value!! else throw error!!
+ }
+
+ /** Returns the underlying exception if this result failed, otherwise null. */
+ fun getErrorOrNull(): Throwable? {
+ return if (isFailure()) error else null
+ }
+
+ companion object {
+ /** Returns a pending result. */
+ fun pending(): AsyncResult {
+ return AsyncResult(status = Status.PENDING)
+ }
+
+ /** Returns a successful result with the specified payload. */
+ fun success(value: T): AsyncResult {
+ return AsyncResult(status = Status.SUCCEEDED, value = value)
+ }
+
+ /** Returns a failed result with the specified error. */
+ fun failed(error: Throwable): AsyncResult {
+ return AsyncResult(status = Status.FAILED, error = error)
+ }
+ }
+}
diff --git a/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
new file mode 100644
index 00000000000..219fc1913b7
--- /dev/null
+++ b/utility/src/test/java/org/oppia/util/data/AsyncResultTest.kt
@@ -0,0 +1,167 @@
+package org.oppia.util.data
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import java.lang.IllegalStateException
+import java.lang.UnsupportedOperationException
+import kotlin.test.assertFailsWith
+
+/** Tests for [AsyncResult]. */
+@RunWith(JUnit4::class)
+class AsyncResultTest {
+
+ /* Pending tests. */
+
+ @Test
+ fun testPendingAsyncResult_isPending() {
+ val result = AsyncResult.pending()
+
+ assertThat(result.isPending()).isTrue()
+ }
+
+ @Test
+ fun testPendingAsyncResult_isNotSuccess() {
+ val result = AsyncResult.pending()
+
+ assertThat(result.isSuccess()).isFalse()
+ }
+
+ @Test
+ fun testPendingAsyncResult_isNotFailure() {
+ val result = AsyncResult.pending()
+
+ assertThat(result.isFailure()).isFalse()
+ }
+
+ @Test
+ fun testPendingAsyncResult_isNotCompleted() {
+ val result = AsyncResult.pending()
+
+ assertThat(result.isCompleted()).isFalse()
+ }
+
+ @Test
+ fun testPendingAsyncResult_getOrDefault_returnsDefault() {
+ val result = AsyncResult.pending()
+
+ assertThat(result.getOrDefault("default")).isEqualTo("default")
+ }
+
+ @Test
+ fun testPendingAsyncResult_getOrThrow_throwsIllegalStateExceptionDueToIncompletion() {
+ val result = AsyncResult.pending()
+
+ assertFailsWith { result.getOrThrow() }
+ }
+
+ @Test
+ fun testPendingAsyncResult_getErrorOrNull_returnsNull() {
+ val result = AsyncResult.pending()
+
+ assertThat(result.getErrorOrNull()).isNull()
+ }
+
+ /* Success tests. */
+
+ @Test
+ fun testSucceededAsyncResult_isNotPending() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.isPending()).isFalse()
+ }
+
+ @Test
+ fun testSucceededAsyncResult_isSuccess() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.isSuccess()).isTrue()
+ }
+
+ @Test
+ fun testSucceededAsyncResult_isNotFailure() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.isFailure()).isFalse()
+ }
+
+ @Test
+ fun testSucceededAsyncResult_isCompleted() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.isCompleted()).isTrue()
+ }
+
+ @Test
+ fun testSucceededAsyncResult_getOrDefault_returnsValue() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.getOrDefault("default")).isEqualTo("value")
+ }
+
+ @Test
+ fun testSucceededAsyncResult_getOrThrow_returnsValue() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.getOrThrow()).isEqualTo("value")
+ }
+
+ @Test
+ fun testSucceededAsyncResult_getErrorOrNull_returnsNull() {
+ val result = AsyncResult.success("value")
+
+ assertThat(result.getErrorOrNull()).isNull()
+ }
+
+ /* Failure tests. */
+
+ @Test
+ fun testFailedAsyncResult_isNotPending() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertThat(result.isPending()).isFalse()
+ }
+
+ @Test
+ fun testFailedAsyncResult_isNotSuccess() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertThat(result.isSuccess()).isFalse()
+ }
+
+ @Test
+ fun testFailedAsyncResult_isFailure() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertThat(result.isFailure()).isTrue()
+ }
+
+ @Test
+ fun testFailedAsyncResult_isCompleted() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertThat(result.isCompleted()).isTrue()
+ }
+
+ @Test
+ fun testFailedAsyncResult_getOrDefault_returnsDefault() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertThat(result.getOrDefault("default")).isEqualTo("default")
+ }
+
+ @Test
+ fun testFailedAsyncResult_getOrThrow_throwsFailureException() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertFailsWith { result.getOrThrow() }
+ }
+
+ @Test
+ fun testFailedAsyncResult_getErrorOrNull_returnsFailureException() {
+ val result = AsyncResult.failed(UnsupportedOperationException())
+
+ assertThat(result.getErrorOrNull()).isInstanceOf(UnsupportedOperationException::class.java)
+ }
+}