From 4aff2473f2247237ead641e702530762536f50a0 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Mon, 27 Mar 2023 16:06:51 +0100 Subject: [PATCH] Add application scope for fire-and-forget jobs (#926) * Add application scope for fire-and-forget jobs * Update app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt Co-authored-by: Manuel Vivo * Update app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt Co-authored-by: Manuel Vivo * Fix imports * Change createTest to return task ID * Replace provides with binds for simpler Hilt config * Add dispatcher and scope to test dependencies, improve param names * Fix failing test, fix spotless * Catch exceptions and update comments for send tasks job --------- Co-authored-by: Manuel Vivo --- .../todoapp/data/DefaultTaskRepository.kt | 112 +++++++++++------- .../blueprints/todoapp/data/TaskRepository.kt | 2 +- .../source/network/TaskNetworkDataSource.kt | 5 +- .../blueprints/todoapp/di/CoroutinesModule.kt | 22 ++++ .../blueprints/todoapp/di/DataModules.kt | 28 ++--- .../todoapp/statistics/StatisticsViewModel.kt | 2 +- .../todoapp/tasks/TasksViewModel.kt | 2 +- .../todoapp/data/DefaultTaskRepositoryTest.kt | 19 +-- .../todoapp/data/FakeTaskRepository.kt | 4 +- 9 files changed, 120 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt index 4c12b29ae..104ca4982 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt @@ -18,32 +18,40 @@ package com.example.android.architecture.blueprints.todoapp.data import com.example.android.architecture.blueprints.todoapp.data.source.local.TaskDao import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkDataSource +import com.example.android.architecture.blueprints.todoapp.di.ApplicationScope +import com.example.android.architecture.blueprints.todoapp.di.DefaultDispatcher import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** * Default implementation of [TaskRepository]. Single entry point for managing tasks' data. * - * @param tasksNetworkDataSource - The network data source - * @param taskDao - The local data source - * @param coroutineDispatcher - The dispatcher to be used for long running or complex operations, - * such as network calls or mapping many models. This is important to avoid blocking the calling - * thread. + * @param networkDataSource - The network data source + * @param localDataSource - The local data source + * @param dispatcher - The dispatcher to be used for long running or complex operations, such as ID + * generation or mapping many models. + * @param scope - The coroutine scope used for deferred jobs where the result isn't important, such + * as sending data to the network. */ -class DefaultTaskRepository( - private val tasksNetworkDataSource: NetworkDataSource, - private val taskDao: TaskDao, - private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default +@Singleton +class DefaultTaskRepository @Inject constructor( + private val networkDataSource: NetworkDataSource, + private val localDataSource: TaskDao, + @DefaultDispatcher private val dispatcher: CoroutineDispatcher, + @ApplicationScope private val scope: CoroutineScope, ) : TaskRepository { override suspend fun createTask(title: String, description: String): String { // ID creation might be a complex operation so it's executed using the supplied // coroutine dispatcher - val taskId = withContext(coroutineDispatcher) { + val taskId = withContext(dispatcher) { UUID.randomUUID().toString() } val task = Task( @@ -51,7 +59,7 @@ class DefaultTaskRepository( description = description, id = taskId, ) - taskDao.upsert(task.toLocal()) + localDataSource.upsert(task.toLocal()) saveTasksToNetwork() return taskId } @@ -62,37 +70,33 @@ class DefaultTaskRepository( description = description ) ?: throw Exception("Task (id $taskId) not found") - taskDao.upsert(task.toLocal()) + localDataSource.upsert(task.toLocal()) saveTasksToNetwork() } override suspend fun getTasks(forceUpdate: Boolean): List { if (forceUpdate) { - loadTasksFromNetwork() + refresh() } - return withContext(coroutineDispatcher) { - taskDao.getAll().toExternal() + return withContext(dispatcher) { + localDataSource.getAll().toExternal() } } - override suspend fun refreshTasks() { - loadTasksFromNetwork() - } - override fun getTasksStream(): Flow> { - return taskDao.observeAll().map { tasks -> - withContext(coroutineDispatcher) { + return localDataSource.observeAll().map { tasks -> + withContext(dispatcher) { tasks.toExternal() } } } override suspend fun refreshTask(taskId: String) { - loadTasksFromNetwork() + refresh() } override fun getTaskStream(taskId: String): Flow { - return taskDao.observeById(taskId).map { it.toExternal() } + return localDataSource.observeById(taskId).map { it.toExternal() } } /** @@ -103,60 +107,78 @@ class DefaultTaskRepository( */ override suspend fun getTask(taskId: String, forceUpdate: Boolean): Task? { if (forceUpdate) { - loadTasksFromNetwork() + refresh() } - return taskDao.getById(taskId)?.toExternal() + return localDataSource.getById(taskId)?.toExternal() } override suspend fun completeTask(taskId: String) { - taskDao.updateCompleted(taskId = taskId, completed = true) + localDataSource.updateCompleted(taskId = taskId, completed = true) saveTasksToNetwork() } override suspend fun activateTask(taskId: String) { - taskDao.updateCompleted(taskId = taskId, completed = false) + localDataSource.updateCompleted(taskId = taskId, completed = false) saveTasksToNetwork() } override suspend fun clearCompletedTasks() { - taskDao.deleteCompleted() + localDataSource.deleteCompleted() saveTasksToNetwork() } override suspend fun deleteAllTasks() { - taskDao.deleteAll() + localDataSource.deleteAll() saveTasksToNetwork() } override suspend fun deleteTask(taskId: String) { - taskDao.deleteById(taskId) + localDataSource.deleteById(taskId) saveTasksToNetwork() } /** - * The following methods load tasks from, and save tasks to, the network. - * - * Consider these to be long running operations, hence the need for `withContext` which - * can change the coroutine dispatcher so that the caller isn't blocked. + * The following methods load tasks from (refresh), and save tasks to, the network. * * Real apps may want to do a proper sync, rather than the "one-way sync everything" approach * below. See https://developer.android.com/topic/architecture/data-layer/offline-first * for more efficient and robust synchronisation strategies. * - * Also, in a real app, these operations could be scheduled using WorkManager. + * Note that the refresh operation is a suspend function (forces callers to wait) and the save + * operation is not. It returns immediately so callers don't have to wait. */ - private suspend fun loadTasksFromNetwork() { - withContext(coroutineDispatcher) { - val remoteTasks = tasksNetworkDataSource.loadTasks() - taskDao.deleteAll() - taskDao.upsertAll(remoteTasks.toLocal()) + + /** + * Delete everything in the local data source and replace it with everything from the network + * data source. + * + * `withContext` is used here in case the bulk `toLocal` mapping operation is complex. + */ + override suspend fun refresh() { + withContext(dispatcher) { + val remoteTasks = networkDataSource.loadTasks() + localDataSource.deleteAll() + localDataSource.upsertAll(remoteTasks.toLocal()) } } - private suspend fun saveTasksToNetwork() { - withContext(coroutineDispatcher) { - val localTasks = taskDao.getAll() - tasksNetworkDataSource.saveTasks(localTasks.toNetwork()) + /** + * Send the tasks from the local data source to the network data source + * + * Returns immediately after launching the job. Real apps may want to suspend here until the + * operation is complete or (better) use WorkManager to schedule this work. Both approaches + * should provide a mechanism for failures to be communicated back to the user so that + * they are aware that their data isn't being backed up. + */ + private fun saveTasksToNetwork() { + scope.launch { + try { + val localTasks = localDataSource.getAll() + networkDataSource.saveTasks(localTasks.toNetwork()) + } catch (e: Exception) { + // In a real app you'd handle the exception e.g. by exposing a `networkStatus` flow + // to an app level UI state holder which could then display a Toast message. + } } } } diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt index 24eaf0679..6f0ee0bb5 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/TaskRepository.kt @@ -27,7 +27,7 @@ interface TaskRepository { suspend fun getTasks(forceUpdate: Boolean = false): List - suspend fun refreshTasks() + suspend fun refresh() fun getTaskStream(taskId: String): Flow diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt index 2fbc899c7..606ee978b 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/source/network/TaskNetworkDataSource.kt @@ -16,15 +16,16 @@ package com.example.android.architecture.blueprints.todoapp.data.source.network +import javax.inject.Inject import kotlinx.coroutines.delay /** * Implementation of the data source that adds a latency simulating network. * */ -object TaskNetworkDataSource : NetworkDataSource { +class TaskNetworkDataSource @Inject constructor() : NetworkDataSource { - private const val SERVICE_LATENCY_IN_MILLIS = 2000L + private val SERVICE_LATENCY_IN_MILLIS = 2000L private var TASK_SERVICE_DATA = LinkedHashMap(2) diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt index 420828433..262d52e39 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/CoroutinesModule.kt @@ -21,13 +21,24 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Qualifier +import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob @Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class IoDispatcher +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class DefaultDispatcher + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + @Module @InstallIn(SingletonComponent::class) object CoroutinesModule { @@ -35,4 +46,15 @@ object CoroutinesModule { @Provides @IoDispatcher fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @DefaultDispatcher + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @Singleton + @ApplicationScope + fun providesCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) } diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt index 0b86f6095..aab699ccd 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/di/DataModules.kt @@ -20,43 +20,34 @@ import android.content.Context import androidx.room.Room import com.example.android.architecture.blueprints.todoapp.data.DefaultTaskRepository import com.example.android.architecture.blueprints.todoapp.data.TaskRepository +import com.example.android.architecture.blueprints.todoapp.data.source.local.TaskDao import com.example.android.architecture.blueprints.todoapp.data.source.local.ToDoDatabase import com.example.android.architecture.blueprints.todoapp.data.source.network.NetworkDataSource import com.example.android.architecture.blueprints.todoapp.data.source.network.TaskNetworkDataSource +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import javax.inject.Qualifier import javax.inject.Singleton -@Qualifier -@Retention(AnnotationRetention.RUNTIME) -annotation class NetworkTaskDataSource - @Module @InstallIn(SingletonComponent::class) -object RepositoryModule { +abstract class RepositoryModule { @Singleton - @Provides - fun provideTaskRepository( - @NetworkTaskDataSource remoteDataSource: NetworkDataSource, - database: ToDoDatabase, - ): TaskRepository { - return DefaultTaskRepository(remoteDataSource, database.taskDao()) - } + @Binds + abstract fun bindTaskRepository(repository: DefaultTaskRepository): TaskRepository } @Module @InstallIn(SingletonComponent::class) -object DataSourceModule { +abstract class DataSourceModule { @Singleton - @NetworkTaskDataSource - @Provides - fun provideTaskRemoteDataSource(): NetworkDataSource = TaskNetworkDataSource + @Binds + abstract fun bindNetworkDataSource(dataSource: TaskNetworkDataSource): NetworkDataSource } @Module @@ -72,4 +63,7 @@ object DatabaseModule { "Tasks.db" ).build() } + + @Provides + fun provideTaskDao(database: ToDoDatabase): TaskDao = database.taskDao() } diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt index c3b639b25..4db5c1f22 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/statistics/StatisticsViewModel.kt @@ -62,7 +62,7 @@ class StatisticsViewModel @Inject constructor( fun refresh() { viewModelScope.launch { - taskRepository.refreshTasks() + taskRepository.refresh() } } diff --git a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt index ff50522cb..9868f80a7 100644 --- a/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt +++ b/app/src/main/java/com/example/android/architecture/blueprints/todoapp/tasks/TasksViewModel.kt @@ -140,7 +140,7 @@ class TasksViewModel @Inject constructor( fun refresh() { _isLoading.value = true viewModelScope.launch { - taskRepository.refreshTasks() + taskRepository.refresh() _isLoading.value = false } } diff --git a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt index 2071b6227..415c43220 100644 --- a/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt +++ b/app/src/test/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepositoryTest.kt @@ -22,6 +22,9 @@ import com.example.android.architecture.blueprints.todoapp.data.source.network.F import com.google.common.truth.Truth.assertThat import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -63,19 +66,18 @@ class DefaultTaskRepositoryTest { localDataSource = FakeTaskDao(localTasks) // Get a reference to the class under test tasksRepository = DefaultTaskRepository( - networkDataSource, localDataSource + networkDataSource = networkDataSource, + localDataSource = localDataSource, + dispatcher = StandardTestDispatcher(), + scope = TestScope() ) } @ExperimentalCoroutinesApi @Test fun getTasks_emptyRepositoryAndUninitializedCache() = runTest { - val emptyRemoteSource = FakeNetworkDataSource() - val emptyLocalSource = FakeTaskDao() - - val tasksRepository = DefaultTaskRepository( - emptyRemoteSource, emptyLocalSource - ) + networkDataSource.tasks?.clear() + localDataSource.deleteAll() assertThat(tasksRepository.getTasks().size).isEqualTo(0) } @@ -110,6 +112,9 @@ class DefaultTaskRepositoryTest { // When a task is saved to the tasks repository val newTaskId = tasksRepository.createTask(newTask.title, newTask.description) + // Wait for the network to be updated + advanceUntilIdle() + // Then the remote and local sources contain the new task assertThat(networkDataSource.tasks?.map { it.id }?.contains(newTaskId)) assertThat(localDataSource.tasks?.map { it.id }?.contains(newTaskId)) diff --git a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt index 540275114..20056ff7d 100644 --- a/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt +++ b/shared-test/src/main/java/com/example/android/architecture/blueprints/todoapp/data/FakeTaskRepository.kt @@ -48,12 +48,12 @@ class FakeTaskRepository : TaskRepository { shouldThrowError = value } - override suspend fun refreshTasks() { + override suspend fun refresh() { // Tasks already refreshed } override suspend fun refreshTask(taskId: String) { - refreshTasks() + refresh() } override suspend fun createTask(title: String, description: String): String {