From d01db4bbc2d0eeab86d3e518ebd73018b39e5e92 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 4 Apr 2022 15:35:55 -0400 Subject: [PATCH] Implement change list invalidation in repositories Change-Id: I75a1d378089a52eaf84f2fa7b01e54144d69107b --- .../core/datastore/NiaPreferences.kt | 45 ++++++++++ .../nowinandroid/data/user_preferences.proto | 4 + .../nowinandroid/core/domain/Utilities.kt | 36 ++++++++ .../repository/LocalAuthorsRepository.kt | 28 ++++-- .../domain/repository/LocalNewsRepository.kt | 90 +++++++++++-------- .../repository/LocalTopicsRepository.kt | 28 ++++-- .../repository/LocalAuthorsRepositoryTest.kt | 52 ++++++++++- .../repository/LocalNewsRepositoryTest.kt | 51 +++++++++++ .../repository/LocalTopicsRepositoryTest.kt | 36 ++++++++ .../core/domain/testdoubles/TestNiaNetwork.kt | 69 ++++++++++++-- .../nowinandroid/core/network/NiANetwork.kt | 13 ++- .../core/network/fake/FakeNiANetwork.kt | 30 ++++++- .../core/network/model/NetworkChangeList.kt | 29 ++++++ .../network/retrofit/RetrofitNiANetwork.kt | 43 +++++++-- 14 files changed, 475 insertions(+), 79 deletions(-) create mode 100644 core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt diff --git a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt index 7c275da57..cc345787a 100644 --- a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt +++ b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt @@ -25,6 +25,13 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retry +data class ChangeListVersions( + val topicVersion: Int = -1, + val authorVersion: Int = -1, + val episodeVersion: Int = -1, + val newsResourceVersion: Int = -1, +) + class NiaPreferences @Inject constructor( private val userPreferences: DataStore ) { @@ -81,4 +88,42 @@ class NiaPreferences @Inject constructor( Log.e("NiaPreferences", "Failed to update user preferences", ioException) } } + + suspend fun getChangeListVersions() = userPreferences.data + .map { + ChangeListVersions( + topicVersion = it.topicChangeListVersion, + authorVersion = it.authorChangeListVersion, + episodeVersion = it.episodeChangeListVersion, + newsResourceVersion = it.newsResourceChangeListVersion, + ) + } + .firstOrNull() ?: ChangeListVersions() + + /** + * Update the [ChangeListVersions] using [update]. + */ + suspend fun updateChangeListVersion(update: ChangeListVersions.() -> ChangeListVersions) { + try { + userPreferences.updateData { currentPreferences -> + val updatedChangeListVersions = update( + ChangeListVersions( + topicVersion = currentPreferences.topicChangeListVersion, + authorVersion = currentPreferences.authorChangeListVersion, + episodeVersion = currentPreferences.episodeChangeListVersion, + newsResourceVersion = currentPreferences.newsResourceChangeListVersion + ) + ) + + currentPreferences.copy { + topicChangeListVersion = updatedChangeListVersions.topicVersion + authorChangeListVersion = updatedChangeListVersions.authorVersion + episodeChangeListVersion = updatedChangeListVersions.episodeVersion + newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion + } + } + } catch (ioException: IOException) { + Log.e("NiaPreferences", "Failed to update user preferences", ioException) + } + } } diff --git a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto index f9ab22ab4..5675769d9 100644 --- a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto +++ b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto @@ -22,4 +22,8 @@ option java_multiple_files = true; message UserPreferences { repeated int32 followed_topic_ids = 1; bool has_run_first_time_sync = 2; + int32 topicChangeListVersion = 3; + int32 authorChangeListVersion = 4; + int32 episodeChangeListVersion = 5; + int32 newsResourceChangeListVersion = 6; } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt index 612c0ea96..cd3b37c32 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt @@ -17,6 +17,9 @@ 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 /** @@ -35,3 +38,36 @@ suspend fun suspendRunCatching(block: suspend () -> T): Result = try { ) Result.failure(exception) } + +/** + * Utility function for syncing a repository with the network. + * [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. + * + * 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. + */ +suspend fun changeListSync( + niaPreferences: NiaPreferences, + versionReader: (ChangeListVersions) -> Int, + changeListFetcher: suspend (Int) -> List, + versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions, + modelUpdater: suspend (List) -> Unit +) = suspendRunCatching { + // Fetch the change list since last sync (akin to a git fetch) + val currentVersion = versionReader(niaPreferences.getChangeListVersions()) + val changeList = changeListFetcher(currentVersion) + if (changeList.isEmpty()) return@suspendRunCatching true + + // Using the change list, pull down and save the changes (akin to a git pull) + modelUpdater(changeList.map(NetworkChangeList::id)) + + // Update the last synced version (akin to updating local git HEAD) + val latestVersion = changeList.last().changeListVersion + niaPreferences.updateChangeListVersion { + versionUpdater(latestVersion) + } +}.isSuccess 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 0e4ddb59a..cd8576e50 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 @@ -19,8 +19,10 @@ package com.google.samples.apps.nowinandroid.core.domain.repository import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao 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.changeListSync import com.google.samples.apps.nowinandroid.core.domain.model.asEntity -import com.google.samples.apps.nowinandroid.core.domain.suspendRunCatching import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor @@ -34,17 +36,27 @@ import kotlinx.coroutines.flow.map class LocalAuthorsRepository @Inject constructor( private val authorDao: AuthorDao, private val network: NiANetwork, + private val niaPreferences: NiaPreferences, ) : AuthorsRepository { override fun getAuthorsStream(): Flow> = authorDao.getAuthorEntitiesStream() .map { it.map(AuthorEntity::asExternalModel) } - // TODO: Pass change list for incremental sync. See b/227206738 - override suspend fun sync(): Boolean = suspendRunCatching { - val networkAuthors = network.getAuthors() - authorDao.upsertAuthors( - entities = networkAuthors.map(NetworkAuthor::asEntity) - ) - }.isSuccess + 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) + ) + } + ) } 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 e7128a319..bd3a0370d 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 @@ -25,13 +25,15 @@ import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource 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.changeListSync 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.suspendRunCatching import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource @@ -48,6 +50,7 @@ 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> = @@ -58,44 +61,53 @@ class LocalNewsRepository @Inject constructor( newsResourceDao.getNewsResourcesStream(filterTopicIds = filterTopicIds) .map { it.map(PopulatedNewsResource::asExternalModel) } - // TODO: Pass change list for incremental sync. See b/227206738 - override suspend fun sync() = suspendRunCatching { - val networkNewsResources = network.getNewsResources() + 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) - // 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() - ) - }.isSuccess + 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 ed49f5105..4cf3f4ba6 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 @@ -19,9 +19,10 @@ package com.google.samples.apps.nowinandroid.core.domain.repository import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao 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.changeListSync import com.google.samples.apps.nowinandroid.core.domain.model.asEntity -import com.google.samples.apps.nowinandroid.core.domain.suspendRunCatching import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic @@ -35,7 +36,7 @@ import kotlinx.coroutines.flow.map class LocalTopicsRepository @Inject constructor( private val topicDao: TopicDao, private val network: NiANetwork, - private val niaPreferences: NiaPreferences + private val niaPreferences: NiaPreferences, ) : TopicsRepository { override fun getTopicsStream(): Flow> = @@ -53,11 +54,20 @@ class LocalTopicsRepository @Inject constructor( override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds - // TODO: Pass change list for incremental sync. See b/227206738 - override suspend fun sync(): Boolean = suspendRunCatching { - val networkTopics = network.getTopics() - topicDao.upsertTopics( - entities = networkTopics.map(NetworkTopic::asEntity) - ) - }.isSuccess + 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) + ) + } + ) } 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 b53998cdb..8d1b96a88 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 @@ -19,16 +19,19 @@ package com.google.samples.apps.nowinandroid.core.domain.repository import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao 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.model.asEntity 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.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder class LocalAuthorsRepositoryTest { @@ -36,16 +39,25 @@ class LocalAuthorsRepositoryTest { private lateinit var authorDao: AuthorDao - private lateinit var network: NiANetwork + private lateinit var network: TestNiaNetwork + + private lateinit var niaPreferences: NiaPreferences + + @get:Rule + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @Before fun setup() { authorDao = TestAuthorDao() network = TestNiaNetwork() + niaPreferences = NiaPreferences( + tmpFolder.testUserPreferencesDataStore() + ) subject = LocalAuthorsRepository( authorDao = authorDao, network = network, + niaPreferences = niaPreferences, ) } @@ -76,5 +88,41 @@ class LocalAuthorsRepositoryTest { network.map(AuthorEntity::id), db.map(AuthorEntity::id) ) + + // After sync version should be updated + Assert.assertEquals( + network.lastIndex, + niaPreferences.getChangeListVersions().authorVersion + ) + } + + @Test + fun localAuthorsRepository_incremental_sync_pulls_from_network() = + runTest { + // Set author version to 5 + niaPreferences.updateChangeListVersion { + copy(authorVersion = 5) + } + + subject.sync() + + val network = network.getAuthors() + .map(NetworkAuthor::asEntity) + // Drop 5 to simulate the first 5 items being unchanged + .drop(5) + + val db = authorDao.getAuthorEntitiesStream() + .first() + + Assert.assertEquals( + network.map(AuthorEntity::id), + db.map(AuthorEntity::id) + ) + + // After sync version should be updated + Assert.assertEquals( + network.lastIndex + 5, + niaPreferences.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 91cb15e49..d77826ed9 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 @@ -23,6 +23,8 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource 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.model.asEntity import com.google.samples.apps.nowinandroid.core.domain.model.authorCrossReferences import com.google.samples.apps.nowinandroid.core.domain.model.authorEntityShells @@ -42,7 +44,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder class LocalNewsRepositoryTest { @@ -58,6 +62,11 @@ class LocalNewsRepositoryTest { private lateinit var network: TestNiaNetwork + private lateinit var niaPreferences: NiaPreferences + + @get:Rule + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + @Before fun setup() { newsResourceDao = TestNewsResourceDao() @@ -65,6 +74,9 @@ class LocalNewsRepositoryTest { authorDao = TestAuthorDao() topicDao = TestTopicDao() network = TestNiaNetwork() + niaPreferences = NiaPreferences( + tmpFolder.testUserPreferencesDataStore() + ) subject = LocalNewsRepository( newsResourceDao = newsResourceDao, @@ -72,6 +84,7 @@ class LocalNewsRepositoryTest { authorDao = authorDao, topicDao = topicDao, network = network, + niaPreferences = niaPreferences, ) } @@ -122,6 +135,44 @@ class LocalNewsRepositoryTest { newsResourcesFromNetwork.map(NewsResource::id), newsResourcesFromDb.map(NewsResource::id) ) + + // After sync version should be updated + assertEquals( + newsResourcesFromNetwork.lastIndex, + niaPreferences.getChangeListVersions().newsResourceVersion + ) + } + + @Test + fun localNewsRepository_incremental_sync_pulls_from_network() = + runTest { + // Set news version to 7 + niaPreferences.updateChangeListVersion { + copy(newsResourceVersion = 7) + } + + subject.sync() + + val newsResourcesFromNetwork = network.getNewsResources() + .map(NetworkNewsResource::asEntity) + .map(NewsResourceEntity::asExternalModel) + // Drop 7 to simulate the first 7 items being unchanged + .drop(7) + + val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() + .first() + .map(PopulatedNewsResource::asExternalModel) + + assertEquals( + newsResourcesFromNetwork.map(NewsResource::id), + newsResourcesFromDb.map(NewsResource::id) + ) + + // After sync version should be updated + assertEquals( + newsResourcesFromNetwork.lastIndex + 7, + niaPreferences.getChangeListVersions().newsResourceVersion + ) } @Test 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 1974f442f..34de7f29c 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 @@ -100,6 +100,42 @@ class LocalTopicsRepositoryTest { network.map(TopicEntity::id), db.map(TopicEntity::id) ) + + // After sync version should be updated + Assert.assertEquals( + network.lastIndex, + niaPreferences.getChangeListVersions().topicVersion + ) + } + + @Test + fun localTopicsRepository_incremental_sync_pulls_from_network() = + runTest { + // Set topics version to 10 + niaPreferences.updateChangeListVersion { + copy(topicVersion = 10) + } + + subject.sync() + + val network = network.getTopics() + .map(NetworkTopic::asEntity) + // Drop 10 to simulate the first 10 items being unchanged + .drop(10) + + val db = topicDao.getTopicEntitiesStream() + .first() + + Assert.assertEquals( + network.map(TopicEntity::id), + db.map(TopicEntity::id) + ) + + // After sync version should be updated + Assert.assertEquals( + network.lastIndex + 10, + niaPreferences.getChangeListVersions().topicVersion + ) } @Test 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 99879f46e..b9cd4b40c 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 @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.domain.testdoubles import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor +import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import kotlinx.serialization.decodeFromString @@ -31,12 +32,68 @@ class TestNiaNetwork : NiANetwork { private val networkJson = Json - override suspend fun getTopics(itemsPerPage: Int): List = - networkJson.decodeFromString(FakeDataSource.topicsData) + override suspend fun getTopics(ids: List?): List = + networkJson.decodeFromString>(FakeDataSource.topicsData) + .matchIds( + ids = ids, + idGetter = NetworkTopic::id + ) - override suspend fun getAuthors(itemsPerPage: Int): List = - networkJson.decodeFromString(FakeDataSource.authors) + override suspend fun getAuthors(ids: List?): List = + networkJson.decodeFromString>(FakeDataSource.authors) + .matchIds( + ids = ids, + idGetter = NetworkAuthor::id + ) - override suspend fun getNewsResources(itemsPerPage: Int): List = - networkJson.decodeFromString(FakeDataSource.data) + override suspend fun getNewsResources(ids: List?): List = + networkJson.decodeFromString>(FakeDataSource.data) + .matchIds( + ids = ids, + idGetter = NetworkNewsResource::id + ) + + override suspend fun getTopicChangeList(after: Int?): List = + getTopics(ids = null).mapToChangeList( + after = after, + idGetter = NetworkTopic::id + ) + + override suspend fun getAuthorChangeList(after: Int?): List = + getAuthors(ids = null).mapToChangeList( + after = after, + idGetter = NetworkAuthor::id + ) + + override suspend fun getNewsResourceChangeList(after: Int?): List = + getNewsResources(ids = null).mapToChangeList( + after = after, + idGetter = NetworkNewsResource::id + ) +} + +/** + * Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null + */ +private fun List.matchIds( + ids: List?, + idGetter: (T) -> Int +) = when (ids) { + null -> this + else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } } + +/** + * Maps items to a change list where the change list version is denoted by the index of each item. + * [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, + isDelete = false, + ) +}.drop(after ?: 0) diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt index a35533f04..ae185435d 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.network import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor +import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic @@ -24,9 +25,15 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic * Interface representing network calls to the NIA backend */ interface NiANetwork { - suspend fun getTopics(itemsPerPage: Int = 200): List + suspend fun getTopics(ids: List? = null): List - suspend fun getAuthors(itemsPerPage: Int = 200): List + suspend fun getAuthors(ids: List? = null): List - suspend fun getNewsResources(itemsPerPage: Int = 200): List + suspend fun getNewsResources(ids: List? = null): List + + suspend fun getTopicChangeList(after: Int? = null): List + + suspend fun getAuthorChangeList(after: Int? = null): List + + suspend fun getNewsResourceChangeList(after: Int? = null): List } diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt index 6b3542a6b..77cd05a16 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt @@ -20,6 +20,7 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor +import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import javax.inject.Inject @@ -35,18 +36,41 @@ class FakeNiANetwork @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val networkJson: Json ) : NiANetwork { - override suspend fun getTopics(itemsPerPage: Int): List = + override suspend fun getTopics(ids: List?): List = withContext(ioDispatcher) { networkJson.decodeFromString(FakeDataSource.topicsData) } - override suspend fun getNewsResources(itemsPerPage: Int): List = + override suspend fun getNewsResources(ids: List?): List = withContext(ioDispatcher) { networkJson.decodeFromString(FakeDataSource.data) } - override suspend fun getAuthors(itemsPerPage: Int): List = + override suspend fun getAuthors(ids: List?): List = withContext(ioDispatcher) { networkJson.decodeFromString(FakeDataSource.authors) } + + override suspend fun getTopicChangeList(after: Int?): List = + getTopics().mapToChangeList(NetworkTopic::id) + + override suspend fun getAuthorChangeList(after: Int?): List = + getAuthors().mapToChangeList(NetworkAuthor::id) + + override suspend fun getNewsResourceChangeList(after: Int?): List = + getNewsResources().mapToChangeList(NetworkNewsResource::id) +} + +/** + * Converts a list of [T] to change list of all the items in it where [idGetter] defines the + * [NetworkChangeList.id] + */ +private fun List.mapToChangeList( + idGetter: (T) -> Int +) = mapIndexed { index, item -> + NetworkChangeList( + id = idGetter(item), + changeListVersion = index, + isDelete = false, + ) } 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 new file mode 100644 index 000000000..55a41fe72 --- /dev/null +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkChangeList.kt @@ -0,0 +1,29 @@ +/* + * 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.network.model + +import kotlinx.serialization.Serializable + +/** + * Network representation of a change list for a model + */ +@Serializable +data class NetworkChangeList( + val id: Int, + val changeListVersion: Int, + val isDelete: Boolean, +) diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt index 718451b6e..9b1b97999 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.network.retrofit import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor +import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory @@ -38,18 +39,33 @@ import retrofit2.http.Query private interface RetrofitNiANetworkApi { @GET(value = "topics") suspend fun getTopics( - @Query("pageSize") itemsPerPage: Int, + @Query("id") ids: List?, ): NetworkResponse> @GET(value = "authors") suspend fun getAuthors( - @Query("pageSize") itemsPerPage: Int, + @Query("id") ids: List?, ): NetworkResponse> @GET(value = "newsresources") suspend fun getNewsResources( - @Query("pageSize") itemsPerPage: Int, + @Query("id") ids: List?, ): NetworkResponse> + + @GET(value = "changelists/topics") + suspend fun getTopicChangeList( + @Query("after") after: Int?, + ): List + + @GET(value = "changelists/authors") + suspend fun getAuthorsChangeList( + @Query("after") after: Int?, + ): List + + @GET(value = "changelists/newsresources") + suspend fun getNewsResourcesChangeList( + @Query("after") after: Int?, + ): List } private const val NiABaseUrl = "https://staging-url.com/" @@ -86,12 +102,21 @@ class RetrofitNiANetwork @Inject constructor( .build() .create(RetrofitNiANetworkApi::class.java) - override suspend fun getTopics(itemsPerPage: Int): List = - networkApi.getTopics(itemsPerPage = itemsPerPage).data + override suspend fun getTopics(ids: List?): List = + networkApi.getTopics(ids = ids).data + + override suspend fun getAuthors(ids: List?): List = + networkApi.getAuthors(ids = ids).data + + override suspend fun getNewsResources(ids: List?): List = + networkApi.getNewsResources(ids = ids).data + + override suspend fun getTopicChangeList(after: Int?): List = + networkApi.getTopicChangeList(after = after) - override suspend fun getAuthors(itemsPerPage: Int): List = - networkApi.getAuthors(itemsPerPage = itemsPerPage).data + override suspend fun getAuthorChangeList(after: Int?): List = + networkApi.getAuthorsChangeList(after = after) - override suspend fun getNewsResources(itemsPerPage: Int): List = - networkApi.getNewsResources(itemsPerPage = itemsPerPage).data + override suspend fun getNewsResourceChangeList(after: Int?): List = + networkApi.getNewsResourcesChangeList(after = after) }