Skip to content

Commit

Permalink
Add application scope for fire-and-forget jobs (#926)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update app/src/main/java/com/example/android/architecture/blueprints/todoapp/data/DefaultTaskRepository.kt

Co-authored-by: Manuel Vivo <[email protected]>

* 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 <[email protected]>
  • Loading branch information
dturner and manuelvicnt authored Mar 27, 2023
1 parent 04924f6 commit 4aff247
Show file tree
Hide file tree
Showing 9 changed files with 120 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,48 @@ 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(
title = title,
description = description,
id = taskId,
)
taskDao.upsert(task.toLocal())
localDataSource.upsert(task.toLocal())
saveTasksToNetwork()
return taskId
}
Expand All @@ -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<Task> {
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<List<Task>> {
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<Task?> {
return taskDao.observeById(taskId).map { it.toExternal() }
return localDataSource.observeById(taskId).map { it.toExternal() }
}

/**
Expand All @@ -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.
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface TaskRepository {

suspend fun getTasks(forceUpdate: Boolean = false): List<Task>

suspend fun refreshTasks()
suspend fun refresh()

fun getTaskStream(taskId: String): Flow<Task?>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, NetworkTask>(2)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,40 @@ 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 {

@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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -72,4 +63,7 @@ object DatabaseModule {
"Tasks.db"
).build()
}

@Provides
fun provideTaskDao(database: ToDoDatabase): TaskDao = database.taskDao()
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class StatisticsViewModel @Inject constructor(

fun refresh() {
viewModelScope.launch {
taskRepository.refreshTasks()
taskRepository.refresh()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ class TasksViewModel @Inject constructor(
fun refresh() {
_isLoading.value = true
viewModelScope.launch {
taskRepository.refreshTasks()
taskRepository.refresh()
_isLoading.value = false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Expand Down
Loading

0 comments on commit 4aff247

Please sign in to comment.