Skip to content

Commit 87a8fcc

Browse files
authored
Merge pull request #4387 from CruGlobal/syncPersonalizedTools
GT-3011: Sync personalized tool order from the API
2 parents a6f8623 + 4230c5c commit 87a8fcc

5 files changed

Lines changed: 164 additions & 5 deletions

File tree

library/api/src/main/kotlin/org/cru/godtools/api/ToolsApi.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.cru.godtools.api
22

3+
import java.util.Locale
34
import org.ccci.gto.android.common.jsonapi.model.JsonApiObject
45
import org.ccci.gto.android.common.jsonapi.retrofit2.JsonApiParams
56
import org.cru.godtools.model.Tool
@@ -11,6 +12,8 @@ import retrofit2.http.QueryMap
1112
private const val PATH_RESOURCES = "resources"
1213
private const val PARAM_FILTER_SYSTEM = "filter[system]"
1314
private const val PARAM_FILTER_CODE = "filter[abbreviation]"
15+
private const val PARAM_FILTER_LANGUAGE = "filter[lang]"
16+
private const val PARAM_FILTER_COUNTRY = "filter[country]"
1417

1518
interface ToolsApi {
1619
@GET("$PATH_RESOURCES?$PARAM_FILTER_SYSTEM=${BuildConfig.MOBILE_CONTENT_SYSTEM}")
@@ -19,6 +22,19 @@ interface ToolsApi {
1922
@GET(PATH_RESOURCES)
2023
suspend fun getTool(
2124
@Query(PARAM_FILTER_CODE) code: String,
22-
@QueryMap params: JsonApiParams
25+
@QueryMap params: JsonApiParams,
26+
): Response<JsonApiObject<Tool>>
27+
28+
@GET("$PATH_RESOURCES/featured")
29+
suspend fun getToolOrder(
30+
@Query(PARAM_FILTER_LANGUAGE) locale: Locale,
31+
@Query(PARAM_FILTER_COUNTRY) country: String,
32+
@QueryMap params: JsonApiParams,
33+
): Response<JsonApiObject<Tool>>
34+
35+
@GET("$PATH_RESOURCES/default_order")
36+
suspend fun getDefaultToolOrder(
37+
@Query(PARAM_FILTER_LANGUAGE) locale: Locale,
38+
@QueryMap params: JsonApiParams,
2339
): Response<JsonApiObject<Tool>>
2440
}

library/model/src/main/kotlin/org/cru/godtools/model/Tool.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ private const val JSON_TYPE_ARTICLE = "article"
2424
private const val JSON_TYPE_CYOA = "cyoa"
2525
private const val JSON_TYPE_LESSON = "lesson"
2626
private const val JSON_TYPE_META = "metatool"
27-
private const val JSON_ABBREVIATION = "abbreviation"
2827
private const val JSON_NAME = "name"
2928
private const val JSON_CATEGORY = "attr-category"
3029
private const val JSON_HIDDEN = "attr-hidden"
@@ -42,7 +41,7 @@ private const val JSON_SCREEN_SHARE_DISABLED = "attr-screen-share-disabled"
4241

4342
@JsonApiType(Tool.JSONAPI_TYPE)
4443
class Tool(
45-
@JsonApiAttribute(JSON_ABBREVIATION)
44+
@JsonApiAttribute(JSON_CODE)
4645
val code: String?,
4746
@JsonApiAttribute(JSON_TYPE)
4847
val type: Type = Type.UNKNOWN,
@@ -101,6 +100,7 @@ class Tool(
101100
companion object {
102101
const val JSONAPI_TYPE = "resource"
103102

103+
const val JSON_CODE = "abbreviation"
104104
const val JSON_ATTACHMENTS = "attachments"
105105
const val JSON_LATEST_TRANSLATIONS = "latest-translations"
106106
const val JSON_METATOOL = "metatool"
@@ -110,7 +110,7 @@ class Tool(
110110

111111
val JSONAPI_FIELDS = arrayOf(
112112
JSON_TYPE,
113-
JSON_ABBREVIATION,
113+
JSON_CODE,
114114
JSON_NAME,
115115
JSON_DESCRIPTION,
116116
JSON_CATEGORY,

library/sync/src/main/kotlin/org/cru/godtools/sync/GodToolsSyncService.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
44
import androidx.work.WorkManager
55
import dagger.Lazy
66
import java.io.IOException
7+
import java.util.Locale
78
import javax.inject.Inject
89
import javax.inject.Provider
910
import javax.inject.Singleton
@@ -85,6 +86,9 @@ class GodToolsSyncService @VisibleForTesting internal constructor(
8586
suspend fun syncTool(toolCode: String, force: Boolean = false) =
8687
executeSync<ToolSyncTasks> { syncTool(toolCode, force) }
8788

89+
suspend fun syncPersonalizedTools(locale: Locale, country: String?, force: Boolean = false) =
90+
executeSync<ToolSyncTasks> { syncPersonalizedTools(locale, country, force) }
91+
8892
suspend fun syncGlobalActivity(force: Boolean = false) =
8993
executeSync<AnalyticsSyncTasks> { syncGlobalActivity(force) }
9094

library/sync/src/main/kotlin/org/cru/godtools/sync/task/ToolSyncTasks.kt

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package org.cru.godtools.sync.task
33
import androidx.annotation.AnyThread
44
import java.io.IOException
55
import java.net.HttpURLConnection.HTTP_OK
6+
import java.util.Locale
67
import javax.inject.Inject
78
import javax.inject.Singleton
89
import kotlinx.coroutines.async
@@ -38,6 +39,7 @@ internal class ToolSyncTasks @Inject internal constructor(
3839
) : BaseSyncTasks() {
3940
internal companion object {
4041
const val SYNC_TIME_TOOLS = "last_synced.tools"
42+
const val SYNC_TIME_PERSONALIZED_TOOLS = "last_synced.personalized_tools."
4143

4244
private val INCLUDES_GET_TOOL = Includes(
4345
Tool.JSON_ATTACHMENTS,
@@ -49,6 +51,9 @@ internal class ToolSyncTasks @Inject internal constructor(
4951
.includes(INCLUDES_GET_TOOL)
5052
.fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS)
5153
.fields(Language.JSONAPI_TYPE, *Language.JSONAPI_FIELDS)
54+
55+
private fun buildPersonalizedToolsApiParams() = JsonApiParams()
56+
.fields(Tool.JSONAPI_TYPE, Tool.JSON_CODE)
5257
}
5358

5459
private val toolsMutex = Mutex()
@@ -80,7 +85,7 @@ internal class ToolSyncTasks @Inject internal constructor(
8085
if (!force &&
8186
!lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_TOOL, toolCode, staleAfter = STALE_DURATION_TOOLS)
8287
) {
83-
return true
88+
return@withLock true
8489
}
8590

8691
// fetch tools from the API, short-circuit if this response is invalid
@@ -97,6 +102,58 @@ internal class ToolSyncTasks @Inject internal constructor(
97102
true
98103
}
99104

105+
// region Personalized Tools
106+
private val personalizedToolsMutex = MutexMap()
107+
108+
internal suspend fun syncPersonalizedTools(locale: Locale, country: String?, force: Boolean = false) =
109+
coroutineScope {
110+
val order = async { country?.let { syncPersonalizedToolOrder(locale, it.uppercase(), force) } ?: true }
111+
val defaultOrder = async { syncDefaultPersonalizedToolOrder(locale, force) }
112+
order.await() && defaultOrder.await()
113+
}
114+
115+
private suspend fun syncPersonalizedToolOrder(locale: Locale, country: String, force: Boolean) =
116+
personalizedToolsMutex.withLock(locale to country) {
117+
if (!force &&
118+
!lastSyncTimeRepository.isLastSyncStale(
119+
SYNC_TIME_PERSONALIZED_TOOLS,
120+
locale,
121+
country,
122+
staleAfter = STALE_DURATION_TOOLS,
123+
)
124+
) {
125+
return@withLock true
126+
}
127+
128+
val tools = toolsApi.getToolOrder(locale, country, buildPersonalizedToolsApiParams())
129+
.takeIf { it.code() == HTTP_OK }?.body()?.data ?: return@withLock false
130+
131+
toolsRepository.storePersonalizedToolOrderFromSync(locale, country, tools)
132+
lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_PERSONALIZED_TOOLS, locale, country)
133+
true
134+
}
135+
136+
private suspend fun syncDefaultPersonalizedToolOrder(locale: Locale, force: Boolean) =
137+
personalizedToolsMutex.withLock(locale) {
138+
if (!force &&
139+
!lastSyncTimeRepository.isLastSyncStale(
140+
SYNC_TIME_PERSONALIZED_TOOLS,
141+
locale,
142+
staleAfter = STALE_DURATION_TOOLS
143+
)
144+
) {
145+
return@withLock true
146+
}
147+
148+
val tools = toolsApi.getDefaultToolOrder(locale, buildPersonalizedToolsApiParams())
149+
.takeIf { it.code() == HTTP_OK }?.body()?.data ?: return@withLock false
150+
151+
toolsRepository.storePersonalizedToolOrderFromSync(locale, null, tools)
152+
lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_PERSONALIZED_TOOLS, locale)
153+
true
154+
}
155+
// endregion Personalized Tools
156+
100157
/**
101158
* @return true if all pending share counts were successfully synced. false if any failed to sync.
102159
*/

library/sync/src/test/kotlin/org/cru/godtools/sync/task/ToolSyncTasksTest.kt

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package org.cru.godtools.sync.task
33
import io.mockk.Called
44
import io.mockk.Runs
55
import io.mockk.coEvery
6+
import io.mockk.coVerify
67
import io.mockk.coVerifyAll
78
import io.mockk.coVerifySequence
89
import io.mockk.just
910
import io.mockk.mockk
11+
import java.util.Locale
1012
import kotlin.test.Test
1113
import kotlin.test.assertFalse
1214
import kotlinx.coroutines.test.runTest
@@ -17,21 +19,31 @@ import org.cru.godtools.db.repository.InMemoryLastSyncTimeRepository
1719
import org.cru.godtools.db.repository.ToolsRepository
1820
import org.cru.godtools.model.randomTool
1921
import org.cru.godtools.sync.repository.SyncRepository
22+
import org.cru.godtools.sync.task.ToolSyncTasks.Companion.SYNC_TIME_PERSONALIZED_TOOLS
2023
import retrofit2.Response
2124

2225
class ToolSyncTasksTest {
26+
private val locale = Locale.ENGLISH
27+
private val country = "US"
28+
2329
private val tool = randomTool()
2430
private val existingTools = listOf(randomTool())
31+
private val personalizedTools = listOf(randomTool(), randomTool())
2532

2633
private val toolsApi: ToolsApi = mockk {
2734
coEvery { list(any()) } returns Response.success(JsonApiObject.single(tool))
35+
coEvery { getToolOrder(any(), any(), any()) } returns
36+
Response.success(JsonApiObject.of(*personalizedTools.toTypedArray()))
37+
coEvery { getDefaultToolOrder(any(), any()) } returns
38+
Response.success(JsonApiObject.of(*personalizedTools.toTypedArray()))
2839
}
2940
private val viewsApi: ViewsApi = mockk()
3041
private val syncRepository: SyncRepository = mockk {
3142
coEvery { storeTools(tools = any(), existingTools = any(), includes = any()) } just Runs
3243
}
3344
private val toolsRepository: ToolsRepository = mockk {
3445
coEvery { getAllTools() } returns existingTools
46+
coEvery { storePersonalizedToolOrderFromSync(any(), any(), any()) } just Runs
3547
}
3648
private val lastSyncTimeRepository = InMemoryLastSyncTimeRepository()
3749

@@ -89,4 +101,74 @@ class ToolSyncTasksTest {
89101
}
90102
}
91103
// endregion syncTools()
104+
105+
// region syncPersonalizedTools()
106+
@Test
107+
fun `syncPersonalizedTools(country = non-null)`() = runTest {
108+
tasks.syncPersonalizedTools(locale, country)
109+
coVerifyAll {
110+
toolsApi.getToolOrder(locale, country, any())
111+
toolsApi.getDefaultToolOrder(locale, any())
112+
toolsRepository.storePersonalizedToolOrderFromSync(locale, country, personalizedTools)
113+
toolsRepository.storePersonalizedToolOrderFromSync(locale, null, personalizedTools)
114+
}
115+
assertFalse(
116+
lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_PERSONALIZED_TOOLS, locale, country, staleAfter = 60_000)
117+
)
118+
assertFalse(
119+
lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_PERSONALIZED_TOOLS, locale, staleAfter = 60_000)
120+
)
121+
}
122+
123+
@Test
124+
fun `syncPersonalizedTools(country = null)`() = runTest {
125+
tasks.syncPersonalizedTools(locale, null)
126+
coVerifyAll {
127+
toolsApi.getDefaultToolOrder(locale, any())
128+
toolsRepository.storePersonalizedToolOrderFromSync(locale, null, personalizedTools)
129+
}
130+
coVerify(exactly = 0) { toolsApi.getToolOrder(any(), any(), any()) }
131+
assertFalse(
132+
lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_PERSONALIZED_TOOLS, locale, staleAfter = 60_000)
133+
)
134+
}
135+
136+
@Test
137+
fun `syncPersonalizedTools(force = false) - already synced`() = runTest {
138+
with(lastSyncTimeRepository) {
139+
setLastSyncTime(SYNC_TIME_PERSONALIZED_TOOLS, locale, country, time = System.currentTimeMillis())
140+
setLastSyncTime(SYNC_TIME_PERSONALIZED_TOOLS, locale, time = System.currentTimeMillis())
141+
}
142+
143+
tasks.syncPersonalizedTools(locale, country, force = false)
144+
coVerifyAll { toolsApi wasNot Called }
145+
}
146+
147+
@Test
148+
fun `syncPersonalizedTools(force = true) - already synced`() = runTest {
149+
with(lastSyncTimeRepository) {
150+
setLastSyncTime(SYNC_TIME_PERSONALIZED_TOOLS, locale, country, time = System.currentTimeMillis())
151+
setLastSyncTime(SYNC_TIME_PERSONALIZED_TOOLS, locale, time = System.currentTimeMillis())
152+
}
153+
154+
tasks.syncPersonalizedTools(locale, country, force = true)
155+
coVerifyAll {
156+
toolsApi.getToolOrder(locale, country, any())
157+
toolsApi.getDefaultToolOrder(locale, any())
158+
toolsRepository.storePersonalizedToolOrderFromSync(locale, country, personalizedTools)
159+
toolsRepository.storePersonalizedToolOrderFromSync(locale, null, personalizedTools)
160+
}
161+
assertFalse(
162+
lastSyncTimeRepository.isLastSyncStale(
163+
SYNC_TIME_PERSONALIZED_TOOLS,
164+
locale,
165+
country.uppercase(),
166+
staleAfter = 60_000,
167+
)
168+
)
169+
assertFalse(
170+
lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_PERSONALIZED_TOOLS, locale, staleAfter = 60_000)
171+
)
172+
}
173+
// endregion syncPersonalizedTools()
92174
}

0 commit comments

Comments
 (0)