From 8cfe91cef81f324b77dce5471adacf9467a3c677 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Thu, 21 Sep 2023 11:35:53 -0600 Subject: [PATCH 01/13] add user sync logic to SyncRepository to handle any related objects --- .../kotlin/org/cru/godtools/model/User.kt | 7 +++++-- .../kotlin/org/cru/godtools/model/UserTest.kt | 3 +++ .../org/cru/godtools/model/user.json | 7 ++++++- .../sync/repository/SyncRepository.kt | 19 ++++++++++++++++++- .../cru/godtools/sync/task/UserSyncTasks.kt | 10 +++++++--- .../sync/repository/SyncRepositoryTest.kt | 1 + .../godtools/sync/task/UserSyncTasksTest.kt | 19 ++++++++++++------- 7 files changed, 52 insertions(+), 14 deletions(-) diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt index 09195993a3..4fedaebb1d 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt @@ -14,7 +14,6 @@ private const val JSON_GIVEN_NAME = "given-name" private const val JSON_FAMILY_NAME = "family-name" private const val JSON_EMAIL = "email" private const val JSON_CREATED_AT = "created-at" -private const val JSON_FAVORITE_TOOLS = "favorite-tools" @JsonApiType(JSON_API_TYPE) data class User @JvmOverloads constructor( @@ -35,6 +34,10 @@ data class User @JvmOverloads constructor( @JsonApiAttribute(JSON_EMAIL) val email: String? = null, ) { + companion object { + const val JSON_FAVORITE_TOOLS = "favorite-tools" + } + @JsonApiAttribute(JSON_FAVORITE_TOOLS) - val apiFavoriteTools: List = emptyList() + val apiFavoriteTools = emptyList() } diff --git a/library/model/src/test/kotlin/org/cru/godtools/model/UserTest.kt b/library/model/src/test/kotlin/org/cru/godtools/model/UserTest.kt index bc0d95f673..6de1e8c9eb 100644 --- a/library/model/src/test/kotlin/org/cru/godtools/model/UserTest.kt +++ b/library/model/src/test/kotlin/org/cru/godtools/model/UserTest.kt @@ -11,6 +11,7 @@ class UserTest { private val jsonApiConverter by lazy { JsonApiConverter.Builder() .addClasses(User::class.java) + .addClasses(Tool::class.java) .addConverters(InstantConverter()) .build() } @@ -20,6 +21,8 @@ class UserTest { val user = parseJson("user.json").dataSingle!! assertEquals("11", user.id) assertEquals(Instant.parse("2022-01-28T14:47:48Z"), user.createdAt) + assertEquals(1, user.apiFavoriteTools.size) + assertEquals(2, user.apiFavoriteTools[0].id) } private fun parseJson(file: String) = this::class.java.getResourceAsStream(file)!!.reader() diff --git a/library/model/src/test/resources/org/cru/godtools/model/user.json b/library/model/src/test/resources/org/cru/godtools/model/user.json index 85c8a6b341..c237543efe 100644 --- a/library/model/src/test/resources/org/cru/godtools/model/user.json +++ b/library/model/src/test/resources/org/cru/godtools/model/user.json @@ -8,7 +8,12 @@ }, "relationships": { "favorite-tools": { - "data": [] + "data": [ + { + "id": "2", + "type": "resource" + } + ] } } } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt index 401126ad06..93ca7204dc 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt @@ -13,9 +13,11 @@ import org.cru.godtools.db.repository.AttachmentsRepository import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.repository.TranslationsRepository +import org.cru.godtools.db.repository.UserRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.Translation +import org.cru.godtools.model.User @Singleton internal class SyncRepository @Inject constructor( @@ -23,9 +25,14 @@ internal class SyncRepository @Inject constructor( private val languagesRepository: LanguagesRepository, private val toolsRepository: ToolsRepository, private val translationsRepository: TranslationsRepository, + private val userRepository: UserRepository, ) { // region Tools - suspend fun storeTools(tools: List, existingTools: MutableSet?, includes: Includes) = coroutineScope { + suspend fun storeTools( + tools: List, + existingTools: MutableSet? = null, + includes: Includes, + ) = coroutineScope { val validTools = tools.filter { it.isValid } if (validTools.isNotEmpty()) toolsRepository.storeToolsFromSync(validTools) @@ -115,4 +122,14 @@ internal class SyncRepository @Inject constructor( return true } // endregion Translations + + // region User + suspend fun storeUser(user: User, includes: Includes) { + userRepository.storeUserFromSync(user) + + if (includes.include(User.JSON_FAVORITE_TOOLS)) { + storeTools(user.apiFavoriteTools, includes = includes.descendant(User.JSON_FAVORITE_TOOLS)) + } + } + // endregion User } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt index 1dbe728c5c..0179d1899b 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt @@ -6,22 +6,26 @@ import javax.inject.Singleton import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.ccci.gto.android.common.base.TimeConstants.WEEK_IN_MS +import org.ccci.gto.android.common.jsonapi.util.Includes import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi import org.cru.godtools.db.repository.LastSyncTimeRepository -import org.cru.godtools.db.repository.UserRepository +import org.cru.godtools.sync.repository.SyncRepository @Singleton internal class UserSyncTasks @Inject constructor( private val accountManager: GodToolsAccountManager, private val lastSyncTimeRepository: LastSyncTimeRepository, + private val syncRepository: SyncRepository, private val userApi: UserApi, - private val userRepository: UserRepository ) : BaseSyncTasks() { companion object { @VisibleForTesting const val SYNC_TIME_USER = "last_synced.user" private const val STALE_DURATION_USER = WEEK_IN_MS + + @VisibleForTesting + internal val INCLUDES_GET_USER = Includes() } private val userMutex = Mutex() @@ -41,7 +45,7 @@ internal class UserSyncTasks @Inject constructor( ?.body()?.takeUnless { it.hasErrors() } ?.dataSingle ?: return false - userRepository.storeUserFromSync(user) + syncRepository.storeUser(user, INCLUDES_GET_USER) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, user.id) true diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt index ac5bfac048..daa72011a2 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt @@ -28,6 +28,7 @@ class SyncRepositoryTest { languagesRepository = languagesRepository, toolsRepository = toolsRepository, translationsRepository = translationsRepository, + userRepository = mockk(), ) // region storeTools() diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt index 3d6aaf5421..fa62c4f2b5 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt @@ -14,8 +14,8 @@ import org.ccci.gto.android.common.jsonapi.model.JsonApiObject import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi import org.cru.godtools.db.repository.InMemoryLastSyncTimeRepository -import org.cru.godtools.db.repository.UserRepository import org.cru.godtools.model.User +import org.cru.godtools.sync.repository.SyncRepository import org.cru.godtools.sync.task.UserSyncTasks.Companion.SYNC_TIME_USER import org.junit.Assert.assertTrue import org.junit.Test @@ -32,9 +32,14 @@ class UserSyncTasksTest { private val lastSyncTimeRepository = spyk(InMemoryLastSyncTimeRepository()) { excludeRecords { setLastSyncTime(key = anyVararg(), time = any()) } } - private val userRepository: UserRepository = mockk() + private val syncRepository: SyncRepository = mockk() - private val tasks = UserSyncTasks(accountManager, lastSyncTimeRepository, userApi, userRepository) + private val tasks = UserSyncTasks( + accountManager, + lastSyncTimeRepository, + syncRepository = syncRepository, + userApi = userApi, + ) // region syncCounters() @Test @@ -69,14 +74,14 @@ class UserSyncTasksTest { val startTime = System.currentTimeMillis() val user = User(id = USER_ID) coEvery { userApi.getUser() } returns Response.success(JsonApiObject.of(user)) - coEvery { userRepository.storeUserFromSync(user) } just Runs + coEvery { syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) } just Runs assertTrue(tasks.syncUser(force = true)) coVerifyAll { accountManager.isAuthenticated() accountManager.userId() userApi.getUser() - userRepository.storeUserFromSync(user) + syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, USER_ID) } assertTrue(lastSyncTimeRepository.getLastSyncTime(SYNC_TIME_USER, USER_ID) >= startTime) @@ -88,14 +93,14 @@ class UserSyncTasksTest { val user = User(id = USER_ID) coEvery { lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_USER, USER_ID, staleAfter = any()) } returns true coEvery { userApi.getUser() } returns Response.success(JsonApiObject.of(user)) - coEvery { userRepository.storeUserFromSync(user) } just Runs + coEvery { syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) } just Runs assertTrue(tasks.syncUser(force = true)) coVerifyAll { accountManager.isAuthenticated() accountManager.userId() userApi.getUser() - userRepository.storeUserFromSync(user) + syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, USER_ID) } assertTrue(lastSyncTimeRepository.getLastSyncTime(SYNC_TIME_USER, USER_ID) >= startTime) From bd07e694bd8b3302d647a35a9f6d2610e17eab50 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 25 Sep 2023 14:02:39 -0600 Subject: [PATCH 02/13] add support for storing favorite tools from sync in the database --- .../godtools/db/repository/ToolsRepository.kt | 1 + .../org/cru/godtools/db/room/dao/ToolsDao.kt | 4 ++ .../db/room/repository/ToolsRoomRepository.kt | 13 +++++ .../db/repository/ToolsRepositoryIT.kt | 47 +++++++++++++++++++ .../cru/godtools/model/ChangeTrackingModel.kt | 5 ++ .../kotlin/org/cru/godtools/model/Tool.kt | 4 ++ 6 files changed, 74 insertions(+) diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/repository/ToolsRepository.kt b/library/db/src/main/kotlin/org/cru/godtools/db/repository/ToolsRepository.kt index 80b123f5d7..4c7f41f01e 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/repository/ToolsRepository.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/repository/ToolsRepository.kt @@ -40,6 +40,7 @@ interface ToolsRepository { // region Sync Methods suspend fun storeToolsFromSync(tools: Collection) + suspend fun storeFavoriteToolsFromSync(tools: Collection) suspend fun deleteIfNotFavorite(code: String) // endregion Sync Methods } diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/ToolsDao.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/ToolsDao.kt index 4838290a9b..a57ff6f410 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/ToolsDao.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/ToolsDao.kt @@ -41,6 +41,8 @@ internal interface ToolsDao { "SELECT * FROM tools WHERE type in (:types) AND code IN (SELECT tool FROM translations WHERE locale = :locale)" ) fun getToolsFlowByTypeAndLanguage(types: Collection, locale: Locale): Flow> + @Query("SELECT * FROM tools") + suspend fun getToolFavorites(): List @Insert(onConflict = OnConflictStrategy.IGNORE) fun insertOrIgnoreTools(tools: Collection) @@ -48,6 +50,8 @@ internal interface ToolsDao { suspend fun upsertSyncTools(tools: Collection) @Update(entity = ToolEntity::class) suspend fun update(tool: ToolFavorite) + @Update(entity = ToolEntity::class) + suspend fun updateToolFavorites(tools: Collection) @Query("UPDATE tools SET `order` = ${Int.MAX_VALUE}") fun resetToolOrder() @Query("UPDATE tools SET `order` = :order WHERE code = :code") diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/ToolsRoomRepository.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/ToolsRoomRepository.kt index 89cd77a605..58b1dcfd51 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/ToolsRoomRepository.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/ToolsRoomRepository.kt @@ -63,11 +63,24 @@ internal abstract class ToolsRoomRepository(private val db: GodToolsRoomDatabase override suspend fun storeInitialResources(tools: Collection) = dao.insertOrIgnoreTools(tools.map { ToolEntity(it) }) + // region Sync Methods override suspend fun storeToolsFromSync(tools: Collection) = dao.upsertSyncTools(tools.map { SyncTool(it) }) + @Transaction + override suspend fun storeFavoriteToolsFromSync(tools: Collection) { + val favorites = tools.mapNotNullTo(mutableSetOf()) { it.code } + val toolFavorites = dao.getToolFavorites().onEach { + val isFavorite = it.code in favorites + if (isFavorite == it.isFavorite) it.clearChanged(Tool.ATTR_IS_FAVORITE) + if (!it.isFieldChanged(Tool.ATTR_IS_FAVORITE)) it.isFavorite = isFavorite + } + dao.updateToolFavorites(toolFavorites) + } + @Transaction override suspend fun deleteIfNotFavorite(code: String) { val tool = dao.findTool(code)?.takeUnless { it.isFavorite } ?: return dao.delete(tool) } + // endregion Sync Methods } diff --git a/library/db/src/test/kotlin/org/cru/godtools/db/repository/ToolsRepositoryIT.kt b/library/db/src/test/kotlin/org/cru/godtools/db/repository/ToolsRepositoryIT.kt index e89d134569..a2c4f77a9b 100644 --- a/library/db/src/test/kotlin/org/cru/godtools/db/repository/ToolsRepositoryIT.kt +++ b/library/db/src/test/kotlin/org/cru/godtools/db/repository/ToolsRepositoryIT.kt @@ -23,6 +23,7 @@ import org.cru.godtools.model.Tool import org.cru.godtools.model.ToolMatchers.tool import org.cru.godtools.model.Translation import org.cru.godtools.model.randomTool +import org.cru.godtools.model.trackChanges import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.containsInAnyOrder @@ -512,6 +513,52 @@ abstract class ToolsRepositoryIT { } // endregion storeToolsFromSync() + // region storeFavoriteToolsFromSync() + @Test + fun `storeFavoriteToolsFromSync()`() = testScope.runTest { + repository.storeInitialResources( + listOf( + Tool("tool1") { isFavorite = true }, + Tool("tool2") { isFavorite = false }, + Tool("tool3") { isFavorite = true }, + ) + ) + + repository.storeFavoriteToolsFromSync(listOf(Tool("tool2"), Tool("tool3"))) + assertFalse(repository.findTool("tool1")!!.isFavorite) + assertTrue(repository.findTool("tool2")!!.isFavorite) + assertTrue(repository.findTool("tool3")!!.isFavorite) + } + + @Test + fun `storeFavoriteToolsFromSync() - Handle dirty tools`() = testScope.runTest { + repository.storeInitialResources( + listOf( + Tool("tool1") { trackChanges { isFavorite = true } }, + Tool("tool2") { + isFavorite = true + trackChanges { isFavorite = false } + }, + Tool("tool3") { trackChanges { isFavorite = true } }, + ) + ) + + repository.storeFavoriteToolsFromSync(listOf(Tool("tool2"), Tool("tool3"))) + assertNotNull(repository.findTool("tool1")) { + assertTrue(it.isFavorite) + assertTrue(it.isFieldChanged(Tool.ATTR_IS_FAVORITE)) + } + assertNotNull(repository.findTool("tool2")) { + assertFalse(it.isFavorite) + assertTrue(it.isFieldChanged(Tool.ATTR_IS_FAVORITE)) + } + assertNotNull(repository.findTool("tool3")) { + assertTrue(it.isFavorite) + assertFalse(it.isFieldChanged(Tool.ATTR_IS_FAVORITE)) + } + } + // endregion storeFavoriteToolsFromSync() + // region deleteIfNotFavorite() @Test fun `deleteIfNotFavorite()`() = testScope.runTest { diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/ChangeTrackingModel.kt b/library/model/src/main/kotlin/org/cru/godtools/model/ChangeTrackingModel.kt index 14b9a5eda3..be4fdd9b47 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/ChangeTrackingModel.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/ChangeTrackingModel.kt @@ -5,10 +5,15 @@ interface ChangeTrackingModel { var isTrackingChanges: Boolean var changedFieldsStr: String + fun isFieldChanged(field: String) = field in changedFields fun markChanged(field: String) { if (isTrackingChanges) changedFieldsStr = "$changedFieldsStr,$field" } + + fun clearChanged(field: String) { + changedFieldsStr = changedFields.filterNot { it == field }.joinToString(",") + } } inline fun T.trackChanges(block: (T) -> Unit) { diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt b/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt index d6ad6d0dbb..95d5eb833a 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt @@ -162,6 +162,10 @@ class Tool : Base(), ChangeTrackingModel { @JsonApiIgnore var isFavorite = false + set(value) { + if (value != field) markChanged(ATTR_IS_FAVORITE) + field = value + } @JsonApiAttribute(JSON_HIDDEN) var isHidden = false @JsonApiAttribute(JSON_SPOTLIGHT) From e03aa6503f0881e77cc31dd9b196ce183406935b Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 25 Sep 2023 14:06:52 -0600 Subject: [PATCH 03/13] update user API method to support additional params --- .../api/src/main/kotlin/org/cru/godtools/api/UserApi.kt | 4 +++- .../kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt | 5 ++++- .../org/cru/godtools/sync/task/UserSyncTasksTest.kt | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt index 5c99e4d8a4..8857e9f65e 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt @@ -1,13 +1,15 @@ package org.cru.godtools.api import org.ccci.gto.android.common.jsonapi.model.JsonApiObject +import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams import org.cru.godtools.model.User import retrofit2.Response import retrofit2.http.GET +import retrofit2.http.QueryMap internal const val PATH_USER = "users/me" interface UserApi { @GET(PATH_USER) - suspend fun getUser(): Response> + suspend fun getUser(@QueryMap params: JsonApiParams = JsonApiParams()): Response> } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt index 0179d1899b..6a2b65accc 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt @@ -6,6 +6,7 @@ import javax.inject.Singleton import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.ccci.gto.android.common.base.TimeConstants.WEEK_IN_MS +import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams import org.ccci.gto.android.common.jsonapi.util.Includes import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi @@ -41,7 +42,9 @@ internal class UserSyncTasks @Inject constructor( return true } - val user = userApi.getUser().takeIf { it.isSuccessful } + val params = JsonApiParams() + .includes(INCLUDES_GET_USER) + val user = userApi.getUser(params).takeIf { it.isSuccessful } ?.body()?.takeUnless { it.hasErrors() } ?.dataSingle ?: return false diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt index fa62c4f2b5..cfbff82cd8 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt @@ -73,14 +73,14 @@ class UserSyncTasksTest { fun `syncUser(force = true)`() = runTest { val startTime = System.currentTimeMillis() val user = User(id = USER_ID) - coEvery { userApi.getUser() } returns Response.success(JsonApiObject.of(user)) + coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of(user)) coEvery { syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) } just Runs assertTrue(tasks.syncUser(force = true)) coVerifyAll { accountManager.isAuthenticated() accountManager.userId() - userApi.getUser() + userApi.getUser(any()) syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, USER_ID) } @@ -92,14 +92,14 @@ class UserSyncTasksTest { val startTime = System.currentTimeMillis() val user = User(id = USER_ID) coEvery { lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_USER, USER_ID, staleAfter = any()) } returns true - coEvery { userApi.getUser() } returns Response.success(JsonApiObject.of(user)) + coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of(user)) coEvery { syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) } just Runs assertTrue(tasks.syncUser(force = true)) coVerifyAll { accountManager.isAuthenticated() accountManager.userId() - userApi.getUser() + userApi.getUser(any()) syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, USER_ID) } From d4dee65d2f0f6aa9da1d785a0469efcb112202bb Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 25 Sep 2023 16:25:04 -0600 Subject: [PATCH 04/13] support syncing favorite tools from the API when syncing the User object --- .../kotlin/org/cru/godtools/model/User.kt | 9 +++- .../sync/repository/SyncRepository.kt | 9 +++- .../cru/godtools/sync/task/UserSyncTasks.kt | 5 +- .../sync/repository/SyncRepositoryTest.kt | 46 ++++++++++++++++++- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt index 4fedaebb1d..5da8acd692 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt @@ -1,5 +1,6 @@ package org.cru.godtools.model +import androidx.annotation.VisibleForTesting import java.time.Instant import org.ccci.gto.android.common.jsonapi.annotation.JsonApiAttribute import org.ccci.gto.android.common.jsonapi.annotation.JsonApiId @@ -14,6 +15,7 @@ private const val JSON_GIVEN_NAME = "given-name" private const val JSON_FAMILY_NAME = "family-name" private const val JSON_EMAIL = "email" private const val JSON_CREATED_AT = "created-at" +private const val JSON_INITIAL_FAVORITE_TOOLS_SYNCED = "attr-initial-favorite-tools-synced" @JsonApiType(JSON_API_TYPE) data class User @JvmOverloads constructor( @@ -38,6 +40,11 @@ data class User @JvmOverloads constructor( const val JSON_FAVORITE_TOOLS = "favorite-tools" } + @set:VisibleForTesting + @JsonApiAttribute(JSON_INITIAL_FAVORITE_TOOLS_SYNCED) + var isInitialFavoriteToolsSynced = false + + @set:VisibleForTesting @JsonApiAttribute(JSON_FAVORITE_TOOLS) - val apiFavoriteTools = emptyList() + var apiFavoriteTools = emptyList() } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt index 93ca7204dc..863bc06b8e 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt @@ -91,6 +91,11 @@ internal class SyncRepository @Inject constructor( toolsRepository.storeToolsFromSync(setOf(tool)) return setOfNotNull(tool.code) + processIncludes(tool, includes) } + + private suspend fun storeFavoriteTools(tools: List, includes: Includes) { + storeTools(tools, includes = includes) + toolsRepository.storeFavoriteToolsFromSync(tools) + } // endregion Tools // region Languages @@ -127,8 +132,8 @@ internal class SyncRepository @Inject constructor( suspend fun storeUser(user: User, includes: Includes) { userRepository.storeUserFromSync(user) - if (includes.include(User.JSON_FAVORITE_TOOLS)) { - storeTools(user.apiFavoriteTools, includes = includes.descendant(User.JSON_FAVORITE_TOOLS)) + if (user.isInitialFavoriteToolsSynced && includes.include(User.JSON_FAVORITE_TOOLS)) { + storeFavoriteTools(user.apiFavoriteTools, includes = includes.descendant(User.JSON_FAVORITE_TOOLS)) } } // endregion User diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt index 6a2b65accc..8e8e45b318 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt @@ -11,6 +11,8 @@ import org.ccci.gto.android.common.jsonapi.util.Includes import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi import org.cru.godtools.db.repository.LastSyncTimeRepository +import org.cru.godtools.model.Tool +import org.cru.godtools.model.User import org.cru.godtools.sync.repository.SyncRepository @Singleton @@ -26,7 +28,7 @@ internal class UserSyncTasks @Inject constructor( private const val STALE_DURATION_USER = WEEK_IN_MS @VisibleForTesting - internal val INCLUDES_GET_USER = Includes() + internal val INCLUDES_GET_USER = Includes(User.JSON_FAVORITE_TOOLS) } private val userMutex = Mutex() @@ -44,6 +46,7 @@ internal class UserSyncTasks @Inject constructor( val params = JsonApiParams() .includes(INCLUDES_GET_USER) + .fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS) val user = userApi.getUser(params).takeIf { it.isSuccessful } ?.body()?.takeUnless { it.hasErrors() } ?.dataSingle ?: return false diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt index daa72011a2..edfc9e53cc 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt @@ -12,9 +12,11 @@ import org.ccci.gto.android.common.jsonapi.util.Includes import org.cru.godtools.db.repository.LanguagesRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.repository.TranslationsRepository +import org.cru.godtools.db.repository.UserRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.Translation +import org.cru.godtools.model.User import org.junit.Assert.assertFalse import org.junit.Test @@ -22,13 +24,14 @@ class SyncRepositoryTest { private val languagesRepository: LanguagesRepository = mockk(relaxUnitFun = true) private val toolsRepository: ToolsRepository = mockk(relaxUnitFun = true) private val translationsRepository: TranslationsRepository = mockk(relaxUnitFun = true) + private val userRepository: UserRepository = mockk(relaxUnitFun = true) private val syncRepository = SyncRepository( attachmentsRepository = mockk(), languagesRepository = languagesRepository, toolsRepository = toolsRepository, translationsRepository = translationsRepository, - userRepository = mockk(), + userRepository = userRepository, ) // region storeTools() @@ -122,4 +125,45 @@ class SyncRepositoryTest { } } // endregion storeTranslations() + + // region storeUser() + private val user = User().apply { + apiFavoriteTools = listOf( + Tool("a"), + Tool("b"), + ) + } + + @Test + fun `storeUser()`() = runTest { + syncRepository.storeUser(user, Includes()) + coVerifyAll { + userRepository.storeUserFromSync(user) + toolsRepository wasNot Called + } + } + + @Test + fun `storeUser() - Store favorite tools`() = runTest { + user.isInitialFavoriteToolsSynced = true + + syncRepository.storeUser(user, Includes(User.JSON_FAVORITE_TOOLS)) + coVerifyAll { + userRepository.storeUserFromSync(user) + toolsRepository.storeToolsFromSync(user.apiFavoriteTools) + toolsRepository.storeFavoriteToolsFromSync(user.apiFavoriteTools) + } + } + + @Test + fun `storeUser() - Don't store favorite tools if they haven't been synced yet`() = runTest { + user.isInitialFavoriteToolsSynced = false + + syncRepository.storeUser(user, Includes(User.JSON_FAVORITE_TOOLS)) + coVerifyAll { + userRepository.storeUserFromSync(user) + toolsRepository wasNot Called + } + } + // endregion storeUser() } From eec2e935c091ca861269ea86e8f910683fceb19d Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 25 Sep 2023 17:55:32 -0600 Subject: [PATCH 05/13] add a UserRepository.findUser method --- .../godtools/db/repository/UserRepository.kt | 1 + .../org/cru/godtools/db/room/dao/UserDao.kt | 2 ++ .../db/room/repository/UserRoomRepository.kt | 1 + .../db/repository/UserRepositoryIT.kt | 24 +++++++++++++++++++ .../room/repository/UserRoomRepositoryIT.kt | 15 ++++++++++++ .../kotlin/org/cru/godtools/model/User.kt | 15 ++++++++++++ 6 files changed, 58 insertions(+) create mode 100644 library/db/src/test/kotlin/org/cru/godtools/db/repository/UserRepositoryIT.kt create mode 100644 library/db/src/test/kotlin/org/cru/godtools/db/room/repository/UserRoomRepositoryIT.kt diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/repository/UserRepository.kt b/library/db/src/main/kotlin/org/cru/godtools/db/repository/UserRepository.kt index 6e57d3dd43..b792168752 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/repository/UserRepository.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/repository/UserRepository.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow import org.cru.godtools.model.User interface UserRepository { + suspend fun findUser(userId: String): User? fun findUserFlow(userId: String): Flow // region Sync Methods diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/UserDao.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/UserDao.kt index 915bb3c8e0..4f5d5e206d 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/UserDao.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/dao/UserDao.kt @@ -9,6 +9,8 @@ import org.cru.godtools.db.room.entity.UserEntity @Dao internal interface UserDao { + @Query("SELECT * FROM users WHERE id = :userId") + suspend fun findUser(userId: String): UserEntity? @Query("SELECT * FROM users WHERE id = :userId") fun findUserFlow(userId: String): Flow diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/UserRoomRepository.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/UserRoomRepository.kt index fcce36baaf..9dd308bc0c 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/UserRoomRepository.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/repository/UserRoomRepository.kt @@ -11,6 +11,7 @@ import org.cru.godtools.model.User internal abstract class UserRoomRepository(private val db: GodToolsRoomDatabase) : UserRepository { private val dao get() = db.userDao + override suspend fun findUser(userId: String) = dao.findUser(userId)?.toModel() override fun findUserFlow(userId: String) = dao.findUserFlow(userId).map { it?.toModel() } override suspend fun storeUserFromSync(user: User) { diff --git a/library/db/src/test/kotlin/org/cru/godtools/db/repository/UserRepositoryIT.kt b/library/db/src/test/kotlin/org/cru/godtools/db/repository/UserRepositoryIT.kt new file mode 100644 index 0000000000..780b1a9d44 --- /dev/null +++ b/library/db/src/test/kotlin/org/cru/godtools/db/repository/UserRepositoryIT.kt @@ -0,0 +1,24 @@ +package org.cru.godtools.db.repository + +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import org.cru.godtools.model.randomUser +import org.junit.Test + +abstract class UserRepositoryIT { + internal abstract val repository: UserRepository + + @Test + fun `findUser()`() = runTest { + val user = randomUser() + repository.storeUserFromSync(user) + + assertEquals(user, repository.findUser(user.id)) + } + + @Test + fun `findUser() - Doesn't Exist`() = runTest { + assertNull(repository.findUser("invalid")) + } +} diff --git a/library/db/src/test/kotlin/org/cru/godtools/db/room/repository/UserRoomRepositoryIT.kt b/library/db/src/test/kotlin/org/cru/godtools/db/room/repository/UserRoomRepositoryIT.kt new file mode 100644 index 0000000000..95c0c49f1c --- /dev/null +++ b/library/db/src/test/kotlin/org/cru/godtools/db/room/repository/UserRoomRepositoryIT.kt @@ -0,0 +1,15 @@ +package org.cru.godtools.db.room.repository + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.ccci.gto.android.common.androidx.room.RoomDatabaseRule +import org.cru.godtools.db.repository.UserRepositoryIT +import org.cru.godtools.db.room.GodToolsRoomDatabase +import org.junit.Rule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UserRoomRepositoryIT : UserRepositoryIT() { + @get:Rule + internal val dbRule = RoomDatabaseRule(GodToolsRoomDatabase::class.java) + override val repository get() = dbRule.db.userRepository +} diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt index 5da8acd692..e993ee855c 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt @@ -1,7 +1,10 @@ package org.cru.godtools.model +import androidx.annotation.RestrictTo import androidx.annotation.VisibleForTesting import java.time.Instant +import java.util.UUID +import kotlin.random.Random import org.ccci.gto.android.common.jsonapi.annotation.JsonApiAttribute import org.ccci.gto.android.common.jsonapi.annotation.JsonApiId import org.ccci.gto.android.common.jsonapi.annotation.JsonApiIgnore @@ -48,3 +51,15 @@ data class User @JvmOverloads constructor( @JsonApiAttribute(JSON_FAVORITE_TOOLS) var apiFavoriteTools = emptyList() } + +// TODO: move this to testFixtures once they support Kotlin source files +@RestrictTo(RestrictTo.Scope.TESTS) +fun randomUser() = User( + id = UUID.randomUUID().toString(), + ssoGuid = UUID.randomUUID().toString(), + createdAt = Instant.ofEpochMilli(Random.nextLong()), + name = UUID.randomUUID().toString(), + givenName = UUID.randomUUID().toString(), + familyName = UUID.randomUUID().toString(), + email = UUID.randomUUID().toString(), +) From a985fc4e8e1cf650c0dfd236b2bb783b160f0b45 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 25 Sep 2023 18:06:32 -0600 Subject: [PATCH 06/13] track if the initial favorite tools have been synced or not in the db --- .../17.json | 693 ++++++++++++++++++ .../godtools/db/room/GodToolsRoomDatabase.kt | 4 +- .../cru/godtools/db/room/entity/UserEntity.kt | 5 + .../room/GodToolsRoomDatabaseMigrationIT.kt | 18 + .../kotlin/org/cru/godtools/model/User.kt | 7 +- .../sync/repository/SyncRepositoryTest.kt | 28 +- 6 files changed, 741 insertions(+), 14 deletions(-) create mode 100644 library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/17.json diff --git a/library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/17.json b/library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/17.json new file mode 100644 index 0000000000..caecb089df --- /dev/null +++ b/library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/17.json @@ -0,0 +1,693 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "288e0515f2cf370e063ab9462736a6af", + "entities": [ + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tool` TEXT, `filename` TEXT, `sha256` TEXT, `isDownloaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`), FOREIGN KEY(`tool`) REFERENCES `tools`(`code`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tool", + "columnName": "tool", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attachments_tool", + "unique": false, + "columnNames": [ + "tool" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_tool` ON `${TABLE_NAME}` (`tool`)" + } + ], + "foreignKeys": [ + { + "table": "tools", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tool" + ], + "referencedColumns": [ + "code" + ] + } + ] + }, + { + "tableName": "languages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `id` INTEGER NOT NULL, `name` TEXT, `isAdded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isAdded", + "columnName": "isAdded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "downloadedFiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`filename` TEXT NOT NULL, PRIMARY KEY(`filename`))", + "fields": [ + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "filename" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "followups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT, `email` TEXT NOT NULL, `destination` INTEGER NOT NULL, `language` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "global_activity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`users` INTEGER NOT NULL, `countries` INTEGER NOT NULL, `launches` INTEGER NOT NULL, `gospelPresentations` INTEGER NOT NULL, `id` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "users", + "columnName": "users", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "countries", + "columnName": "countries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launches", + "columnName": "launches", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gospelPresentations", + "columnName": "gospelPresentations", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tools", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `code` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT, `category` TEXT, `description` TEXT, `shares` INTEGER NOT NULL DEFAULT 0, `pendingShares` INTEGER NOT NULL DEFAULT 0, `bannerId` INTEGER, `detailsBannerId` INTEGER, `detailsBannerAnimationId` INTEGER, `detailsBannerYoutubeVideoId` TEXT, `isScreenShareDisabled` INTEGER NOT NULL DEFAULT false, `defaultOrder` INTEGER NOT NULL DEFAULT 0, `order` INTEGER NOT NULL DEFAULT 2147483647, `metatoolCode` TEXT, `defaultVariantCode` TEXT, `isFavorite` INTEGER NOT NULL DEFAULT false, `isHidden` INTEGER NOT NULL DEFAULT false, `isSpotlight` INTEGER NOT NULL DEFAULT false, `changedFields` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shares", + "columnName": "shares", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pendingShares", + "columnName": "pendingShares", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "bannerId", + "columnName": "bannerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "detailsBannerId", + "columnName": "detailsBannerId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "detailsBannerAnimationId", + "columnName": "detailsBannerAnimationId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "detailsBannerYoutubeVideoId", + "columnName": "detailsBannerYoutubeVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isScreenShareDisabled", + "columnName": "isScreenShareDisabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "defaultOrder", + "columnName": "defaultOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2147483647" + }, + { + "fieldPath": "metatoolCode", + "columnName": "metatoolCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "defaultVariantCode", + "columnName": "defaultVariantCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isSpotlight", + "columnName": "isSpotlight", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "changedFields", + "columnName": "changedFields", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "training_tips", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`isCompleted` INTEGER NOT NULL, `isNew` INTEGER NOT NULL, `tool` TEXT NOT NULL, `locale` TEXT NOT NULL, `tipId` TEXT NOT NULL, PRIMARY KEY(`tool`, `locale`, `tipId`))", + "fields": [ + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNew", + "columnName": "isNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key.tool", + "columnName": "tool", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key.locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key.tipId", + "columnName": "tipId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tool", + "locale", + "tipId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "translations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tool` TEXT NOT NULL, `locale` TEXT NOT NULL, `version` INTEGER NOT NULL, `name` TEXT, `description` TEXT, `tagline` TEXT, `toolDetailsConversationStarters` TEXT, `toolDetailsOutline` TEXT, `toolDetailsBibleReferences` TEXT, `manifestFileName` TEXT, `isDownloaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`), FOREIGN KEY(`tool`) REFERENCES `tools`(`code`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`locale`) REFERENCES `languages`(`code`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tool", + "columnName": "tool", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toolDetailsConversationStarters", + "columnName": "toolDetailsConversationStarters", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toolDetailsOutline", + "columnName": "toolDetailsOutline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toolDetailsBibleReferences", + "columnName": "toolDetailsBibleReferences", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifestFileName", + "columnName": "manifestFileName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_translations_tool_locale", + "unique": false, + "columnNames": [ + "tool", + "locale" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_translations_tool_locale` ON `${TABLE_NAME}` (`tool`, `locale`)" + }, + { + "name": "index_translations_tool_locale_version", + "unique": false, + "columnNames": [ + "tool", + "locale", + "version" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_translations_tool_locale_version` ON `${TABLE_NAME}` (`tool` ASC, `locale` ASC, `version` DESC)" + } + ], + "foreignKeys": [ + { + "table": "tools", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tool" + ], + "referencedColumns": [ + "code" + ] + }, + { + "table": "languages", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "locale" + ], + "referencedColumns": [ + "code" + ] + } + ] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `ssoGuid` TEXT, `name` TEXT, `givenName` TEXT, `familyName` TEXT, `email` TEXT, `createdAt` INTEGER, `isInitialFavoriteToolsSynced` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ssoGuid", + "columnName": "ssoGuid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "givenName", + "columnName": "givenName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "familyName", + "columnName": "familyName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isInitialFavoriteToolsSynced", + "columnName": "isInitialFavoriteToolsSynced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_counters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `count` INTEGER NOT NULL, `decayedCount` REAL NOT NULL, `delta` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "decayedCount", + "columnName": "decayedCount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "delta", + "columnName": "delta", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "last_sync_times", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `time` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '288e0515f2cf370e063ab9462736a6af')" + ] + } +} \ No newline at end of file diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabase.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabase.kt index 0ffe0c316b..8ddaae06e7 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabase.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabase.kt @@ -44,7 +44,7 @@ import org.cru.godtools.db.room.repository.UserCountersRoomRepository import org.cru.godtools.db.room.repository.UserRoomRepository @Database( - version = 16, + version = 17, entities = [ AttachmentEntity::class, LanguageEntity::class, @@ -69,6 +69,7 @@ import org.cru.godtools.db.room.repository.UserRoomRepository AutoMigration(from = 13, to = 14, spec = Migration14::class), AutoMigration(from = 14, to = 15), AutoMigration(from = 15, to = 16, spec = ResetUserSyncMigration::class), + AutoMigration(from = 16, to = 17), ], ) @TypeConverters(Java8TimeConverters::class, LocaleConverter::class) @@ -124,6 +125,7 @@ internal abstract class GodToolsRoomDatabase : RoomDatabase() { * 14: 2023-09-18 * 15: 2023-09-18 * 16: 2023-09-19 + * 17: 2023-09-25 */ internal fun RoomDatabase.Builder.enableMigrations() = fallbackToDestructiveMigration() diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/UserEntity.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/UserEntity.kt index 38729551b0..e29aa15f08 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/UserEntity.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/UserEntity.kt @@ -1,5 +1,6 @@ package org.cru.godtools.db.room.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import java.time.Instant @@ -15,6 +16,8 @@ internal class UserEntity( val familyName: String?, val email: String?, val createdAt: Instant?, + @ColumnInfo(defaultValue = "false") + val isInitialFavoriteToolsSynced: Boolean = false, ) { constructor(user: User) : this( id = user.id, @@ -24,6 +27,7 @@ internal class UserEntity( familyName = user.familyName, email = user.email, createdAt = user.createdAt, + isInitialFavoriteToolsSynced = user.isInitialFavoriteToolsSynced, ) fun toModel() = User( @@ -34,5 +38,6 @@ internal class UserEntity( familyName = familyName, email = email, createdAt = createdAt, + isInitialFavoriteToolsSynced = isInitialFavoriteToolsSynced, ) } diff --git a/library/db/src/test/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabaseMigrationIT.kt b/library/db/src/test/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabaseMigrationIT.kt index 4896520f8e..432919777f 100644 --- a/library/db/src/test/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabaseMigrationIT.kt +++ b/library/db/src/test/kotlin/org/cru/godtools/db/room/GodToolsRoomDatabaseMigrationIT.kt @@ -239,4 +239,22 @@ class GodToolsRoomDatabaseMigrationIT { } } } + + @Test + fun testMigrate16To17() { + // create v16 database + helper.createDatabase(GodToolsRoomDatabase.DATABASE_NAME, 16).use { db -> + db.execSQL("""INSERT INTO users (id) VALUES (1)""") + } + + // run migration + helper.runMigrationsAndValidate(GodToolsRoomDatabase.DATABASE_NAME, 17, true, *MIGRATIONS).use { db -> + db.query("SELECT id, isInitialFavoriteToolsSynced FROM users").use { + assertEquals(1, it.count) + it.moveToFirst() + assertEquals(1, it.getIntOrNull(0)) + assertEquals(0, it.getIntOrNull(1)) + } + } + } } diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt index e993ee855c..0fa2d6716f 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt @@ -38,15 +38,13 @@ data class User @JvmOverloads constructor( val familyName: String? = null, @JsonApiAttribute(JSON_EMAIL) val email: String? = null, + @JsonApiAttribute(JSON_INITIAL_FAVORITE_TOOLS_SYNCED) + val isInitialFavoriteToolsSynced: Boolean = false, ) { companion object { const val JSON_FAVORITE_TOOLS = "favorite-tools" } - @set:VisibleForTesting - @JsonApiAttribute(JSON_INITIAL_FAVORITE_TOOLS_SYNCED) - var isInitialFavoriteToolsSynced = false - @set:VisibleForTesting @JsonApiAttribute(JSON_FAVORITE_TOOLS) var apiFavoriteTools = emptyList() @@ -62,4 +60,5 @@ fun randomUser() = User( givenName = UUID.randomUUID().toString(), familyName = UUID.randomUUID().toString(), email = UUID.randomUUID().toString(), + isInitialFavoriteToolsSynced = Random.nextBoolean(), ) diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt index edfc9e53cc..7f1c0bd082 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/repository/SyncRepositoryTest.kt @@ -127,15 +127,15 @@ class SyncRepositoryTest { // endregion storeTranslations() // region storeUser() - private val user = User().apply { - apiFavoriteTools = listOf( - Tool("a"), - Tool("b"), - ) - } - @Test fun `storeUser()`() = runTest { + val user = User().apply { + apiFavoriteTools = listOf( + Tool("a"), + Tool("b"), + ) + } + syncRepository.storeUser(user, Includes()) coVerifyAll { userRepository.storeUserFromSync(user) @@ -145,7 +145,12 @@ class SyncRepositoryTest { @Test fun `storeUser() - Store favorite tools`() = runTest { - user.isInitialFavoriteToolsSynced = true + val user = User(isInitialFavoriteToolsSynced = true).apply { + apiFavoriteTools = listOf( + Tool("a"), + Tool("b"), + ) + } syncRepository.storeUser(user, Includes(User.JSON_FAVORITE_TOOLS)) coVerifyAll { @@ -157,7 +162,12 @@ class SyncRepositoryTest { @Test fun `storeUser() - Don't store favorite tools if they haven't been synced yet`() = runTest { - user.isInitialFavoriteToolsSynced = false + val user = User(isInitialFavoriteToolsSynced = false).apply { + apiFavoriteTools = listOf( + Tool("a"), + Tool("b"), + ) + } syncRepository.storeUser(user, Includes(User.JSON_FAVORITE_TOOLS)) coVerifyAll { From fbc6546af94fe2ca2eacf07487eddde146367cab Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Wed, 27 Sep 2023 16:42:08 -0600 Subject: [PATCH 07/13] add an API interface to add/remove favorite tools --- gradle/libs.versions.toml | 2 + library/api/build.gradle.kts | 3 ++ .../kotlin/org/cru/godtools/api/ApiModule.kt | 6 +++ .../cru/godtools/api/UserFavoriteToolsApi.kt | 33 +++++++++++++ .../godtools/api/UserFavoriteToolsApiTest.kt | 46 +++++++++++++++++++ 5 files changed, 90 insertions(+) create mode 100644 library/api/src/main/kotlin/org/cru/godtools/api/UserFavoriteToolsApi.kt create mode 100644 library/api/src/test/kotlin/org/cru/godtools/api/UserFavoriteToolsApiTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6122ac18cb..a08152824f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -158,6 +158,7 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dag hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger" } javapoet = "com.squareup:javapoet:1.13.0" json = "org.json:json:20230618" +jsonUnit-assertj = "net.javacrumbs.json-unit:json-unit-assertj:3.2.2" jsoup = "org.jsoup:jsoup:1.16.1" junit = "junit:junit:4.13.2" kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } @@ -175,6 +176,7 @@ lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = " materialComponents = "com.google.android.material:material:1.9.0" mockk = "io.mockk:mockk:1.13.8" okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp3" } +okhttp3-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp3" } onesky-gradlePlugin = "co.brainly:plugin:1.6.0" picasso = "com.squareup.picasso:picasso:2.8" picasso-transformations = "jp.wasabeef:picasso-transformations:2.4.0" diff --git a/library/api/build.gradle.kts b/library/api/build.gradle.kts index 3a04e12da5..3867cc1fc1 100644 --- a/library/api/build.gradle.kts +++ b/library/api/build.gradle.kts @@ -43,4 +43,7 @@ dependencies { kapt(libs.hilt.compiler) testImplementation(libs.json) + testImplementation(libs.jsonUnit.assertj) + testImplementation(libs.kotlin.coroutines.test) + testImplementation(libs.okhttp3.mockwebserver) } diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt b/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt index 188e59a635..7ec8a0f9b1 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt @@ -148,6 +148,12 @@ object ApiModule { @Named(MOBILE_CONTENT_API_AUTHENTICATED) retrofit: Retrofit, ): UserCountersApi = retrofit.create() + @Provides + @Reusable + fun userFavoriteToolsApi( + @Named(MOBILE_CONTENT_API_AUTHENTICATED) retrofit: Retrofit, + ): UserFavoriteToolsApi = retrofit.create() + @Provides @Reusable fun viewsApi(@Named(MOBILE_CONTENT_API) retrofit: Retrofit): ViewsApi = retrofit.create() diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/UserFavoriteToolsApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/UserFavoriteToolsApi.kt new file mode 100644 index 0000000000..ecb11b89ba --- /dev/null +++ b/library/api/src/main/kotlin/org/cru/godtools/api/UserFavoriteToolsApi.kt @@ -0,0 +1,33 @@ +package org.cru.godtools.api + +import org.ccci.gto.android.common.jsonapi.model.JsonApiObject +import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams +import org.ccci.gto.android.common.jsonapi.retrofit2.annotation.JsonApiFields +import org.cru.godtools.model.Tool +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.HTTP +import retrofit2.http.POST +import retrofit2.http.QueryMap + +interface UserFavoriteToolsApi { + companion object { + internal const val PATH_FAVORITE_TOOLS = "$PATH_USER/relationships/favorite-tools" + } + + @POST(PATH_FAVORITE_TOOLS) + suspend fun addFavoriteTools( + @QueryMap params: JsonApiParams = JsonApiParams(), + @Body + @JsonApiFields(Tool.JSONAPI_TYPE) + tools: List, + ): Response> + + @HTTP(method = "DELETE", path = PATH_FAVORITE_TOOLS, hasBody = true) + suspend fun removeFavoriteTools( + @QueryMap params: JsonApiParams = JsonApiParams(), + @Body + @JsonApiFields(Tool.JSONAPI_TYPE) + tools: List, + ): Response> +} diff --git a/library/api/src/test/kotlin/org/cru/godtools/api/UserFavoriteToolsApiTest.kt b/library/api/src/test/kotlin/org/cru/godtools/api/UserFavoriteToolsApiTest.kt new file mode 100644 index 0000000000..aba767550a --- /dev/null +++ b/library/api/src/test/kotlin/org/cru/godtools/api/UserFavoriteToolsApiTest.kt @@ -0,0 +1,46 @@ +package org.cru.godtools.api + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlinx.coroutines.test.runTest +import net.javacrumbs.jsonunit.assertj.assertThatJson +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiConverterFactory +import org.cru.godtools.api.UserFavoriteToolsApi.Companion.PATH_FAVORITE_TOOLS +import org.cru.godtools.model.Tool +import org.junit.Rule +import retrofit2.Retrofit + +private const val JSON_RESPONSE_FAVORITES = "{data:[{id:5,type:\"resource\"}]}" + +class UserFavoriteToolsApiTest { + @get:Rule + val server = MockWebServer() + + private var api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(JsonApiConverterFactory(Tool::class.java)) + .build() + .create(UserFavoriteToolsApi::class.java) + + @Test + fun `removeFavoriteTools()`() = runTest { + server.enqueue(MockResponse().setBody(JSON_RESPONSE_FAVORITES)) + + val resp = api.removeFavoriteTools(tools = listOf(Tool("en") { id = 1 })).body()!!.data.single() + assertNotNull(resp) { assertEquals(5, it.id) } + + val request = server.takeRequest() + assertEquals("DELETE", request.method) + assertEquals("/$PATH_FAVORITE_TOOLS", request.path) + assertThatJson(request.body.readUtf8()) { + isObject + node("data").isArray.hasSize(1) + node("data[0].id").isEqualTo(1) + node("data[0].attributes").isAbsent() + node("data[0].relationships").isAbsent() + } + } +} From 0ed98736fa916006cfce953da1d445c07566e474 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 29 Sep 2023 11:05:56 -0600 Subject: [PATCH 08/13] create a dirty favorite tools sync task --- .../kotlin/org/cru/godtools/api/UserApi.kt | 6 + .../kotlin/org/cru/godtools/model/User.kt | 8 +- .../sync/repository/SyncRepository.kt | 4 +- .../sync/task/UserFavoriteToolsSyncTasks.kt | 85 +++++++ .../task/UserFavoriteToolsSyncTasksTest.kt | 218 ++++++++++++++++++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt create mode 100644 library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt index 8857e9f65e..7c8209cc5c 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt @@ -2,9 +2,12 @@ package org.cru.godtools.api import org.ccci.gto.android.common.jsonapi.model.JsonApiObject import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams +import org.ccci.gto.android.common.jsonapi.retrofit2.model.JsonApiRetrofitObject import org.cru.godtools.model.User import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.QueryMap internal const val PATH_USER = "users/me" @@ -12,4 +15,7 @@ internal const val PATH_USER = "users/me" interface UserApi { @GET(PATH_USER) suspend fun getUser(@QueryMap params: JsonApiParams = JsonApiParams()): Response> + + @PATCH(PATH_USER) + suspend fun updateUser(@Body user: JsonApiRetrofitObject): Response> } diff --git a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt index 0fa2d6716f..f0de30e5e2 100644 --- a/library/model/src/main/kotlin/org/cru/godtools/model/User.kt +++ b/library/model/src/main/kotlin/org/cru/godtools/model/User.kt @@ -10,17 +10,14 @@ import org.ccci.gto.android.common.jsonapi.annotation.JsonApiId import org.ccci.gto.android.common.jsonapi.annotation.JsonApiIgnore import org.ccci.gto.android.common.jsonapi.annotation.JsonApiType -private const val JSON_API_TYPE = "user" - private const val JSON_SSO_GUID = "sso-guid" private const val JSON_NAME = "name" private const val JSON_GIVEN_NAME = "given-name" private const val JSON_FAMILY_NAME = "family-name" private const val JSON_EMAIL = "email" private const val JSON_CREATED_AT = "created-at" -private const val JSON_INITIAL_FAVORITE_TOOLS_SYNCED = "attr-initial-favorite-tools-synced" -@JsonApiType(JSON_API_TYPE) +@JsonApiType(User.JSONAPI_TYPE) data class User @JvmOverloads constructor( @JsonApiId val id: String = "", @@ -42,6 +39,9 @@ data class User @JvmOverloads constructor( val isInitialFavoriteToolsSynced: Boolean = false, ) { companion object { + const val JSONAPI_TYPE = "user" + + const val JSON_INITIAL_FAVORITE_TOOLS_SYNCED = "attr-initial-favorite-tools-synced" const val JSON_FAVORITE_TOOLS = "favorite-tools" } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt index 863bc06b8e..ee05c501a4 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/repository/SyncRepository.kt @@ -92,7 +92,7 @@ internal class SyncRepository @Inject constructor( return setOfNotNull(tool.code) + processIncludes(tool, includes) } - private suspend fun storeFavoriteTools(tools: List, includes: Includes) { + suspend fun storeFavoriteTools(tools: List, includes: Includes = Includes()) { storeTools(tools, includes = includes) toolsRepository.storeFavoriteToolsFromSync(tools) } @@ -129,7 +129,7 @@ internal class SyncRepository @Inject constructor( // endregion Translations // region User - suspend fun storeUser(user: User, includes: Includes) { + suspend fun storeUser(user: User, includes: Includes = Includes()) { userRepository.storeUserFromSync(user) if (user.isInitialFavoriteToolsSynced && includes.include(User.JSON_FAVORITE_TOOLS)) { diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt new file mode 100644 index 0000000000..1fbe50c0c0 --- /dev/null +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt @@ -0,0 +1,85 @@ +package org.cru.godtools.sync.task + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.ccci.gto.android.common.jsonapi.JsonApiConverter +import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams +import org.ccci.gto.android.common.jsonapi.retrofit2.model.JsonApiRetrofitObject +import org.cru.godtools.account.GodToolsAccountManager +import org.cru.godtools.api.UserApi +import org.cru.godtools.api.UserFavoriteToolsApi +import org.cru.godtools.db.repository.ToolsRepository +import org.cru.godtools.db.repository.UserRepository +import org.cru.godtools.model.Tool +import org.cru.godtools.model.User +import org.cru.godtools.sync.repository.SyncRepository + +@Singleton +internal class UserFavoriteToolsSyncTasks @Inject constructor( + private val accountManager: GodToolsAccountManager, + private val favoritesApi: UserFavoriteToolsApi, + private val syncRepository: SyncRepository, + private val toolsRepository: ToolsRepository, + private val userApi: UserApi, + private val userRepository: UserRepository, +) : BaseSyncTasks() { + private val favoritesUpdateMutex = Mutex() + + suspend fun syncDirtyFavoriteTools(): Boolean = favoritesUpdateMutex.withLock { + coroutineScope { + if (!accountManager.isAuthenticated()) return@coroutineScope true + val userId = accountManager.userId().orEmpty() + + val user = userRepository.findUser(userId)?.takeIf { it.isInitialFavoriteToolsSynced } + ?: userApi.getUser().takeIf { it.isSuccessful } + ?.body()?.dataSingle + ?.also { syncRepository.storeUser(it) } + ?: return@coroutineScope false + + val favoritesToAdd = toolsRepository.getResources() + .filter { + (it.isFieldChanged(Tool.ATTR_IS_FAVORITE) || !user.isInitialFavoriteToolsSynced) && it.isFavorite + } + + val params = JsonApiParams().fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS) + if (favoritesToAdd.isNotEmpty()) { + favoritesApi.addFavoriteTools(params, favoritesToAdd).takeIf { it.isSuccessful } + ?.body()?.data + ?.also { syncRepository.storeFavoriteTools(it) } + ?: return@coroutineScope false + + if (!user.isInitialFavoriteToolsSynced) { + launch { + val update = JsonApiRetrofitObject.single(User(userId, isInitialFavoriteToolsSynced = true)) + .apply { + options = JsonApiConverter.Options.Builder() + .fields(User.JSONAPI_TYPE, User.JSON_INITIAL_FAVORITE_TOOLS_SYNCED) + .build() + } + + userApi.updateUser(update).takeIf { it.isSuccessful } + ?.body()?.dataSingle + ?.also { syncRepository.storeUser(it) } + ?: return@launch + } + } + } + + val favoritesToRemove = toolsRepository.getResources() + .filter { it.isFieldChanged(Tool.ATTR_IS_FAVORITE) && !it.isFavorite } + + if (favoritesToRemove.isNotEmpty()) { + favoritesApi.removeFavoriteTools(params, favoritesToRemove).takeIf { it.isSuccessful } + ?.body()?.data + ?.also { syncRepository.storeFavoriteTools(it) } + ?: return@coroutineScope false + } + + true + } + } +} diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt new file mode 100644 index 0000000000..81b58f7c45 --- /dev/null +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt @@ -0,0 +1,218 @@ +package org.cru.godtools.sync.task + +import io.mockk.Called +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coExcludeRecords +import io.mockk.coVerifySequence +import io.mockk.just +import io.mockk.mockk +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.ccci.gto.android.common.jsonapi.model.JsonApiObject +import org.cru.godtools.account.GodToolsAccountManager +import org.cru.godtools.api.UserApi +import org.cru.godtools.api.UserFavoriteToolsApi +import org.cru.godtools.db.repository.ToolsRepository +import org.cru.godtools.db.repository.UserRepository +import org.cru.godtools.model.Tool +import org.cru.godtools.model.User +import org.cru.godtools.model.trackChanges +import org.cru.godtools.sync.repository.SyncRepository +import retrofit2.Response + +class UserFavoriteToolsSyncTasksTest { + private val userId = UUID.randomUUID().toString() + + private val accountManager: GodToolsAccountManager = mockk { + coEvery { isAuthenticated() } returns true + coEvery { userId() } returns userId + } + private val favoritesApi: UserFavoriteToolsApi = mockk { + coEvery { addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of()) + coEvery { removeFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of()) + } + private val syncRepository: SyncRepository = mockk { + coEvery { storeUser(any(), any()) } just Runs + coEvery { storeFavoriteTools(any(), any()) } just Runs + } + private val toolsRepository: ToolsRepository = mockk { + coEvery { getResources() } returns emptyList() + } + private val userApi: UserApi = mockk { + coEvery { getUser(any()) } + .returns(Response.success(JsonApiObject.single(User(userId, isInitialFavoriteToolsSynced = true)))) + coEvery { updateUser(any()) } returns Response.success(JsonApiObject.single(User(userId))) + } + private val userRepository: UserRepository = mockk { + coEvery { findUser(userId) } returns User(userId, isInitialFavoriteToolsSynced = true) + } + + private val tasks = UserFavoriteToolsSyncTasks( + accountManager = accountManager, + favoritesApi = favoritesApi, + syncRepository = syncRepository, + toolsRepository = toolsRepository, + userApi = userApi, + userRepository = userRepository, + ) + + // region syncDirtyFavoriteTools() + @Test + fun `syncDirtyFavoriteTools() - add new favorites`() = runTest { + val tools = listOf( + Tool("1") { + id = 1 + isFavorite = true + }, + Tool("2") { + id = 2 + trackChanges { isFavorite = true } + }, + Tool("3") { + id = 3 + isFavorite = false + }, + ) + val responseTool = Tool("resp") + + coEvery { toolsRepository.getResources() } returns tools + coEvery { favoritesApi.addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of(responseTool)) + + assertTrue(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + favoritesApi.addFavoriteTools(any(), listOf(tools[1])) + syncRepository.storeFavoriteTools(listOf(responseTool), any()) + + userApi wasNot Called + } + } + + @Test + fun `syncDirtyFavoriteTools() - initial favorites`() = runTest { + val user = User(userId, isInitialFavoriteToolsSynced = false) + val tools = listOf( + Tool("1") { + id = 1 + isFavorite = true + }, + Tool("2") { + id = 2 + trackChanges { isFavorite = true } + }, + Tool("3") { + id = 3 + isFavorite = false + }, + ) + val responseTool = Tool("resp") + + coEvery { userRepository.findUser(userId) } returns null + coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.single(user)) + coEvery { toolsRepository.getResources() } returns tools + coEvery { favoritesApi.addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of(responseTool)) + coExcludeRecords { + userApi.getUser(any()) + syncRepository.storeUser(any(), any()) + } + + assertTrue(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + favoritesApi.addFavoriteTools(any(), listOf(tools[0], tools[1])) + syncRepository.storeFavoriteTools(listOf(responseTool), any()) + userApi.updateUser(match { it.dataSingle == User(userId, isInitialFavoriteToolsSynced = true) }) + } + } + + @Test + fun `syncDirtyFavoriteTools() - remove old favorites`() = runTest { + val tools = listOf( + Tool("1") { + id = 1 + isFavorite = true + }, + Tool("2") { + id = 2 + isFavorite = false + }, + Tool("3") { + id = 3 + isFavorite = true + trackChanges { isFavorite = false } + }, + ) + val responseTool = Tool("resp") + + coEvery { toolsRepository.getResources() } returns tools + coEvery { favoritesApi.removeFavoriteTools(any(), any()) } + .returns(Response.success(JsonApiObject.of(responseTool))) + + assertTrue(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + favoritesApi.removeFavoriteTools(any(), listOf(tools[2])) + syncRepository.storeFavoriteTools(listOf(responseTool), any()) + + userApi wasNot Called + } + } + + @Test + fun `syncDirtyFavoriteTools() - not authenticated`() = runTest { + coEvery { accountManager.isAuthenticated() } returns false + + assertTrue(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + accountManager.isAuthenticated() + + userRepository wasNot Called + userApi wasNot Called + syncRepository wasNot Called + toolsRepository wasNot Called + favoritesApi wasNot Called + } + } + + @Test + fun `syncDirtyFavoriteTools() - user not found`() = runTest { + coEvery { userRepository.findUser(userId) } returns null + coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of()) + + assertFalse(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + userRepository.findUser(userId) + userApi.getUser(any()) + + syncRepository wasNot Called + toolsRepository wasNot Called + favoritesApi wasNot Called + } + } + + @Test + fun `syncDirtyFavoriteTools() - user resolved from API - user not found`() = runTest { + coEvery { userRepository.findUser(userId) } returns null + + assertTrue(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + userRepository.findUser(userId) + userApi.getUser(any()) + syncRepository.storeUser(any(), any()) + } + } + + @Test + fun `syncDirtyFavoriteTools() - user resolved from API - user hasn't synced initial favorites`() = runTest { + coEvery { userRepository.findUser(userId) } returns User(userId, isInitialFavoriteToolsSynced = false) + + assertTrue(tasks.syncDirtyFavoriteTools()) + coVerifySequence { + userRepository.findUser(userId) + userApi.getUser(any()) + syncRepository.storeUser(any(), any()) + } + } + // endregion syncDirtyFavoriteTools() +} From d8104271d66cb038562a51bb6d5b92f3d446eb07 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 29 Sep 2023 17:51:11 -0600 Subject: [PATCH 09/13] move favorite tools sync into it's own sync task --- .../sync/task/UserFavoriteToolsSyncTasks.kt | 43 +++++++++++++++ .../cru/godtools/sync/task/UserSyncTasks.kt | 9 +-- .../task/UserFavoriteToolsSyncTasksTest.kt | 55 +++++++++++++++++++ .../godtools/sync/task/UserSyncTasksTest.kt | 8 +-- 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt index 1fbe50c0c0..3533f1bc76 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasks.kt @@ -1,17 +1,21 @@ package org.cru.godtools.sync.task +import androidx.annotation.VisibleForTesting import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.ccci.gto.android.common.base.TimeConstants import org.ccci.gto.android.common.jsonapi.JsonApiConverter import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams import org.ccci.gto.android.common.jsonapi.retrofit2.model.JsonApiRetrofitObject +import org.ccci.gto.android.common.jsonapi.util.Includes import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi import org.cru.godtools.api.UserFavoriteToolsApi +import org.cru.godtools.db.repository.LastSyncTimeRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.repository.UserRepository import org.cru.godtools.model.Tool @@ -22,13 +26,52 @@ import org.cru.godtools.sync.repository.SyncRepository internal class UserFavoriteToolsSyncTasks @Inject constructor( private val accountManager: GodToolsAccountManager, private val favoritesApi: UserFavoriteToolsApi, + private val lastSyncTimeRepository: LastSyncTimeRepository, private val syncRepository: SyncRepository, private val toolsRepository: ToolsRepository, private val userApi: UserApi, private val userRepository: UserRepository, ) : BaseSyncTasks() { + companion object { + @VisibleForTesting + internal const val SYNC_TIME_FAVORITE_TOOLS = "last_synced.favorite_tools" + private const val STALE_DURATION_FAVORITE_TOOLS = TimeConstants.DAY_IN_MS + + private val INCLUDES_GET_FAVORITE_TOOLS = Includes(User.JSON_FAVORITE_TOOLS) + } + + private val favoriteToolsMutex = Mutex() private val favoritesUpdateMutex = Mutex() + suspend fun syncFavoriteTools(force: Boolean) = favoriteToolsMutex.withLock { + if (!accountManager.isAuthenticated()) return true + val userId = accountManager.userId().orEmpty() + + // short-circuit if we aren't forcing a sync and the data isn't stale + if (!force && + !lastSyncTimeRepository.isLastSyncStale( + SYNC_TIME_FAVORITE_TOOLS, + userId, + staleAfter = STALE_DURATION_FAVORITE_TOOLS + ) + ) { + return true + } + + val params = JsonApiParams() + .includes(INCLUDES_GET_FAVORITE_TOOLS) + .fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS) + val user = userApi.getUser(params).takeIf { it.isSuccessful } + ?.body()?.takeUnless { it.hasErrors } + ?.dataSingle ?: return false + + syncRepository.storeUser(user, INCLUDES_GET_FAVORITE_TOOLS) + lastSyncTimeRepository.resetLastSyncTime(SYNC_TIME_FAVORITE_TOOLS, isPrefix = true) + lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_FAVORITE_TOOLS, user.id) + + true + } + suspend fun syncDirtyFavoriteTools(): Boolean = favoritesUpdateMutex.withLock { coroutineScope { if (!accountManager.isAuthenticated()) return@coroutineScope true diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt index 8e8e45b318..1c543772c9 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/UserSyncTasks.kt @@ -6,12 +6,10 @@ import javax.inject.Singleton import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.ccci.gto.android.common.base.TimeConstants.WEEK_IN_MS -import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams import org.ccci.gto.android.common.jsonapi.util.Includes import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi import org.cru.godtools.db.repository.LastSyncTimeRepository -import org.cru.godtools.model.Tool import org.cru.godtools.model.User import org.cru.godtools.sync.repository.SyncRepository @@ -44,14 +42,11 @@ internal class UserSyncTasks @Inject constructor( return true } - val params = JsonApiParams() - .includes(INCLUDES_GET_USER) - .fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS) - val user = userApi.getUser(params).takeIf { it.isSuccessful } + val user = userApi.getUser().takeIf { it.isSuccessful } ?.body()?.takeUnless { it.hasErrors() } ?.dataSingle ?: return false - syncRepository.storeUser(user, INCLUDES_GET_USER) + syncRepository.storeUser(user) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, user.id) true diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt index 81b58f7c45..3129f98d83 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserFavoriteToolsSyncTasksTest.kt @@ -4,10 +4,12 @@ import io.mockk.Called import io.mockk.Runs import io.mockk.coEvery import io.mockk.coExcludeRecords +import io.mockk.coVerifyAll import io.mockk.coVerifySequence import io.mockk.just import io.mockk.mockk import java.util.UUID +import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -16,12 +18,14 @@ import org.ccci.gto.android.common.jsonapi.model.JsonApiObject import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.api.UserApi import org.cru.godtools.api.UserFavoriteToolsApi +import org.cru.godtools.db.repository.LastSyncTimeRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.repository.UserRepository import org.cru.godtools.model.Tool import org.cru.godtools.model.User import org.cru.godtools.model.trackChanges import org.cru.godtools.sync.repository.SyncRepository +import org.cru.godtools.sync.task.UserFavoriteToolsSyncTasks.Companion.SYNC_TIME_FAVORITE_TOOLS import retrofit2.Response class UserFavoriteToolsSyncTasksTest { @@ -35,6 +39,9 @@ class UserFavoriteToolsSyncTasksTest { coEvery { addFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of()) coEvery { removeFavoriteTools(any(), any()) } returns Response.success(JsonApiObject.of()) } + private val lastSyncTimeRepository: LastSyncTimeRepository = mockk(relaxUnitFun = true) { + coEvery { isLastSyncStale(key = anyVararg(), staleAfter = any()) } returns true + } private val syncRepository: SyncRepository = mockk { coEvery { storeUser(any(), any()) } just Runs coEvery { storeFavoriteTools(any(), any()) } just Runs @@ -54,12 +61,60 @@ class UserFavoriteToolsSyncTasksTest { private val tasks = UserFavoriteToolsSyncTasks( accountManager = accountManager, favoritesApi = favoritesApi, + lastSyncTimeRepository = lastSyncTimeRepository, syncRepository = syncRepository, toolsRepository = toolsRepository, userApi = userApi, userRepository = userRepository, ) + // region syncFavoriteTools() + @Test + fun `syncFavoriteTools() - not authenticated`() = runTest { + coEvery { accountManager.isAuthenticated() } returns false + + assertTrue(tasks.syncFavoriteTools(Random.nextBoolean())) + coVerifyAll { + accountManager.isAuthenticated() + lastSyncTimeRepository wasNot Called + userApi wasNot Called + syncRepository wasNot Called + } + } + + @Test + fun `syncFavoriteTools(force = false) - already synced`() = runTest { + coEvery { + lastSyncTimeRepository.isLastSyncStale(key = anyVararg(), staleAfter = any()) + } returns false + + assertTrue(tasks.syncFavoriteTools(force = false)) + coVerifyAll { + accountManager.isAuthenticated() + accountManager.userId() + lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_FAVORITE_TOOLS, userId, staleAfter = any()) + userApi wasNot Called + } + } + + @Test + fun `syncFavoriteTools(force = true)`() = runTest { + val user = User(id = userId) + coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of(user)) + coEvery { syncRepository.storeUser(user, any()) } just Runs + + assertTrue(tasks.syncFavoriteTools(force = true)) + coVerifySequence { + accountManager.isAuthenticated() + accountManager.userId() + userApi.getUser(any()) + syncRepository.storeUser(user, any()) + lastSyncTimeRepository.resetLastSyncTime(SYNC_TIME_FAVORITE_TOOLS, isPrefix = true) + lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_FAVORITE_TOOLS, userId) + } + } + // endregion syncFavoriteTools() + // region syncDirtyFavoriteTools() @Test fun `syncDirtyFavoriteTools() - add new favorites`() = runTest { diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt index cfbff82cd8..cd90bb3fdb 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/UserSyncTasksTest.kt @@ -74,14 +74,14 @@ class UserSyncTasksTest { val startTime = System.currentTimeMillis() val user = User(id = USER_ID) coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of(user)) - coEvery { syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) } just Runs + coEvery { syncRepository.storeUser(user, any()) } just Runs assertTrue(tasks.syncUser(force = true)) coVerifyAll { accountManager.isAuthenticated() accountManager.userId() userApi.getUser(any()) - syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) + syncRepository.storeUser(user, any()) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, USER_ID) } assertTrue(lastSyncTimeRepository.getLastSyncTime(SYNC_TIME_USER, USER_ID) >= startTime) @@ -93,14 +93,14 @@ class UserSyncTasksTest { val user = User(id = USER_ID) coEvery { lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_USER, USER_ID, staleAfter = any()) } returns true coEvery { userApi.getUser(any()) } returns Response.success(JsonApiObject.of(user)) - coEvery { syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) } just Runs + coEvery { syncRepository.storeUser(user, any()) } just Runs assertTrue(tasks.syncUser(force = true)) coVerifyAll { accountManager.isAuthenticated() accountManager.userId() userApi.getUser(any()) - syncRepository.storeUser(user, UserSyncTasks.INCLUDES_GET_USER) + syncRepository.storeUser(user, any()) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_USER, USER_ID) } assertTrue(lastSyncTimeRepository.getLastSyncTime(SYNC_TIME_USER, USER_ID) >= startTime) From 0ea9d75bfd11da4fd2856204c8870537c61185a4 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 2 Oct 2023 08:44:17 -0600 Subject: [PATCH 10/13] expose favorite tools sync via the SyncManager --- .../org/cru/godtools/sync/GodToolsSyncService.kt | 13 +++++++++++++ .../org/cru/godtools/sync/task/SyncTaskModule.kt | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt index 42b7efda49..a56000dc2a 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt @@ -23,6 +23,7 @@ import org.cru.godtools.sync.task.FollowupSyncTasks import org.cru.godtools.sync.task.LanguagesSyncTasks import org.cru.godtools.sync.task.ToolSyncTasks import org.cru.godtools.sync.task.UserCounterSyncTasks +import org.cru.godtools.sync.task.UserFavoriteToolsSyncTasks import org.cru.godtools.sync.task.UserSyncTasks import org.cru.godtools.sync.work.scheduleSyncFollowupsWork import org.cru.godtools.sync.work.scheduleSyncLanguagesWork @@ -104,6 +105,18 @@ class GodToolsSyncService @VisibleForTesting internal constructor( } } + suspend fun syncFavoriteTools(force: Boolean) = try { + executeSync { syncFavoriteTools(force) } + } finally { + coroutineScope.launch { syncDirtyFavoriteTools() } + } + suspend fun syncDirtyFavoriteTools() = try { + executeSync { syncDirtyFavoriteTools() } + } catch (e: CancellationException) { + // TODO: work manager job + throw e + } + fun syncToolSharesAsync() = coroutineScope.async { syncToolShares() } private suspend fun syncToolShares() = try { executeSync { syncShares() }.also { if (!it) workManager.scheduleSyncToolSharesWork() } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/SyncTaskModule.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/SyncTaskModule.kt index ddffa9074a..185687bd1d 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/SyncTaskModule.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/SyncTaskModule.kt @@ -45,4 +45,9 @@ internal abstract class SyncTaskModule { @IntoMap @SyncTaskKey(UserCounterSyncTasks::class) internal abstract fun userCounterSyncTasks(tasks: UserCounterSyncTasks): BaseSyncTasks + + @Binds + @IntoMap + @SyncTaskKey(UserFavoriteToolsSyncTasks::class) + internal abstract fun userFavoriteToolsSyncTasks(tasks: UserFavoriteToolsSyncTasks): BaseSyncTasks } From 5237acaacb4e71e37b0ae5fdde34c8e0bd54dbc7 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 2 Oct 2023 08:53:38 -0600 Subject: [PATCH 11/13] trigger the favorite tools sync from the dashboard --- .../org/cru/godtools/ui/dashboard/DashboardViewModel.kt | 6 +++++- .../kotlin/org/cru/godtools/ExternalSingletonsModule.kt | 1 + .../org/cru/godtools/ui/dashboard/DashboardViewModelTest.kt | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModel.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModel.kt index ababc19a7e..478e9cf8dc 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map @@ -53,7 +54,10 @@ class DashboardViewModel @Inject constructor( @Suppress("DeferredResultUnused") syncService.syncToolSharesAsync() syncsRunning.value++ - syncService.syncTools(force) + coroutineScope { + launch { syncService.syncFavoriteTools(force) } + launch { syncService.syncTools(force) } + } syncsRunning.value-- } } diff --git a/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt b/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt index 6315514824..12d61745ae 100644 --- a/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt +++ b/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt @@ -66,6 +66,7 @@ class ExternalSingletonsModule { val syncService: GodToolsSyncService by lazy { mockk { coEvery { syncTools(any()) } returns true + coEvery { syncFavoriteTools(any()) } returns true every { syncFollowupsAsync() } returns CompletableDeferred(true) every { syncToolSharesAsync() } returns CompletableDeferred(true) } diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModelTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModelTest.kt index 1a8d11180d..f20e3635da 100644 --- a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModelTest.kt +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/DashboardViewModelTest.kt @@ -33,6 +33,7 @@ class DashboardViewModelTest { every { syncFollowupsAsync() } returns CompletableDeferred() every { syncToolSharesAsync() } returns CompletableDeferred() coEvery { syncTools(any()) } returns true + coEvery { syncFavoriteTools(any()) } returns true } private val testScope = TestScope() @@ -59,6 +60,7 @@ class DashboardViewModelTest { syncService.syncFollowupsAsync() syncService.syncToolSharesAsync() syncService.syncTools(false) + syncService.syncFavoriteTools(false) } } @@ -90,7 +92,8 @@ class DashboardViewModelTest { coVerifyAll { syncService.syncFollowupsAsync() syncService.syncToolSharesAsync() - syncService.syncTools(any()) + syncService.syncTools(false) + syncService.syncFavoriteTools(false) } } } From f44f06a67b42646cf5825ae09a03c29880c27f5f Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 2 Oct 2023 17:14:44 -0600 Subject: [PATCH 12/13] trigger the dirty favorite tools sync when the user adds/removes a favorite --- .../org/cru/godtools/ui/tools/ToolViewModels.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt index 7c17108e32..f2049c1d3f 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/tools/ToolViewModels.kt @@ -30,6 +30,7 @@ import org.cru.godtools.downloadmanager.GodToolsDownloadManager import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.Translation +import org.cru.godtools.sync.GodToolsSyncService internal const val EXTRA_ADDITIONAL_LANGUAGE = "additionalLanguage" @@ -42,6 +43,7 @@ class ToolViewModels @Inject constructor( private val languagesRepository: LanguagesRepository, private val manifestManager: ManifestManager, private val settings: Settings, + private val syncService: GodToolsSyncService, private val toolsRepository: ToolsRepository, private val translationsRepository: TranslationsRepository, savedState: SavedStateHandle, @@ -123,10 +125,16 @@ class ToolViewModels @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) fun pinTool() { - viewModelScope.launch { toolsRepository.pinTool(code) } + viewModelScope.launch { + toolsRepository.pinTool(code) + syncService.syncDirtyFavoriteTools() + } settings.setFeatureDiscovered(Settings.FEATURE_TOOL_FAVORITE) } - fun unpinTool() = viewModelScope.launch { toolsRepository.unpinTool(code) } + fun unpinTool() = viewModelScope.launch { + toolsRepository.unpinTool(code) + syncService.syncDirtyFavoriteTools() + } } private fun Flow.attachmentFileFlow(transform: (value: Tool?) -> Long?) = this From 7da63c1c7e1b79c0fd8b936948c0f8425364ea17 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 2 Oct 2023 17:16:12 -0600 Subject: [PATCH 13/13] add a workmanager worker to be able to sync dirty tools in the background --- .../cru/godtools/sync/GodToolsSyncService.kt | 4 ++- .../sync/work/SyncDirtyFavoriteToolsWorker.kt | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 library/sync/src/main/kotlin/org/cru/godtools/sync/work/SyncDirtyFavoriteToolsWorker.kt diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt index a56000dc2a..f017054153 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt @@ -25,6 +25,7 @@ import org.cru.godtools.sync.task.ToolSyncTasks import org.cru.godtools.sync.task.UserCounterSyncTasks import org.cru.godtools.sync.task.UserFavoriteToolsSyncTasks import org.cru.godtools.sync.task.UserSyncTasks +import org.cru.godtools.sync.work.scheduleSyncDirtyFavoriteToolsWork import org.cru.godtools.sync.work.scheduleSyncFollowupsWork import org.cru.godtools.sync.work.scheduleSyncLanguagesWork import org.cru.godtools.sync.work.scheduleSyncToolSharesWork @@ -112,8 +113,9 @@ class GodToolsSyncService @VisibleForTesting internal constructor( } suspend fun syncDirtyFavoriteTools() = try { executeSync { syncDirtyFavoriteTools() } + .also { if (!it) workManager.scheduleSyncDirtyFavoriteToolsWork() } } catch (e: CancellationException) { - // TODO: work manager job + workManager.scheduleSyncDirtyFavoriteToolsWork() throw e } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/work/SyncDirtyFavoriteToolsWorker.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/work/SyncDirtyFavoriteToolsWorker.kt new file mode 100644 index 0000000000..c9f99befe4 --- /dev/null +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/work/SyncDirtyFavoriteToolsWorker.kt @@ -0,0 +1,29 @@ +package org.cru.godtools.sync.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.cru.godtools.sync.task.UserFavoriteToolsSyncTasks + +private const val WORK_NAME = "SyncDirtyFavoriteTools" + +internal fun WorkManager.scheduleSyncDirtyFavoriteToolsWork() = enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.KEEP, + SyncWorkRequestBuilder().build() +) + +@HiltWorker +internal class SyncDirtyFavoriteToolsWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val favoriteToolsSyncTasks: UserFavoriteToolsSyncTasks +) : CoroutineWorker(context, workerParams) { + override suspend fun doWork() = + if (favoriteToolsSyncTasks.syncDirtyFavoriteTools()) Result.success() else Result.retry() +}