diff --git a/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt b/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt index 614e2dcd8..06c86bab9 100644 --- a/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt +++ b/core-database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt @@ -305,6 +305,49 @@ class NewsResourceDaoTest { filteredNewsResources.map { it.entity.id } ) } + + @Test + fun newsResourceDao_deletes_items_by_ids() = + runTest { + val newsResourceEntities = listOf( + testNewsResource( + id = 0, + millisSinceEpoch = 0, + ), + testNewsResource( + id = 1, + millisSinceEpoch = 3, + ), + testNewsResource( + id = 2, + millisSinceEpoch = 1, + ), + testNewsResource( + id = 3, + millisSinceEpoch = 2, + ), + ) + val episodeEntityShells = newsResourceEntities + .map(NewsResourceEntity::episodeEntityShell) + .distinct() + + episodeDao.upsertEpisodes(episodeEntityShells) + newsResourceDao.upsertNewsResources(newsResourceEntities) + + val (toDelete, toKeep) = newsResourceEntities.partition { it.id % 2 == 0 } + + newsResourceDao.deleteNewsResources( + toDelete.map(NewsResourceEntity::id) + ) + + assertEquals( + toKeep.map(NewsResourceEntity::id) + .toSet(), + newsResourceDao.getNewsResourcesStream().first() + .map { it.entity.id } + .toSet() + ) + } } private fun testAuthorEntity( diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt index 5df59b0de..40592e9ad 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/AuthorDao.kt @@ -54,4 +54,15 @@ interface AuthorDao { insertMany = ::insertOrIgnoreAuthors, updateMany = ::updateAuthors ) + + /** + * Deletes rows in the db matching the specified [ids] + */ + @Query( + value = """ + DELETE FROM authors + WHERE id in (:ids) + """ + ) + suspend fun deleteAuthors(ids: List) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt index 10f23f430..dfffc31fe 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt @@ -56,4 +56,15 @@ interface EpisodeDao { insertMany = ::insertOrIgnoreEpisodes, updateMany = ::updateEpisodes ) + + /** + * Deletes rows in the db matching the specified [ids] + */ + @Query( + value = """ + DELETE FROM episodes + WHERE id in (:ids) + """ + ) + suspend fun deleteEpisodes(ids: List) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index d3f615cf0..c2ffa2214 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -94,4 +94,15 @@ interface NewsResourceDao { suspend fun insertOrIgnoreAuthorCrossRefEntities( newsResourceAuthorCrossReferences: List ) + + /** + * Deletes rows in the db matching the specified [ids] + */ + @Query( + value = """ + DELETE FROM news_resources + WHERE id in (:ids) + """ + ) + suspend fun deleteNewsResources(ids: List) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 9d71a4f9b..6d91e139b 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -70,4 +70,15 @@ interface TopicDao { insertMany = ::insertOrIgnoreTopics, updateMany = ::updateTopics ) + + /** + * Deletes rows in the db matching the specified [ids] + */ + @Query( + value = """ + DELETE FROM topics + WHERE id in (:ids) + """ + ) + suspend fun deleteTopics(ids: List) } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt index c544602c9..35c628a06 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/SyncUtilities.kt @@ -18,12 +18,38 @@ package com.google.samples.apps.nowinandroid.core.domain import android.util.Log import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions -import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +/** + * Interface marker for a class that manages synchronization between local data and a remote + * source for a [Syncable]. + */ +interface Synchronizer { + suspend fun getChangeListVersions(): ChangeListVersions + + suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions) + + /** + * Syntactic sugar to call [Syncable.syncWith] while omitting the synchronizer argument + */ + suspend fun Syncable.sync() = this@sync.syncWith(this@Synchronizer) +} + +/** + * Interface marker for a class that is synchronized with a remote source. Syncing must not be + * performed concurrently and it is the [Synchronizer]'s responsibility to ensure this. + */ +interface Syncable { + /** + * Synchronizes the local database in backing the repository with the network. + * Returns if the sync was successful or not. + */ + suspend fun syncWith(synchronizer: Synchronizer): Boolean +} + /** * Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure] * taking care not to break structured concurrency @@ -46,30 +72,35 @@ private suspend fun suspendRunCatching(block: suspend () -> T): Result = * [versionReader] Reads the current version of the model that needs to be synced * [changeListFetcher] Fetches the change list for the model * [versionUpdater] Updates the [ChangeListVersions] after a successful sync - * [modelUpdater] Updates the model by consuming the ids of the models that have changed. + * [modelDeleter] Deletes models by consuming the ids of the models that have been deleted. + * [modelUpdater] Updates models by consuming the ids of the models that have changed. * - * Note that the blocks defined above may be run concurrently due to concurrent calls - * to [changeListSync]. It is the caller's responsibility to make [modelUpdater] performs atomic - * operations. + * Note that the blocks defined above are never run concurrently, and the [Synchronizer] + * implementation must guarantee this. */ -suspend fun changeListSync( - niaPreferences: NiaPreferences, +suspend fun Synchronizer.changeListSync( versionReader: (ChangeListVersions) -> Int, changeListFetcher: suspend (Int) -> List, versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions, - modelUpdater: suspend (List) -> Unit + modelDeleter: suspend (List) -> Unit, + modelUpdater: suspend (List) -> Unit, ) = suspendRunCatching { // Fetch the change list since last sync (akin to a git fetch) - val currentVersion = versionReader(niaPreferences.getChangeListVersions()) + val currentVersion = versionReader(getChangeListVersions()) val changeList = changeListFetcher(currentVersion) if (changeList.isEmpty()) return@suspendRunCatching true + val (deleted, updated) = changeList.partition(NetworkChangeList::isDelete) + + // Delete models that have been deleted server-side + modelDeleter(deleted.map(NetworkChangeList::id)) + // Using the change list, pull down and save the changes (akin to a git pull) - modelUpdater(changeList.map(NetworkChangeList::id)) + modelUpdater(updated.map(NetworkChangeList::id)) // Update the last synced version (akin to updating local git HEAD) val latestVersion = changeList.last().changeListVersion - niaPreferences.updateChangeListVersion { + updateChangeListVersions { versionUpdater(latestVersion) } }.isSuccess diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt index 39fbf8f18..ddcfc99b0 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/AuthorsRepository.kt @@ -16,10 +16,11 @@ package com.google.samples.apps.nowinandroid.core.domain.repository +import com.google.samples.apps.nowinandroid.core.domain.Syncable import com.google.samples.apps.nowinandroid.core.model.data.Author import kotlinx.coroutines.flow.Flow -interface AuthorsRepository { +interface AuthorsRepository : Syncable { /** * Gets the available Authors as a stream */ @@ -39,10 +40,4 @@ interface AuthorsRepository { * Returns the users currently followed authors */ fun getFollowedAuthorIdsStream(): Flow> - - /** - * Synchronizes the local database in backing the repository with the network. - * Returns if the sync was successful or not. - */ - suspend fun sync(): Boolean } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt index 18f839dcb..43e17e102 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepository.kt @@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.changeListSync import com.google.samples.apps.nowinandroid.core.domain.model.asEntity import com.google.samples.apps.nowinandroid.core.model.data.Author @@ -51,20 +52,21 @@ class LocalAuthorsRepository @Inject constructor( override fun getFollowedAuthorIdsStream(): Flow> = niaPreferences.followedAuthorIds - override suspend fun sync(): Boolean = changeListSync( - niaPreferences = niaPreferences, - versionReader = ChangeListVersions::authorVersion, - changeListFetcher = { currentVersion -> - network.getAuthorChangeList(after = currentVersion) - }, - versionUpdater = { latestVersion -> - copy(authorVersion = latestVersion) - }, - modelUpdater = { changedIds -> - val networkAuthors = network.getAuthors(ids = changedIds) - authorDao.upsertAuthors( - entities = networkAuthors.map(NetworkAuthor::asEntity) - ) - } - ) + override suspend fun syncWith(synchronizer: Synchronizer): Boolean = + synchronizer.changeListSync( + versionReader = ChangeListVersions::authorVersion, + changeListFetcher = { currentVersion -> + network.getAuthorChangeList(after = currentVersion) + }, + versionUpdater = { latestVersion -> + copy(authorVersion = latestVersion) + }, + modelDeleter = authorDao::deleteAuthors, + modelUpdater = { changedIds -> + val networkAuthors = network.getAuthors(ids = changedIds) + authorDao.upsertAuthors( + entities = networkAuthors.map(NetworkAuthor::asEntity) + ) + } + ) } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt index 8e549a8ba..f5840600d 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt @@ -26,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsRes import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions -import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.changeListSync import com.google.samples.apps.nowinandroid.core.domain.model.asEntity import com.google.samples.apps.nowinandroid.core.domain.model.authorCrossReferences @@ -50,7 +50,6 @@ class LocalNewsRepository @Inject constructor( private val authorDao: AuthorDao, private val topicDao: TopicDao, private val network: NiANetwork, - private val niaPreferences: NiaPreferences, ) : NewsRepository { override fun getNewsResourcesStream(): Flow> = @@ -66,53 +65,54 @@ class LocalNewsRepository @Inject constructor( ) .map { it.map(PopulatedNewsResource::asExternalModel) } - override suspend fun sync() = changeListSync( - niaPreferences = niaPreferences, - versionReader = ChangeListVersions::newsResourceVersion, - changeListFetcher = { currentVersion -> - network.getNewsResourceChangeList(after = currentVersion) - }, - versionUpdater = { latestVersion -> - copy(newsResourceVersion = latestVersion) - }, - modelUpdater = { changedIds -> - val networkNewsResources = network.getNewsResources(ids = changedIds) + override suspend fun syncWith(synchronizer: Synchronizer) = + synchronizer.changeListSync( + versionReader = ChangeListVersions::newsResourceVersion, + changeListFetcher = { currentVersion -> + network.getNewsResourceChangeList(after = currentVersion) + }, + versionUpdater = { latestVersion -> + copy(newsResourceVersion = latestVersion) + }, + modelDeleter = newsResourceDao::deleteNewsResources, + modelUpdater = { changedIds -> + val networkNewsResources = network.getNewsResources(ids = changedIds) - // Order of invocation matters to satisfy id and foreign key constraints! + // Order of invocation matters to satisfy id and foreign key constraints! - topicDao.insertOrIgnoreTopics( - topicEntities = networkNewsResources - .map(NetworkNewsResource::topicEntityShells) - .flatten() - .distinctBy(TopicEntity::id) - ) - authorDao.insertOrIgnoreAuthors( - authorEntities = networkNewsResources - .map(NetworkNewsResource::authorEntityShells) - .flatten() - .distinctBy(AuthorEntity::id) - ) - episodeDao.insertOrIgnoreEpisodes( - episodeEntities = networkNewsResources - .map(NetworkNewsResource::episodeEntityShell) - .distinctBy(EpisodeEntity::id) - ) - newsResourceDao.upsertNewsResources( - newsResourceEntities = networkNewsResources - .map(NetworkNewsResource::asEntity) - ) - newsResourceDao.insertOrIgnoreTopicCrossRefEntities( - newsResourceTopicCrossReferences = networkNewsResources - .map(NetworkNewsResource::topicCrossReferences) - .distinct() - .flatten() - ) - newsResourceDao.insertOrIgnoreAuthorCrossRefEntities( - newsResourceAuthorCrossReferences = networkNewsResources - .map(NetworkNewsResource::authorCrossReferences) - .distinct() - .flatten() - ) - } - ) + topicDao.insertOrIgnoreTopics( + topicEntities = networkNewsResources + .map(NetworkNewsResource::topicEntityShells) + .flatten() + .distinctBy(TopicEntity::id) + ) + authorDao.insertOrIgnoreAuthors( + authorEntities = networkNewsResources + .map(NetworkNewsResource::authorEntityShells) + .flatten() + .distinctBy(AuthorEntity::id) + ) + episodeDao.insertOrIgnoreEpisodes( + episodeEntities = networkNewsResources + .map(NetworkNewsResource::episodeEntityShell) + .distinctBy(EpisodeEntity::id) + ) + newsResourceDao.upsertNewsResources( + newsResourceEntities = networkNewsResources + .map(NetworkNewsResource::asEntity) + ) + newsResourceDao.insertOrIgnoreTopicCrossRefEntities( + newsResourceTopicCrossReferences = networkNewsResources + .map(NetworkNewsResource::topicCrossReferences) + .distinct() + .flatten() + ) + newsResourceDao.insertOrIgnoreAuthorCrossRefEntities( + newsResourceAuthorCrossReferences = networkNewsResources + .map(NetworkNewsResource::authorCrossReferences) + .distinct() + .flatten() + ) + } + ) } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt index 4cf3f4ba6..83bc0999c 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt @@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.changeListSync import com.google.samples.apps.nowinandroid.core.domain.model.asEntity import com.google.samples.apps.nowinandroid.core.model.data.Topic @@ -54,20 +55,21 @@ class LocalTopicsRepository @Inject constructor( override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds - override suspend fun sync(): Boolean = changeListSync( - niaPreferences = niaPreferences, - versionReader = ChangeListVersions::topicVersion, - changeListFetcher = { currentVersion -> - network.getTopicChangeList(after = currentVersion) - }, - versionUpdater = { latestVersion -> - copy(topicVersion = latestVersion) - }, - modelUpdater = { changedIds -> - val networkTopics = network.getTopics(ids = changedIds) - topicDao.upsertTopics( - entities = networkTopics.map(NetworkTopic::asEntity) - ) - } - ) + override suspend fun syncWith(synchronizer: Synchronizer): Boolean = + synchronizer.changeListSync( + versionReader = ChangeListVersions::topicVersion, + changeListFetcher = { currentVersion -> + network.getTopicChangeList(after = currentVersion) + }, + versionUpdater = { latestVersion -> + copy(topicVersion = latestVersion) + }, + modelDeleter = topicDao::deleteTopics, + modelUpdater = { changedIds -> + val networkTopics = network.getTopics(ids = changedIds) + topicDao.upsertTopics( + entities = networkTopics.map(NetworkTopic::asEntity) + ) + } + ) } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt index 96da2685c..190423cc2 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/NewsRepository.kt @@ -16,13 +16,14 @@ package com.google.samples.apps.nowinandroid.core.domain.repository +import com.google.samples.apps.nowinandroid.core.domain.Syncable import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import kotlinx.coroutines.flow.Flow /** * Data layer implementation for [NewsResource] */ -interface NewsRepository { +interface NewsRepository : Syncable { /** * Returns available news resources as a stream. */ @@ -35,10 +36,4 @@ interface NewsRepository { filterAuthorIds: Set = emptySet(), filterTopicIds: Set = emptySet(), ): Flow> - - /** - * Synchronizes the local database in backing the repository with the network. - * Returns if the sync was successful or not. - */ - suspend fun sync(): Boolean } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt index 6d61ab3b8..96450c41e 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/TopicsRepository.kt @@ -16,10 +16,11 @@ package com.google.samples.apps.nowinandroid.core.domain.repository +import com.google.samples.apps.nowinandroid.core.domain.Syncable import com.google.samples.apps.nowinandroid.core.model.data.Topic import kotlinx.coroutines.flow.Flow -interface TopicsRepository { +interface TopicsRepository : Syncable { /** * Gets the available topics as a stream */ @@ -44,10 +45,4 @@ interface TopicsRepository { * Returns the users currently followed topics */ fun getFollowedTopicIdsStream(): Flow> - - /** - * Synchronizes the local database in backing the repository with the network. - * Returns if the sync was successful or not. - */ - suspend fun sync(): Boolean } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt index 792771c32..42aca55b2 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeAuthorsRepository.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.domain.repository.fake import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.network.Dispatcher @@ -68,5 +69,5 @@ class FakeAuthorsRepository @Inject constructor( override fun getFollowedAuthorIdsStream(): Flow> = niaPreferences.followedAuthorIds - override suspend fun sync() = true + override suspend fun syncWith(synchronizer: Synchronizer) = true } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt index fd4da95af..57251b0ea 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeNewsRepository.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.domain.repository.fake import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.model.asEntity import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.model.data.NewsResource @@ -71,5 +72,5 @@ class FakeNewsRepository @Inject constructor( } .flowOn(ioDispatcher) - override suspend fun sync() = true + override suspend fun syncWith(synchronizer: Synchronizer) = true } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt index 1de555285..84a86c1e5 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/fake/FakeTopicsRepository.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.domain.repository.fake import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.Dispatcher @@ -72,5 +73,5 @@ class FakeTopicsRepository @Inject constructor( override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds - override suspend fun sync() = true + override suspend fun syncWith(synchronizer: Synchronizer) = true } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepositoryTest.kt index 8d1b96a88..ebb9718bc 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepositoryTest.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalAuthorsRepositoryTest.kt @@ -21,10 +21,14 @@ import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.model.asEntity +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.CollectionType import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestAuthorDao import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork +import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor +import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert @@ -41,7 +45,7 @@ class LocalAuthorsRepositoryTest { private lateinit var network: TestNiaNetwork - private lateinit var niaPreferences: NiaPreferences + private lateinit var synchronizer: Synchronizer @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @@ -50,9 +54,10 @@ class LocalAuthorsRepositoryTest { fun setup() { authorDao = TestAuthorDao() network = TestNiaNetwork() - niaPreferences = NiaPreferences( + val niaPreferences = NiaPreferences( tmpFolder.testUserPreferencesDataStore() ) + synchronizer = TestSynchronizer(niaPreferences) subject = LocalAuthorsRepository( authorDao = authorDao, @@ -76,23 +81,23 @@ class LocalAuthorsRepositoryTest { @Test fun localAuthorsRepository_sync_pulls_from_network() = runTest { - subject.sync() + subject.syncWith(synchronizer) - val network = network.getAuthors() + val networkAuthors = network.getAuthors() .map(NetworkAuthor::asEntity) - val db = authorDao.getAuthorEntitiesStream() + val dbAuthors = authorDao.getAuthorEntitiesStream() .first() Assert.assertEquals( - network.map(AuthorEntity::id), - db.map(AuthorEntity::id) + networkAuthors.map(AuthorEntity::id), + dbAuthors.map(AuthorEntity::id) ) // After sync version should be updated Assert.assertEquals( - network.lastIndex, - niaPreferences.getChangeListVersions().authorVersion + network.latestChangeListVersion(CollectionType.Authors), + synchronizer.getChangeListVersions().authorVersion ) } @@ -100,16 +105,23 @@ class LocalAuthorsRepositoryTest { fun localAuthorsRepository_incremental_sync_pulls_from_network() = runTest { // Set author version to 5 - niaPreferences.updateChangeListVersion { + synchronizer.updateChangeListVersions { copy(authorVersion = 5) } - subject.sync() + subject.syncWith(synchronizer) + + val changeList = network.changeListsAfter( + CollectionType.Authors, + version = 5 + ) + val changeListIds = changeList + .map(NetworkChangeList::id) + .toSet() val network = network.getAuthors() .map(NetworkAuthor::asEntity) - // Drop 5 to simulate the first 5 items being unchanged - .drop(5) + .filter { it.id in changeListIds } val db = authorDao.getAuthorEntitiesStream() .first() @@ -121,8 +133,47 @@ class LocalAuthorsRepositoryTest { // After sync version should be updated Assert.assertEquals( - network.lastIndex + 5, - niaPreferences.getChangeListVersions().authorVersion + changeList.last().changeListVersion, + synchronizer.getChangeListVersions().authorVersion + ) + } + + @Test + fun localAuthorsRepository_sync_deletes_items_marked_deleted_on_network() = + runTest { + val networkAuthors = network.getAuthors() + .map(NetworkAuthor::asEntity) + .map(AuthorEntity::asExternalModel) + + val deletedItems = networkAuthors + .map(Author::id) + .partition { it % 2 == 0 } + .first + .toSet() + + deletedItems.forEach { + network.editCollection( + collectionType = CollectionType.Authors, + id = it, + isDelete = true + ) + } + + subject.syncWith(synchronizer) + + val dbAuthors = authorDao.getAuthorEntitiesStream() + .first() + .map(AuthorEntity::asExternalModel) + + Assert.assertEquals( + networkAuthors.map(Author::id) - deletedItems, + dbAuthors.map(Author::id) + ) + + // After sync version should be updated + Assert.assertEquals( + network.latestChangeListVersion(CollectionType.Authors), + synchronizer.getChangeListVersions().authorVersion ) } } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt index d20acc592..d960fe053 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt @@ -25,12 +25,14 @@ import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.model.asEntity import com.google.samples.apps.nowinandroid.core.domain.model.authorCrossReferences import com.google.samples.apps.nowinandroid.core.domain.model.authorEntityShells import com.google.samples.apps.nowinandroid.core.domain.model.episodeEntityShell import com.google.samples.apps.nowinandroid.core.domain.model.topicCrossReferences import com.google.samples.apps.nowinandroid.core.domain.model.topicEntityShells +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.CollectionType import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestAuthorDao import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestEpisodeDao import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNewsResourceDao @@ -39,6 +41,7 @@ import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestTopicDao import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredInterestsIds import com.google.samples.apps.nowinandroid.core.domain.testdoubles.nonPresentInterestsIds import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -62,7 +65,7 @@ class LocalNewsRepositoryTest { private lateinit var network: TestNiaNetwork - private lateinit var niaPreferences: NiaPreferences + private lateinit var synchronizer: Synchronizer @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @@ -74,8 +77,10 @@ class LocalNewsRepositoryTest { authorDao = TestAuthorDao() topicDao = TestTopicDao() network = TestNiaNetwork() - niaPreferences = NiaPreferences( - tmpFolder.testUserPreferencesDataStore() + synchronizer = TestSynchronizer( + NiaPreferences( + tmpFolder.testUserPreferencesDataStore() + ) ) subject = LocalNewsRepository( @@ -84,7 +89,6 @@ class LocalNewsRepositoryTest { authorDao = authorDao, topicDao = topicDao, network = network, - niaPreferences = niaPreferences, ) } @@ -151,7 +155,7 @@ class LocalNewsRepositoryTest { @Test fun localNewsRepository_sync_pulls_from_network() = runTest { - subject.sync() + subject.syncWith(synchronizer) val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity) @@ -168,8 +172,47 @@ class LocalNewsRepositoryTest { // After sync version should be updated assertEquals( - newsResourcesFromNetwork.lastIndex, - niaPreferences.getChangeListVersions().newsResourceVersion + network.latestChangeListVersion(CollectionType.NewsResources), + synchronizer.getChangeListVersions().newsResourceVersion + ) + } + + @Test + fun localNewsRepository_sync_deletes_items_marked_deleted_on_network() = + runTest { + val newsResourcesFromNetwork = network.getNewsResources() + .map(NetworkNewsResource::asEntity) + .map(NewsResourceEntity::asExternalModel) + + val deletedItems = newsResourcesFromNetwork + .map(NewsResource::id) + .partition { it % 2 == 0 } + .first + .toSet() + + deletedItems.forEach { + network.editCollection( + collectionType = CollectionType.NewsResources, + id = it, + isDelete = true + ) + } + + subject.syncWith(synchronizer) + + val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() + .first() + .map(PopulatedNewsResource::asExternalModel) + + assertEquals( + newsResourcesFromNetwork.map(NewsResource::id) - deletedItems, + newsResourcesFromDb.map(NewsResource::id) + ) + + // After sync version should be updated + assertEquals( + network.latestChangeListVersion(CollectionType.NewsResources), + synchronizer.getChangeListVersions().newsResourceVersion ) } @@ -177,17 +220,24 @@ class LocalNewsRepositoryTest { fun localNewsRepository_incremental_sync_pulls_from_network() = runTest { // Set news version to 7 - niaPreferences.updateChangeListVersion { + synchronizer.updateChangeListVersions { copy(newsResourceVersion = 7) } - subject.sync() + subject.syncWith(synchronizer) + + val changeList = network.changeListsAfter( + CollectionType.NewsResources, + version = 7 + ) + val changeListIds = changeList + .map(NetworkChangeList::id) + .toSet() val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel) - // Drop 7 to simulate the first 7 items being unchanged - .drop(7) + .filter { it.id in changeListIds } val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() .first() @@ -200,15 +250,15 @@ class LocalNewsRepositoryTest { // After sync version should be updated assertEquals( - newsResourcesFromNetwork.lastIndex + 7, - niaPreferences.getChangeListVersions().newsResourceVersion + changeList.last().changeListVersion, + synchronizer.getChangeListVersions().newsResourceVersion ) } @Test fun localNewsRepository_sync_saves_shell_topic_entities() = runTest { - subject.sync() + subject.syncWith(synchronizer) assertEquals( network.getNewsResources() @@ -223,7 +273,7 @@ class LocalNewsRepositoryTest { @Test fun localNewsRepository_sync_saves_shell_author_entities() = runTest { - subject.sync() + subject.syncWith(synchronizer) assertEquals( network.getNewsResources() @@ -238,7 +288,7 @@ class LocalNewsRepositoryTest { @Test fun localNewsRepository_sync_saves_shell_episode_entities() = runTest { - subject.sync() + subject.syncWith(synchronizer) assertEquals( network.getNewsResources() @@ -253,7 +303,7 @@ class LocalNewsRepositoryTest { @Test fun localNewsRepository_sync_saves_topic_cross_references() = runTest { - subject.sync() + subject.syncWith(synchronizer) assertEquals( network.getNewsResources() @@ -267,7 +317,7 @@ class LocalNewsRepositoryTest { @Test fun localNewsRepository_sync_saves_author_cross_references() = runTest { - subject.sync() + subject.syncWith(synchronizer) assertEquals( network.getNewsResources() diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt index 34de7f29c..0adb1027b 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt @@ -21,10 +21,12 @@ import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.model.asEntity +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.CollectionType import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestTopicDao -import com.google.samples.apps.nowinandroid.core.network.NiANetwork +import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -40,10 +42,12 @@ class LocalTopicsRepositoryTest { private lateinit var topicDao: TopicDao - private lateinit var network: NiANetwork + private lateinit var network: TestNiaNetwork private lateinit var niaPreferences: NiaPreferences + private lateinit var synchronizer: Synchronizer + @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @@ -54,6 +58,7 @@ class LocalTopicsRepositoryTest { niaPreferences = NiaPreferences( tmpFolder.testUserPreferencesDataStore() ) + synchronizer = TestSynchronizer(niaPreferences) subject = LocalTopicsRepository( topicDao = topicDao, @@ -88,23 +93,23 @@ class LocalTopicsRepositoryTest { @Test fun localTopicsRepository_sync_pulls_from_network() = runTest { - subject.sync() + subject.syncWith(synchronizer) - val network = network.getTopics() + val networkTopics = network.getTopics() .map(NetworkTopic::asEntity) - val db = topicDao.getTopicEntitiesStream() + val dbTopics = topicDao.getTopicEntitiesStream() .first() Assert.assertEquals( - network.map(TopicEntity::id), - db.map(TopicEntity::id) + networkTopics.map(TopicEntity::id), + dbTopics.map(TopicEntity::id) ) // After sync version should be updated Assert.assertEquals( - network.lastIndex, - niaPreferences.getChangeListVersions().topicVersion + network.latestChangeListVersion(CollectionType.Topics), + synchronizer.getChangeListVersions().topicVersion ) } @@ -112,29 +117,68 @@ class LocalTopicsRepositoryTest { fun localTopicsRepository_incremental_sync_pulls_from_network() = runTest { // Set topics version to 10 - niaPreferences.updateChangeListVersion { + synchronizer.updateChangeListVersions { copy(topicVersion = 10) } - subject.sync() + subject.syncWith(synchronizer) - val network = network.getTopics() + val networkTopics = network.getTopics() .map(NetworkTopic::asEntity) // Drop 10 to simulate the first 10 items being unchanged .drop(10) - val db = topicDao.getTopicEntitiesStream() + val dbTopics = topicDao.getTopicEntitiesStream() + .first() + + Assert.assertEquals( + networkTopics.map(TopicEntity::id), + dbTopics.map(TopicEntity::id) + ) + + // After sync version should be updated + Assert.assertEquals( + network.latestChangeListVersion(CollectionType.Topics), + synchronizer.getChangeListVersions().topicVersion + ) + } + + @Test + fun localTopicsRepository_sync_deletes_items_marked_deleted_on_network() = + runTest { + val networkTopics = network.getTopics() + .map(NetworkTopic::asEntity) + .map(TopicEntity::asExternalModel) + + val deletedItems = networkTopics + .map(Topic::id) + .partition { it % 2 == 0 } + .first + .toSet() + + deletedItems.forEach { + network.editCollection( + collectionType = CollectionType.Topics, + id = it, + isDelete = true + ) + } + + subject.syncWith(synchronizer) + + val dbTopics = topicDao.getTopicEntitiesStream() .first() + .map(TopicEntity::asExternalModel) Assert.assertEquals( - network.map(TopicEntity::id), - db.map(TopicEntity::id) + networkTopics.map(Topic::id) - deletedItems, + dbTopics.map(Topic::id) ) // After sync version should be updated Assert.assertEquals( - network.lastIndex + 10, - niaPreferences.getChangeListVersions().topicVersion + network.latestChangeListVersion(CollectionType.Topics), + synchronizer.getChangeListVersions().topicVersion ) } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/TestSynchronizer.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/TestSynchronizer.kt new file mode 100644 index 000000000..53467569d --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/TestSynchronizer.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.repository + +import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions +import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer + +/** + * Test synchronizer that delegates to [NiaPreferences] + */ +class TestSynchronizer( + private val niaPreferences: NiaPreferences +) : Synchronizer { + override suspend fun getChangeListVersions(): ChangeListVersions = + niaPreferences.getChangeListVersions() + + override suspend fun updateChangeListVersions( + update: ChangeListVersions.() -> ChangeListVersions + ) = niaPreferences.updateChangeListVersion(update) +} diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt index 15b6d4ac0..c41349c39 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestAuthorDao.kt @@ -20,6 +20,7 @@ import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update /** * Test double for [AuthorDao] @@ -50,4 +51,11 @@ class TestAuthorDao : AuthorDao { override suspend fun updateAuthors(entities: List) { throw NotImplementedError("Unused in tests") } + + override suspend fun deleteAuthors(ids: List) { + val idSet = ids.toSet() + entitiesStateFlow.update { entities -> + entities.filterNot { idSet.contains(it.id) } + } + } } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt index d0c3b1af8..89049bd8f 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt @@ -22,6 +22,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.datetime.Instant /** @@ -55,6 +56,13 @@ class TestEpisodeDao : EpisodeDao { override suspend fun updateEpisodes(entities: List) { throw NotImplementedError("Unused in tests") } + + override suspend fun deleteEpisodes(ids: List) { + val idSet = ids.toSet() + entitiesStateFlow.update { entities -> + entities.filterNot { idSet.contains(it.id) } + } + } } private fun EpisodeEntity.asPopulatedEpisode() = PopulatedEpisode( diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt index 9d850efbc..561a10feb 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt @@ -28,6 +28,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.datetime.Instant val filteredInterestsIds = setOf(1) @@ -97,6 +98,13 @@ class TestNewsResourceDao : NewsResourceDao { ) { authorCrossReferences = newsResourceAuthorCrossReferences } + + override suspend fun deleteNewsResources(ids: List) { + val idSet = ids.toSet() + entitiesStateFlow.update { entities -> + entities.filterNot { idSet.contains(it.id) } + } + } } private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt index b9cd4b40c..cec2b00d9 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt @@ -25,6 +25,13 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +enum class CollectionType { + Topics, + Authors, + Episodes, + NewsResources +} + /** * Test double for [NiANetwork] */ @@ -32,46 +39,80 @@ class TestNiaNetwork : NiANetwork { private val networkJson = Json - override suspend fun getTopics(ids: List?): List = + private val allTopics = networkJson.decodeFromString>(FakeDataSource.topicsData) - .matchIds( - ids = ids, - idGetter = NetworkTopic::id - ) - override suspend fun getAuthors(ids: List?): List = + private val allAuthors = networkJson.decodeFromString>(FakeDataSource.authors) - .matchIds( - ids = ids, - idGetter = NetworkAuthor::id - ) - override suspend fun getNewsResources(ids: List?): List = + private val allNewsResources = networkJson.decodeFromString>(FakeDataSource.data) - .matchIds( - ids = ids, - idGetter = NetworkNewsResource::id - ) - override suspend fun getTopicChangeList(after: Int?): List = - getTopics(ids = null).mapToChangeList( - after = after, + private val changeLists: MutableMap> = mutableMapOf( + CollectionType.Topics to allTopics + .mapToChangeList(idGetter = NetworkTopic::id), + CollectionType.Authors to allAuthors + .mapToChangeList(idGetter = NetworkAuthor::id), + CollectionType.Episodes to listOf(), + CollectionType.NewsResources to allNewsResources + .mapToChangeList(idGetter = NetworkNewsResource::id), + ) + + override suspend fun getTopics(ids: List?): List = + allTopics.matchIds( + ids = ids, idGetter = NetworkTopic::id ) - override suspend fun getAuthorChangeList(after: Int?): List = - getAuthors(ids = null).mapToChangeList( - after = after, + override suspend fun getAuthors(ids: List?): List = + allAuthors.matchIds( + ids = ids, idGetter = NetworkAuthor::id ) - override suspend fun getNewsResourceChangeList(after: Int?): List = - getNewsResources(ids = null).mapToChangeList( - after = after, + override suspend fun getNewsResources(ids: List?): List = + allNewsResources.matchIds( + ids = ids, idGetter = NetworkNewsResource::id ) + + override suspend fun getTopicChangeList(after: Int?): List = + changeLists.getValue(CollectionType.Topics).after(after) + + override suspend fun getAuthorChangeList(after: Int?): List = + changeLists.getValue(CollectionType.Authors).after(after) + + override suspend fun getNewsResourceChangeList(after: Int?): List = + changeLists.getValue(CollectionType.NewsResources).after(after) + + fun latestChangeListVersion(collectionType: CollectionType) = + changeLists.getValue(collectionType).last().changeListVersion + + fun changeListsAfter(collectionType: CollectionType, version: Int) = + changeLists.getValue(collectionType).after(version) + + /** + * Edits the change list for the backing [collectionType] for the given [id] mimicking + * the server's change list registry + */ + fun editCollection(collectionType: CollectionType, id: Int, isDelete: Boolean) { + val changeList = changeLists.getValue(collectionType) + val latestVersion = changeList.lastOrNull()?.changeListVersion ?: 0 + val change = NetworkChangeList( + id = id, + isDelete = isDelete, + changeListVersion = latestVersion + 1, + ) + changeLists[collectionType] = changeList.filterNot { it.id == id } + change + } } +fun List.after(version: Int?): List = + when (version) { + null -> this + else -> this.filter { it.changeListVersion > version } + } + /** * Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null */ @@ -88,12 +129,11 @@ private fun List.matchIds( * [after] simulates which models have changed by excluding items before it */ private fun List.mapToChangeList( - after: Int?, idGetter: (T) -> Int ) = mapIndexed { index, item -> NetworkChangeList( id = idGetter(item), - changeListVersion = index, + changeListVersion = index + 1, isDelete = false, ) -}.drop(after ?: 0) +} diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt index 87beaa4bb..620df89b0 100644 --- a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt @@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update /** * Test double for [TopicDao] @@ -60,4 +61,11 @@ class TestTopicDao : TopicDao { override suspend fun updateTopics(entities: List) { throw NotImplementedError("Unused in tests") } + + override suspend fun deleteTopics(ids: List) { + val idSet = ids.toSet() + entitiesStateFlow.update { entities -> + entities.filterNot { idSet.contains(it.id) } + } + } } diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt index 55a41fe72..9204e4715 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt @@ -19,11 +19,25 @@ package com.google.samples.apps.nowinandroid.core.network.model import kotlinx.serialization.Serializable /** - * Network representation of a change list for a model + * Network representation of a change list for a model. + * + * Change lists are a representation of a server-side map like data structure of model ids to + * metadata about that model. In a single change list, a given model id can only show up once. */ @Serializable data class NetworkChangeList( + /** + * The id of the model that was changed + */ val id: Int, + /** + * Unique consecutive, monotonically increasing version number in the collection describing + * the relative point of change between models in the collection + */ val changeListVersion: Int, + /** + * Summarizes the update to the model; whether it was deleted or updated. + * Updates include creations. + */ val isDelete: Boolean, ) diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt index 1b5693f15..64dd54a20 100644 --- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestAuthorsRepository.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.testing.repository +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository import com.google.samples.apps.nowinandroid.core.model.data.Author import kotlinx.coroutines.channels.BufferOverflow @@ -52,7 +53,7 @@ class TestAuthorsRepository : AuthorsRepository { } } - override suspend fun sync(): Boolean = true + override suspend fun syncWith(synchronizer: Synchronizer) = true /** * A test-only API to allow controlling the list of topics from tests. diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt index df9ddd927..a7e2e2c8b 100644 --- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.testing.repository +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.NewsResource @@ -54,5 +55,5 @@ class TestNewsRepository : NewsRepository { newsResourcesFlow.tryEmit(newsResources) } - override suspend fun sync(): Boolean = true + override suspend fun syncWith(synchronizer: Synchronizer) = true } diff --git a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt index 7658a1928..2f3f2a3c7 100644 --- a/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt +++ b/core-testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestTopicsRepository.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.testing.repository +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.model.data.Topic import kotlinx.coroutines.channels.BufferOverflow @@ -69,5 +70,5 @@ class TestTopicsRepository : TopicsRepository { */ fun getCurrentFollowedTopics(): Set? = _followedTopicIds.replayCache.firstOrNull() - override suspend fun sync(): Boolean = true + override suspend fun syncWith(synchronizer: Synchronizer) = true } diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt index 354a7589a..18a010c82 100644 --- a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.sync.initializers import android.content.Context import androidx.startup.AppInitializer import androidx.startup.Initializer -import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager import androidx.work.WorkManagerInitializer import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker @@ -43,11 +43,11 @@ private const val SyncWorkName = "SyncWorkName" class SyncInitializer : Initializer { override fun create(context: Context): Sync { WorkManager.getInstance(context).apply { - enqueue(SyncWorker.startUpSyncWork()) - enqueueUniquePeriodicWork( + // Run sync on app startup and ensure only one sync worker runs at any time + enqueueUniqueWork( SyncWorkName, - ExistingPeriodicWorkPolicy.KEEP, - SyncWorker.periodicSyncWork() + ExistingWorkPolicy.REPLACE, + SyncWorker.startUpSyncWork() ) } diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index 1ae07fae3..87e451b05 100644 --- a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -22,8 +22,10 @@ import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkerParameters +import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions +import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import com.google.samples.apps.nowinandroid.core.domain.Synchronizer import com.google.samples.apps.nowinandroid.core.domain.repository.AuthorsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository @@ -31,7 +33,6 @@ import com.google.samples.apps.nowinandroid.sync.initializers.SyncConstraints import com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import java.util.concurrent.TimeUnit import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -44,10 +45,11 @@ import kotlinx.coroutines.coroutineScope class SyncWorker @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted workerParams: WorkerParameters, + private val niaPreferences: NiaPreferences, private val topicRepository: TopicsRepository, private val newsRepository: NewsRepository, private val authorsRepository: AuthorsRepository, -) : CoroutineWorker(appContext, workerParams) { +) : CoroutineWorker(appContext, workerParams), Synchronizer { override suspend fun getForegroundInfo(): ForegroundInfo = appContext.syncForegroundInfo() @@ -64,10 +66,14 @@ class SyncWorker @AssistedInject constructor( else Result.retry() } - companion object { - private const val SyncInterval = 1L - private val SyncIntervalTimeUnit = TimeUnit.DAYS + override suspend fun getChangeListVersions(): ChangeListVersions = + niaPreferences.getChangeListVersions() + + override suspend fun updateChangeListVersions( + update: ChangeListVersions.() -> ChangeListVersions + ) = niaPreferences.updateChangeListVersion(update) + companion object { /** * Expedited one time work to sync data on app startup */ @@ -76,16 +82,5 @@ class SyncWorker @AssistedInject constructor( .setConstraints(SyncConstraints) .setInputData(SyncWorker::class.delegatedData()) .build() - - /** - * Periodic sync work to routinely keep the app up to date - */ - fun periodicSyncWork() = PeriodicWorkRequestBuilder( - SyncInterval, - SyncIntervalTimeUnit - ) - .setConstraints(SyncConstraints) - .setInputData(SyncWorker::class.delegatedData()) - .build() } }