diff --git a/.claude/skills/pr-review/dismissed-issues.md b/.claude/skills/pr-review/dismissed-issues.md index d5e8ef21a1..f588508cba 100644 --- a/.claude/skills/pr-review/dismissed-issues.md +++ b/.claude/skills/pr-review/dismissed-issues.md @@ -9,3 +9,11 @@ Issues listed here are suppressed in future PR reviews. **Reason**: Circuit only requires `UiState` to be `@Stable`, not strictly immutable. Embedding `MutableState` (which is stable) is valid and intentional — it allows the UI to mutate local state (e.g. search query) without routing every keystroke through `eventSink`. **Dismissed**: 2026-03-30 **Dismissed by**: Daniel Frett + +--- + +## Missing @AnyThread on sync task methods +**Pattern**: Flagging absence of `@AnyThread` annotation on `internal suspend fun` methods in `ToolSyncTasks` (or similar sync task classes). +**Reason**: `@AnyThread` is a vestigial remnant from older versions of the sync logic. It's unnecessary on suspend functions and not required going forward. +**Dismissed**: 2026-05-06 +**Dismissed by**: Daniel Frett diff --git a/CLAUDE.md b/CLAUDE.md index 1d605d329e..2d720ab6a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,10 @@ The default branch for this repository is **`develop`**. Use `develop` as the ba # Run tests for a specific module ./gradlew :library:model:test -./gradlew :app:testProductionDebugUnitTest +./gradlew :app:test # Run a single test class -./gradlew :library:model:testProductionDebugUnitTest --tests "org.cru.godtools.model.ToolTest" +./gradlew :library:model:test --tests "org.cru.godtools.model.ToolTest" # Verify Paparazzi snapshot tests (requires Git LFS) ./gradlew verifyPaparazzi @@ -65,7 +65,7 @@ The default branch for this repository is **`develop`**. Use `develop` as the ba - **Product flavors** (dimension `env`): `stage` (staging API), `production` (production API) - **Build types**: `debug`, `qa` (inherits from debug), `release` - **Stage flavor** is only enabled for debug and QA build types -- **Unit tests only run** on `productionDebug` variant +- **Unit test variants**: some modules have product flavors and expose `testProductionDebugUnitTest`; others only expose `testDebugUnitTest`. Use the `test` task to run all variants without needing to know which applies. ### Key Frameworks & Patterns diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/FeaturedToolsFlowProducer.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/FeaturedToolsFlowProducer.kt new file mode 100644 index 0000000000..bc4cb33b73 --- /dev/null +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/FeaturedToolsFlowProducer.kt @@ -0,0 +1,44 @@ +package org.cru.godtools.ui.dashboard.tools + +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.ccci.gto.android.common.kotlin.coroutines.flow.combineTransformLatest +import org.cru.godtools.base.Settings +import org.cru.godtools.db.repository.ToolsRepository +import org.cru.godtools.model.Tool +import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState.Mode + +internal class FeaturedToolsFlowProducer @Inject constructor( + private val settings: Settings, + private val toolsRepository: ToolsRepository, +) { + @OptIn(ExperimentalCoroutinesApi::class) + fun getFlow(mode: Mode, language: Locale? = null): Flow> { + val baseFlow = when (mode) { + Mode.PERSONALIZATION -> { + val languageFlow = if (language != null) flowOf(language) else settings.appLanguageFlow + val fallbackFlow = languageFlow.flatMapLatest { toolsRepository.getFeaturedToolsFlow(it, null) } + + languageFlow + .combineTransformLatest(settings.getPersonalizationCountryFlow()) { lang, country -> + emitAll(toolsRepository.getFeaturedToolsFlow(lang, country)) + } + .combine(fallbackFlow) { featured, fallback -> featured.ifEmpty { fallback } } + .distinctUntilChanged() + } + + Mode.ALL_TOOLS -> toolsRepository.getNormalToolsFlow() + .map { it.filter { it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) } + } + + return baseFlow.map { it.filterNot { it.isHidden } } + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt index ccf4666882..da2eccb9d9 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenter.kt @@ -22,8 +22,9 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.components.SingletonComponent import java.util.Locale +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.ccci.gto.android.common.sync.SyncTracker import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.ACTION_OPEN_TOOL_DETAILS @@ -32,7 +33,6 @@ import org.cru.godtools.analytics.model.OpenAnalyticsActionEvent.Companion.SOURC import org.cru.godtools.base.CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED import org.cru.godtools.base.Settings import org.cru.godtools.base.ui.circuit.screen.dashboard.page.ToolsScreen -import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.sync.GodToolsSyncService @@ -53,8 +53,8 @@ class ToolsPresenter @AssistedInject internal constructor( private val remoteConfig: FirebaseRemoteConfig, private val settings: Settings, private val toolCardPresenter: ToolCardPresenter, - private val toolsRepository: ToolsRepository, private val favoriteToolsBannerPresenter: BannerPresenter, + private val featuredToolsFlowProducer: FeaturedToolsFlowProducer, private val filteredToolsFlowProducer: FilteredToolsFlowProducer, private val toolFiltersStateProducer: ToolFiltersStateProducer, private val syncService: GodToolsSyncService, @@ -79,7 +79,6 @@ class ToolsPresenter @AssistedInject internal constructor( sealed interface UiEvent : CircuitUiEvent { data class ChangeMode(val mode: Mode) : UiEvent - data class OpenToolDetails(val tool: String, val source: String? = null) : UiEvent } // endregion UiState / UiEvent @@ -96,45 +95,33 @@ class ToolsPresenter @AssistedInject internal constructor( RegisterSyncTask(selectedLocale) - val eventSink: (UiEvent) -> Unit = remember { - { - when (it) { - is UiEvent.ChangeMode -> mode = it.mode - - is UiEvent.OpenToolDetails -> { - if (it.source != null) { - eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, it.tool, it.source)) - } - navigator.goTo(ToolDetailsScreen(it.tool, selectedLocale)) - } - } - } - } + val featuredTools = rememberFeaturedTools( + mode = mode, + language = filters.languageFilter.selectedItem + ) { openToolDetails(it, selectedLocale, SOURCE_SPOTLIGHT) } - val spotlightTools = rememberSpotlightTools( - secondLanguage = filters.languageFilter.selectedItem, - eventSink = eventSink - ) val tools = rememberTools( mode = mode, category = filters.categoryFilter.selectedItem, language = filters.languageFilter.selectedItem, - eventSink = eventSink, - ) + ) { openToolDetails(it, selectedLocale, SOURCE_ALL_TOOLS) } return UiState( mode = mode, banner = favoriteToolsBannerPresenter.present(), - dataLoaded = spotlightTools != null && tools != null, + dataLoaded = featuredTools != null && tools != null, spotlightTools = when { isPersonalizationEnabled && mode == Mode.ALL_TOOLS -> emptyList() - else -> spotlightTools.orEmpty() + else -> featuredTools.orEmpty() }, filters = filters, tools = tools.orEmpty(), isPersonalizationEnabled = isPersonalizationEnabled, - eventSink = eventSink, - ) + ) { + when (it) { + is UiEvent.ChangeMode -> mode = it.mode + } + } } @Composable @@ -148,68 +135,62 @@ class ToolsPresenter @AssistedInject internal constructor( } @Composable - private fun rememberSpotlightTools( - secondLanguage: Language?, - eventSink: (UiEvent) -> Unit, + private fun rememberFeaturedTools( + mode: Mode, + language: Language?, + onOpenToolDetails: (String) -> Unit, ): List? { - val tools by remember { - toolsRepository.getNormalToolsFlow() - .map { it.filter { !it.isHidden && it.isSpotlight }.sortedWith(Tool.COMPARATOR_DEFAULT_ORDER) } + val locale = language?.code + val tools by remember(mode, locale) { + featuredToolsFlowProducer.getFlow(mode, locale) }.collectAsState(null) - val eventSink by rememberUpdatedState(eventSink) + return tools?.toToolCardState(language, onOpenToolDetails) + } - return tools?.map { tool -> - val toolCode by rememberUpdatedState(tool.code) + @Composable + private fun rememberTools( + mode: Mode, + category: String?, + language: Language?, + onOpenToolDetails: (String) -> Unit, + ): List? { + val locale = language?.code + val tools by remember(mode, category, locale) { filteredToolsFlowProducer.getFlow(mode, category, locale) } + .collectAsState(null) + return tools?.toToolCardState(language, onOpenToolDetails) + } + @Composable + private fun List.toToolCardState(language: Language?, onOpenToolDetails: (String) -> Unit) = map { tool -> + key(tool.code) { + val toolCode by rememberUpdatedState(tool.code) toolCardPresenter.present( tool = tool, - secondLanguage = secondLanguage, + secondLanguage = language, eventSink = { when (it) { ToolCardEvent.Click, ToolCardEvent.OpenTool, - ToolCardEvent.OpenToolDetails -> - toolCode?.let { eventSink(UiEvent.OpenToolDetails(it, SOURCE_SPOTLIGHT)) } + ToolCardEvent.OpenToolDetails -> toolCode?.let { onOpenToolDetails(it) } } } ) } } - @Composable - private fun rememberTools( - mode: Mode, - category: String?, - language: Language?, - eventSink: (UiEvent) -> Unit, - ): List? { - val locale = language?.code - val tools by remember(mode, category, locale) { filteredToolsFlowProducer.getFlow(mode, category, locale) } - .collectAsState(null) - val eventSink by rememberUpdatedState(eventSink) - - return tools?.map { tool -> - key(tool.code) { - val toolCode by rememberUpdatedState(tool.code) - toolCardPresenter.present( - tool = tool, - secondLanguage = language, - eventSink = { - when (it) { - ToolCardEvent.Click, - ToolCardEvent.OpenTool, - ToolCardEvent.OpenToolDetails -> - toolCode?.let { eventSink(UiEvent.OpenToolDetails(it, SOURCE_ALL_TOOLS)) } - } - } - ) - } + private fun openToolDetails(tool: String, selectedLocale: Locale?, source: String?) { + if (source != null) { + eventBus.post(OpenAnalyticsActionEvent(ACTION_OPEN_TOOL_DETAILS, tool, source)) } + navigator.goTo(ToolDetailsScreen(tool, selectedLocale)) } private fun SyncTracker.syncData(locale: Locale, force: Boolean = false) = launchSync { val country = settings.getCountrySettingFlow().first() - syncService.syncToolOrder(locale, country, force) + coroutineScope { + launch { syncService.syncFeaturedTools(locale, country, force) } + launch { syncService.syncToolOrder(locale, country, force) } + } } @AssistedFactory diff --git a/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/FeaturedToolsFlowProducerTest.kt b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/FeaturedToolsFlowProducerTest.kt new file mode 100644 index 0000000000..0592bac27b --- /dev/null +++ b/app/src/test/kotlin/org/cru/godtools/ui/dashboard/tools/FeaturedToolsFlowProducerTest.kt @@ -0,0 +1,160 @@ +package org.cru.godtools.ui.dashboard.tools + +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.cru.godtools.base.Settings +import org.cru.godtools.db.repository.ToolsRepository +import org.cru.godtools.model.Tool +import org.cru.godtools.model.randomTool +import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState.Mode + +@Suppress("UnusedFlow") +class FeaturedToolsFlowProducerTest { + private val appLanguageFlow = MutableStateFlow(Locale.ENGLISH) + private val countryFlow = MutableStateFlow(null) + private val normalToolsFlow = MutableStateFlow(emptyList()) + + private val settings: Settings = mockk { + every { appLanguageFlow } returns this@FeaturedToolsFlowProducerTest.appLanguageFlow + every { getPersonalizationCountryFlow() } returns countryFlow + } + private val toolsRepository: ToolsRepository = mockk { + every { getNormalToolsFlow() } returns normalToolsFlow + every { getFeaturedToolsFlow(any(), any()) } returns flowOf(emptyList()) + } + + private val producer = FeaturedToolsFlowProducer(settings = settings, toolsRepository = toolsRepository) + + // region ALL_TOOLS mode + @Test + fun `getFlow - All Tools - uses getNormalToolsFlow`() = runTest { + producer.getFlow(mode = Mode.ALL_TOOLS).first() + verify { toolsRepository.getNormalToolsFlow() } + verify(exactly = 0) { toolsRepository.getFeaturedToolsFlow(any(), any()) } + } + + @Test + fun `getFlow - All Tools - only returns spotlight tools`() = runTest { + val spotlight = createTool(isSpotlight = true) + val nonSpotlight = createTool(isSpotlight = false) + + normalToolsFlow.value = listOf(spotlight, nonSpotlight) + assertEquals(listOf(spotlight), producer.getFlow(mode = Mode.ALL_TOOLS).first()) + } + + @Test + fun `getFlow - All Tools - excludes hidden tools`() = runTest { + val hidden = createTool(isSpotlight = true, isHidden = true) + val visible = createTool(isSpotlight = true, isHidden = false) + + normalToolsFlow.value = listOf(hidden, visible) + assertEquals(listOf(visible), producer.getFlow(mode = Mode.ALL_TOOLS).first()) + } + + @Test + fun `getFlow - All Tools - spotlight tools sorted by defaultOrder`() = runTest { + val tools = List(5) { createTool(isSpotlight = true, defaultOrder = it) } + + normalToolsFlow.value = tools.shuffled() + assertEquals(tools, producer.getFlow(mode = Mode.ALL_TOOLS).first()) + } + // endregion ALL_TOOLS mode + + // region PERSONALIZATION mode + @Test + fun `getFlow - Personalization - uses getFeaturedToolsFlow`() = runTest { + producer.getFlow(mode = Mode.PERSONALIZATION).first() + verify { toolsRepository.getFeaturedToolsFlow(any(), any()) } + verify(exactly = 0) { toolsRepository.getNormalToolsFlow() } + } + + @Test + fun `getFlow - Personalization - uses appLanguageFlow when no language provided`() = runTest { + appLanguageFlow.value = Locale.FRENCH + producer.getFlow(mode = Mode.PERSONALIZATION).first() + verify { toolsRepository.getFeaturedToolsFlow(Locale.FRENCH, any()) } + } + + @Test + fun `getFlow - Personalization - uses provided language`() = runTest { + producer.getFlow(mode = Mode.PERSONALIZATION, language = Locale.GERMAN).first() + verify { toolsRepository.getFeaturedToolsFlow(Locale.GERMAN, any()) } + verify(exactly = 0) { toolsRepository.getFeaturedToolsFlow(Locale.ENGLISH, any()) } + } + + @Test + fun `getFlow - Personalization - uses Settings getPersonalizationCountryFlow for country`() = runTest { + countryFlow.value = "US" + producer.getFlow(mode = Mode.PERSONALIZATION).first() + verify { toolsRepository.getFeaturedToolsFlow(any(), "US") } + } + + @Test + fun `getFlow - Personalization - returns featured tools when non-empty`() = runTest { + val tool = createTool() + val fallbackTool = createTool() + countryFlow.value = "US" + every { toolsRepository.getFeaturedToolsFlow(Locale.ENGLISH, "US") } returns flowOf(listOf(tool)) + every { toolsRepository.getFeaturedToolsFlow(Locale.ENGLISH, null) } returns flowOf(listOf(fallbackTool)) + + assertEquals(listOf(tool), producer.getFlow(mode = Mode.PERSONALIZATION).first()) + } + + @Test + fun `getFlow - Personalization - falls back to language only when no country-specific tools`() = runTest { + val fallbackTool = createTool() + countryFlow.value = "US" + every { toolsRepository.getFeaturedToolsFlow(Locale.ENGLISH, "US") } returns flowOf(emptyList()) + every { toolsRepository.getFeaturedToolsFlow(Locale.ENGLISH, null) } returns flowOf(listOf(fallbackTool)) + + assertEquals(listOf(fallbackTool), producer.getFlow(mode = Mode.PERSONALIZATION).first()) + } + + @Test + fun `getFlow - Personalization - excludes hidden tools`() = runTest { + val hidden = createTool(isHidden = true) + val visible = createTool(isHidden = false) + every { toolsRepository.getFeaturedToolsFlow(any(), any()) } returns flowOf(listOf(hidden, visible)) + + assertEquals(listOf(visible), producer.getFlow(mode = Mode.PERSONALIZATION).first()) + } + + @Test + fun `getFlow - Personalization - updates when appLanguage changes`() = runTest { + val frenchTool = createTool() + every { toolsRepository.getFeaturedToolsFlow(Locale.FRENCH, null) } returns flowOf(listOf(frenchTool)) + + producer.getFlow(mode = Mode.PERSONALIZATION).test { + assertEquals(emptyList(), awaitItem()) + + appLanguageFlow.value = Locale.FRENCH + assertEquals(listOf(frenchTool), awaitItem()) + } + } + + @Test + fun `getFlow - Personalization - updates when country changes`() = runTest { + val usTool = createTool() + every { toolsRepository.getFeaturedToolsFlow(Locale.ENGLISH, "US") } returns flowOf(listOf(usTool)) + + producer.getFlow(mode = Mode.PERSONALIZATION).test { + assertEquals(emptyList(), awaitItem()) + + countryFlow.value = "US" + assertEquals(listOf(usTool), awaitItem()) + } + } + // endregion PERSONALIZATION mode + + private fun createTool(defaultOrder: Int = 0, isHidden: Boolean = false, isSpotlight: Boolean = false) = + randomTool(defaultOrder = defaultOrder, isHidden = isHidden, isSpotlight = isSpotlight) +} diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt index 03cb95f525..04178013e5 100644 --- a/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt +++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsPresenterTest.kt @@ -33,7 +33,6 @@ import org.ccci.gto.support.turbine.awaitItemMatching import org.cru.godtools.base.CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED import org.cru.godtools.base.Settings import org.cru.godtools.base.ui.circuit.screen.dashboard.page.ToolsScreen -import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.Language import org.cru.godtools.model.Tool import org.cru.godtools.model.randomTool @@ -59,7 +58,7 @@ import org.robolectric.annotation.Config class ToolsPresenterTest { private var isPersonalizationEnabled = false private val countryFlow = MutableStateFlow("US") - private val toolsFlow = MutableStateFlow(emptyList()) + private val featuredToolsFlow = MutableStateFlow(emptyList()) private val filteredToolsFlow = MutableStateFlow(emptyList()) private val toolOrderSync = Channel() @@ -74,8 +73,12 @@ class ToolsPresenterTest { } private val syncService: GodToolsSyncService = mockk { coEvery { syncToolOrder(any(), any(), any()) } coAnswers { toolOrderSync.receive() } + coEvery { syncFeaturedTools(any(), any(), any()) } returns true } private val favoriteToolsBannerPresenter = FakeBannerPresenter(null) + private val featuredToolsFlowProducer: FeaturedToolsFlowProducer = mockk { + every { getFlow(any(), any()) } returns featuredToolsFlow + } private val filteredToolsFlowProducer: FilteredToolsFlowProducer = mockk { every { getFlow(any(), any(), any()) } returns filteredToolsFlow } @@ -83,9 +86,6 @@ class ToolsPresenterTest { private val remoteConfig: FirebaseRemoteConfig = mockk { every { getBoolean(CONFIG_UI_DASHBOARD_PERSONALIZATION_ENABLED) } answers { isPersonalizationEnabled } } - private val toolsRepository: ToolsRepository = mockk { - every { getNormalToolsFlow() } returns toolsFlow - } private val toolFiltersStateProducer = FakeToolFiltersStateProducer() private val toolCardPresenter = FakeToolCardPresenter() @@ -94,8 +94,8 @@ class ToolsPresenterTest { remoteConfig = remoteConfig, settings = settings, toolCardPresenter = toolCardPresenter, - toolsRepository = toolsRepository, favoriteToolsBannerPresenter = favoriteToolsBannerPresenter, + featuredToolsFlowProducer = featuredToolsFlowProducer, filteredToolsFlowProducer = filteredToolsFlowProducer, toolFiltersStateProducer = toolFiltersStateProducer, syncService = syncService, @@ -185,54 +185,30 @@ class ToolsPresenterTest { // region State.spotlightTools @Test - fun `Property spotlightTools`() = testScope.runTest { - val normalTool = randomTool("normal", isHidden = false, isSpotlight = false) - val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) - - presenter.test { - toolsFlow.value = listOf(normalTool, spotlightTool) - assertEquals(listOf(spotlightTool), expectMostRecentItem().spotlightTools.map { it.tool }) - } - } - - @Test - fun `Property spotlightTools - Don't show hidden tools`() = testScope.runTest { - val hiddenTool = randomTool("normal", isHidden = true, isSpotlight = true) - val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) - - presenter.test { - toolsFlow.value = listOf(hiddenTool, spotlightTool) - assertEquals(listOf(spotlightTool), expectMostRecentItem().spotlightTools.map { it.tool }) - } - } - - @Test - fun `Property spotlightTools - Sorted by default order`() = testScope.runTest { - val tools = List(10) { - randomTool("tool$it", Tool.Type.TRACT, defaultOrder = it, isHidden = false, isSpotlight = true) - } + fun `State - spotlightTools - shows tools from featuredToolsFlowProducer`() = testScope.runTest { + val tool = randomTool(isHidden = false, isSpotlight = true) presenter.test { - toolsFlow.value = tools.shuffled() - assertEquals(tools, expectMostRecentItem().spotlightTools.map { it.tool }) + featuredToolsFlow.value = listOf(tool) + assertEquals(listOf(tool), expectMostRecentItem().spotlightTools.map { it.tool }) } } @Test - fun `Property spotlightTools - Don't show spotlight tools for ALL_TOOLS`() = testScope.runTest { - isPersonalizationEnabled = true - val normalTool = randomTool("normal", isHidden = false, isSpotlight = false) - val spotlightTool = randomTool("spotlight", isHidden = false, isSpotlight = true) - toolsFlow.value = listOf(normalTool, spotlightTool) - - presenter.test { - val initialState = awaitInitialItem() - assertEquals(listOf(spotlightTool), initialState.spotlightTools.map { it.tool }) - - initialState.eventSink(UiEvent.ChangeMode(Mode.ALL_TOOLS)) - assertEquals(emptyList(), expectMostRecentItem().spotlightTools) + fun `State - spotlightTools - Don't show spotlight tools for ALL_TOOLS when personalization enabled`() = + testScope.runTest { + isPersonalizationEnabled = true + val spotlightTool = randomTool(isHidden = false, isSpotlight = true) + featuredToolsFlow.value = listOf(spotlightTool) + + presenter.test { + val initialState = awaitInitialItem() + assertEquals(listOf(spotlightTool), initialState.spotlightTools.map { it.tool }) + + initialState.eventSink(UiEvent.ChangeMode(Mode.ALL_TOOLS)) + assertEquals(emptyList(), expectMostRecentItem().spotlightTools) + } } - } // endregion State.spotlightTools // region State.filters @@ -290,7 +266,10 @@ class ToolsPresenterTest { presenter.test { awaitInitialItem() toolOrderSync.send(true) - coVerifyAll { syncService.syncToolOrder(Locale.ENGLISH, "US", false) } + coVerifyAll { + syncService.syncToolOrder(Locale.ENGLISH, "US", false) + syncService.syncFeaturedTools(Locale.ENGLISH, "US", false) + } } } @@ -303,7 +282,10 @@ class ToolsPresenterTest { presenter.test { awaitInitialItem() toolOrderSync.send(true) - coVerifyAll { syncService.syncToolOrder(Locale.FRENCH, "US", false) } + coVerifyAll { + syncService.syncToolOrder(Locale.FRENCH, "US", false) + syncService.syncFeaturedTools(Locale.FRENCH, "US", false) + } } } diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/ToolsApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/ToolsApi.kt index 2386477c05..e0d7cdf057 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/ToolsApi.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/ToolsApi.kt @@ -25,6 +25,13 @@ interface ToolsApi { @QueryMap params: JsonApiParams, ): Response> + @GET("$PATH_RESOURCES/featured") + suspend fun getFeaturedTools( + @Query(PARAM_FILTER_LANGUAGE) locale: Locale, + @Query(PARAM_FILTER_COUNTRY) country: String? = null, + @QueryMap params: JsonApiParams, + ): Response> + @GET("$PATH_RESOURCES/default_order") suspend fun getToolOrder( @Query(PARAM_FILTER_LANGUAGE) locale: Locale, diff --git a/library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/27.json b/library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/27.json new file mode 100644 index 0000000000..6ff12da4a0 --- /dev/null +++ b/library/db/room-schemas/org.cru.godtools.db.room.GodToolsRoomDatabase/27.json @@ -0,0 +1,859 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "f0379875563560d6bb79eddb990cef7a", + "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" + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT" + }, + { + "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, `name` TEXT, `isForcedName` INTEGER NOT NULL DEFAULT false, `isAdded` INTEGER NOT NULL DEFAULT false, `apiId` INTEGER, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "isForcedName", + "columnName": "isForcedName", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "isAdded", + "columnName": "isAdded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "apiId", + "columnName": "apiId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + } + }, + { + "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" + ] + } + }, + { + "tableName": "downloadedTranslationFiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`translationId` INTEGER NOT NULL, `filename` TEXT NOT NULL, PRIMARY KEY(`translationId`, `filename`))", + "fields": [ + { + "fieldPath": "key.translationId", + "columnName": "translationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key.filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "translationId", + "filename" + ] + } + }, + { + "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" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "tableName": "tools", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `type` TEXT NOT NULL DEFAULT 'UNKNOWN', `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, `defaultLocale` TEXT NOT NULL DEFAULT 'en', `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, `primaryLocale` TEXT, `parallelLocale` TEXT, `changedFields` TEXT NOT NULL DEFAULT '', `progress` REAL, `progressLastPageId` TEXT, `apiId` INTEGER, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNKNOWN'" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "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" + }, + { + "fieldPath": "detailsBannerId", + "columnName": "detailsBannerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "detailsBannerAnimationId", + "columnName": "detailsBannerAnimationId", + "affinity": "INTEGER" + }, + { + "fieldPath": "detailsBannerYoutubeVideoId", + "columnName": "detailsBannerYoutubeVideoId", + "affinity": "TEXT" + }, + { + "fieldPath": "isScreenShareDisabled", + "columnName": "isScreenShareDisabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "defaultLocale", + "columnName": "defaultLocale", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'en'" + }, + { + "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" + }, + { + "fieldPath": "defaultVariantCode", + "columnName": "defaultVariantCode", + "affinity": "TEXT" + }, + { + "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": "primaryLocale", + "columnName": "primaryLocale", + "affinity": "TEXT" + }, + { + "fieldPath": "parallelLocale", + "columnName": "parallelLocale", + "affinity": "TEXT" + }, + { + "fieldPath": "changedFields", + "columnName": "changedFields", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL" + }, + { + "fieldPath": "progressLastPageId", + "columnName": "progressLastPageId", + "affinity": "TEXT" + }, + { + "fieldPath": "apiId", + "columnName": "apiId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + } + }, + { + "tableName": "personalized_featured_tool_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locale` TEXT NOT NULL, `country` TEXT NOT NULL, `tool` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`locale`, `country`, `tool`), FOREIGN KEY(`tool`) REFERENCES `tools`(`code`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tool", + "columnName": "tool", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "locale", + "country", + "tool" + ] + }, + "indices": [ + { + "name": "index_personalized_featured_tool_order_tool", + "unique": false, + "columnNames": [ + "tool" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_personalized_featured_tool_order_tool` ON `${TABLE_NAME}` (`tool`)" + }, + { + "name": "index_personalized_featured_tool_order_locale_country_order", + "unique": false, + "columnNames": [ + "locale", + "country", + "order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_personalized_featured_tool_order_locale_country_order` ON `${TABLE_NAME}` (`locale`, `country`, `order`)" + } + ], + "foreignKeys": [ + { + "table": "tools", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tool" + ], + "referencedColumns": [ + "code" + ] + } + ] + }, + { + "tableName": "personalized_tool_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locale` TEXT NOT NULL, `country` TEXT NOT NULL, `tool` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`locale`, `country`, `tool`), FOREIGN KEY(`tool`) REFERENCES `tools`(`code`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tool", + "columnName": "tool", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "locale", + "country", + "tool" + ] + }, + "indices": [ + { + "name": "index_personalized_tool_order_tool", + "unique": false, + "columnNames": [ + "tool" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_personalized_tool_order_tool` ON `${TABLE_NAME}` (`tool`)" + }, + { + "name": "index_personalized_tool_order_locale_country_order", + "unique": false, + "columnNames": [ + "locale", + "country", + "order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_personalized_tool_order_locale_country_order` ON `${TABLE_NAME}` (`locale`, `country`, `order`)" + } + ], + "foreignKeys": [ + { + "table": "tools", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "tool" + ], + "referencedColumns": [ + "code" + ] + } + ] + }, + { + "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" + ] + } + }, + { + "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" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT" + }, + { + "fieldPath": "toolDetailsConversationStarters", + "columnName": "toolDetailsConversationStarters", + "affinity": "TEXT" + }, + { + "fieldPath": "toolDetailsOutline", + "columnName": "toolDetailsOutline", + "affinity": "TEXT" + }, + { + "fieldPath": "toolDetailsBibleReferences", + "columnName": "toolDetailsBibleReferences", + "affinity": "TEXT" + }, + { + "fieldPath": "manifestFileName", + "columnName": "manifestFileName", + "affinity": "TEXT" + }, + { + "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)" + }, + { + "name": "index_translations_locale", + "unique": false, + "columnNames": [ + "locale" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_translations_locale` ON `${TABLE_NAME}` (`locale`)" + } + ], + "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" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "givenName", + "columnName": "givenName", + "affinity": "TEXT" + }, + { + "fieldPath": "familyName", + "columnName": "familyName", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "isInitialFavoriteToolsSynced", + "columnName": "isInitialFavoriteToolsSynced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + } + ], + "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, 'f0379875563560d6bb79eddb990cef7a')" + ] + } +} \ No newline at end of file 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 91e5165b2c..e008460ca4 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 @@ -29,9 +29,15 @@ interface ToolsRepository { fun getFavoriteToolsFlow() = getNormalToolsFlow() .map { it.filter { it.isFavorite }.sortedWith(Tool.COMPARATOR_FAVORITE_ORDER) } + // region Personalized Tools + fun getFeaturedToolsFlow(locale: Locale, country: String?): Flow> fun getPersonalizedLessonsFlow(locale: Locale, country: String?): Flow> fun getPersonalizedToolsFlow(locale: Locale, country: String?): Flow> + suspend fun storeFeaturedToolsFromSync(locale: Locale, country: String?, tools: List) + suspend fun storePersonalizedToolOrderFromSync(locale: Locale, country: String?, tools: List) + // endregion Personalized Tools + fun toolsChangeFlow(): Flow suspend fun pinTool(code: String, trackChanges: Boolean = true) @@ -49,7 +55,6 @@ interface ToolsRepository { // region Sync Methods suspend fun storeToolsFromSync(tools: Collection) suspend fun storeFavoriteToolsFromSync(tools: Collection) - suspend fun storePersonalizedToolOrderFromSync(locale: Locale, country: String?, tools: List) suspend fun deleteIfNotFavorite(code: String) // endregion Sync Methods } 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 b9ed395419..1992a8aaec 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 @@ -27,6 +27,7 @@ import org.cru.godtools.db.room.entity.FollowupEntity import org.cru.godtools.db.room.entity.GlobalActivityEntity import org.cru.godtools.db.room.entity.LanguageEntity import org.cru.godtools.db.room.entity.LastSyncTimeEntity +import org.cru.godtools.db.room.entity.PersonalizedFeaturedToolOrderEntity import org.cru.godtools.db.room.entity.PersonalizedToolOrderEntity import org.cru.godtools.db.room.entity.ToolEntity import org.cru.godtools.db.room.entity.TrainingTipEntity @@ -46,7 +47,7 @@ import org.cru.godtools.db.room.repository.UserCountersRoomRepository import org.cru.godtools.db.room.repository.UserRoomRepository @Database( - version = 26, + version = 27, entities = [ AttachmentEntity::class, LanguageEntity::class, @@ -55,6 +56,7 @@ import org.cru.godtools.db.room.repository.UserRoomRepository FollowupEntity::class, GlobalActivityEntity::class, ToolEntity::class, + PersonalizedFeaturedToolOrderEntity::class, PersonalizedToolOrderEntity::class, TrainingTipEntity::class, TranslationEntity::class, @@ -68,6 +70,7 @@ import org.cru.godtools.db.room.repository.UserRoomRepository AutoMigration(from = 23, to = 24), AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), + AutoMigration(from = 26, to = 27), ], ) @TypeConverters(Java8TimeConverters::class, LocaleConverter::class) @@ -121,6 +124,7 @@ internal abstract class GodToolsRoomDatabase : RoomDatabase() { * 25: 2025-01-27 * v6.3.7 * 26: 2026-04-07 + * 27: 2026-05-04 */ internal fun RoomDatabase.Builder.enableMigrations() = 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 a6951a2182..d48628cb98 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 @@ -10,10 +10,11 @@ import androidx.room.Update import androidx.room.Upsert import java.util.Locale import kotlinx.coroutines.flow.Flow +import org.cru.godtools.db.room.entity.PersonalizedFeaturedToolOrderEntity import org.cru.godtools.db.room.entity.PersonalizedToolOrderEntity import org.cru.godtools.db.room.entity.ToolEntity -import org.cru.godtools.db.room.entity.partial.SyncPersonalizedTool import org.cru.godtools.db.room.entity.partial.SyncTool +import org.cru.godtools.db.room.entity.partial.SyncToolPlaceholder import org.cru.godtools.db.room.entity.partial.ToolFavorite import org.cru.godtools.model.Tool @@ -58,6 +59,8 @@ internal interface ToolsDao { fun insertOrIgnoreTools(tools: Collection) @Upsert(entity = ToolEntity::class) suspend fun upsertSyncTools(tools: Collection) + @Upsert(entity = ToolEntity::class) + suspend fun upsertToolPlaceholders(placeholders: Collection) @Update(entity = ToolEntity::class) suspend fun update(tool: ToolFavorite) @Update(entity = ToolEntity::class) @@ -75,6 +78,23 @@ internal interface ToolsDao { @Delete suspend fun delete(tool: ToolEntity) + // region Featured Tools + @Query( + """ + SELECT t.* + FROM personalized_featured_tool_order AS o JOIN tools AS t ON t.code = o.tool + WHERE o.locale = :locale AND o.country = :country AND t.type IN (:type) + ORDER BY o.`order` ASC + """ + ) + fun getFeaturedToolsFlow(locale: Locale, country: String, vararg type: Tool.Type): Flow> + + @Upsert + suspend fun upsertFeaturedToolOrder(order: Collection) + @Query("DELETE FROM personalized_featured_tool_order WHERE locale = :locale AND country = :country") + suspend fun resetFeaturedToolOrder(locale: Locale, country: String) + // endregion Featured Tools + // region Personalized Tools @Query( """ @@ -86,11 +106,8 @@ internal interface ToolsDao { ) fun getPersonalizedToolsFlow(locale: Locale, country: String, vararg type: Tool.Type): Flow> - @Upsert(entity = ToolEntity::class) - suspend fun upsertToolsPersonalized(tools: Collection) @Upsert suspend fun upsertPersonalizedToolOrder(order: Collection) - @Query("DELETE FROM personalized_tool_order WHERE locale = :locale AND country = :country") suspend fun resetPersonalizedToolOrder(locale: Locale, country: String) // endregion Personalized Tools diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/PersonalizedFeaturedToolOrderEntity.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/PersonalizedFeaturedToolOrderEntity.kt new file mode 100644 index 0000000000..08b950c5bd --- /dev/null +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/PersonalizedFeaturedToolOrderEntity.kt @@ -0,0 +1,30 @@ +package org.cru.godtools.db.room.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import java.util.Locale + +@Entity( + tableName = "personalized_featured_tool_order", + primaryKeys = ["locale", "country", "tool"], + foreignKeys = [ + ForeignKey( + entity = ToolEntity::class, + parentColumns = ["code"], + childColumns = ["tool"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("tool"), + Index("locale", "country", "order"), + ] +) +internal data class PersonalizedFeaturedToolOrderEntity( + val locale: Locale, + val country: String, + val tool: String, + val order: Int, +) diff --git a/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/partial/SyncPersonalizedTool.kt b/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/partial/SyncToolPlaceholder.kt similarity index 74% rename from library/db/src/main/kotlin/org/cru/godtools/db/room/entity/partial/SyncPersonalizedTool.kt rename to library/db/src/main/kotlin/org/cru/godtools/db/room/entity/partial/SyncToolPlaceholder.kt index 9e36dca93f..59fd73ce1f 100644 --- a/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/partial/SyncPersonalizedTool.kt +++ b/library/db/src/main/kotlin/org/cru/godtools/db/room/entity/partial/SyncToolPlaceholder.kt @@ -2,7 +2,7 @@ package org.cru.godtools.db.room.entity.partial import org.cru.godtools.model.Tool -internal class SyncPersonalizedTool(tool: Tool) { +internal class SyncToolPlaceholder(tool: Tool) { val apiId = tool.apiId val code = tool.code.orEmpty() } 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 1047770bd5..28ba0d71bd 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 @@ -8,10 +8,11 @@ import kotlinx.coroutines.flow.map import org.ccci.gto.android.common.androidx.room.changeFlow import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.db.room.GodToolsRoomDatabase +import org.cru.godtools.db.room.entity.PersonalizedFeaturedToolOrderEntity import org.cru.godtools.db.room.entity.PersonalizedToolOrderEntity import org.cru.godtools.db.room.entity.ToolEntity -import org.cru.godtools.db.room.entity.partial.SyncPersonalizedTool import org.cru.godtools.db.room.entity.partial.SyncTool +import org.cru.godtools.db.room.entity.partial.SyncToolPlaceholder import org.cru.godtools.model.Tool import org.cru.godtools.model.Tool.Type.Companion.NORMAL_TYPES import org.cru.godtools.model.trackChanges @@ -35,6 +36,9 @@ internal abstract class ToolsRoomRepository(private val db: GodToolsRoomDatabase override fun getLessonsFlowByLanguage(locale: Locale) = dao.getToolsFlowByTypeAndLanguage(listOf(Tool.Type.LESSON), locale).map { it.map { it.toModel() } } + override fun getFeaturedToolsFlow(locale: Locale, country: String?) = + dao.getFeaturedToolsFlow(locale, country.orEmpty(), *NORMAL_TYPES.toTypedArray()) + .map { it.map { it.toModel() } } override fun getPersonalizedLessonsFlow(locale: Locale, country: String?) = dao.getPersonalizedToolsFlow(locale, country.orEmpty(), Tool.Type.LESSON).map { it.map { it.toModel() } } override fun getPersonalizedToolsFlow(locale: Locale, country: String?) = @@ -96,9 +100,25 @@ internal abstract class ToolsRoomRepository(private val db: GodToolsRoomDatabase dao.updateToolFavorites(toolFavorites) } + @Transaction + override suspend fun storeFeaturedToolsFromSync(locale: Locale, country: String?, tools: List) { + dao.upsertToolPlaceholders(tools.map { SyncToolPlaceholder(it) }) + dao.resetFeaturedToolOrder(locale, country.orEmpty()) + dao.upsertFeaturedToolOrder( + tools.mapIndexed { i, tool -> + PersonalizedFeaturedToolOrderEntity( + locale = locale, + country = country.orEmpty(), + tool = tool.code.orEmpty(), + order = i + ) + } + ) + } + @Transaction override suspend fun storePersonalizedToolOrderFromSync(locale: Locale, country: String?, tools: List) { - dao.upsertToolsPersonalized(tools.map { SyncPersonalizedTool(it) }) + dao.upsertToolPlaceholders(tools.map { SyncToolPlaceholder(it) }) dao.resetPersonalizedToolOrder(locale, country.orEmpty()) dao.upsertPersonalizedToolOrder( tools.mapIndexed { i, tool -> 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 99f1646878..e5c24c2e6e 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 @@ -251,6 +251,78 @@ abstract class ToolsRepositoryIT { } // endregion getLessonsFlowByLanguage() + // region getFeaturedToolsFlow() + @Test + fun `getFeaturedToolsFlow() - empty when no order stored`() = testScope.runTest { + repository.storeInitialTools(listOf(randomTool("tool", Tool.Type.TRACT))) + assertTrue(repository.getFeaturedToolsFlow(Locale.ENGLISH, null).first().isEmpty()) + } + + @Test + fun `getFeaturedToolsFlow() - returns tools in featured order`() = testScope.runTest { + val tool1 = randomTool("tool1", Tool.Type.TRACT) + val tool2 = randomTool("tool2", Tool.Type.CYOA) + val tool3 = randomTool("tool3", Tool.Type.ARTICLE) + repository.storeInitialTools(listOf(tool1, tool2, tool3)) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, "US", listOf(tool2, tool3, tool1)) + assertEquals( + listOf("tool2", "tool3", "tool1"), + repository.getFeaturedToolsFlow(Locale.ENGLISH, "US").first().map { it.code } + ) + } + + @Test + fun `getFeaturedToolsFlow() - filters to NORMAL_TYPES only`() = testScope.runTest { + val tract = randomTool("tract", Tool.Type.TRACT) + val lesson = randomTool("lesson", Tool.Type.LESSON) + val meta = randomTool("meta", Tool.Type.META) + repository.storeInitialTools(listOf(tract, lesson, meta)) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(lesson, meta, tract)) + assertEquals( + listOf("tract"), + repository.getFeaturedToolsFlow(Locale.ENGLISH, null).first().map { it.code } + ) + } + + @Test + fun `getFeaturedToolsFlow() - scoped to locale and country`() = testScope.runTest { + val tool = randomTool("tool", Tool.Type.TRACT) + repository.storeInitialTools(listOf(tool)) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, "US", listOf(tool)) + assertTrue(repository.getFeaturedToolsFlow(Locale.FRENCH, "US").first().isEmpty()) + assertTrue(repository.getFeaturedToolsFlow(Locale.ENGLISH, "CA").first().isEmpty()) + } + + @Test + fun `getFeaturedToolsFlow() - null country`() = testScope.runTest { + val tool = randomTool("tool", Tool.Type.TRACT) + repository.storeInitialTools(listOf(tool)) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(tool)) + assertEquals(listOf("tool"), repository.getFeaturedToolsFlow(Locale.ENGLISH, null).first().map { it.code }) + } + + @Test + fun `getFeaturedToolsFlow() - updates when order changes`() = testScope.runTest { + val tool1 = randomTool("tool1", Tool.Type.TRACT) + val tool2 = randomTool("tool2", Tool.Type.TRACT) + repository.storeInitialTools(listOf(tool1, tool2)) + + repository.getFeaturedToolsFlow(Locale.ENGLISH, null).test { + assertTrue(awaitItem().isEmpty()) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(tool1, tool2)) + assertEquals(listOf("tool1", "tool2"), awaitItem().map { it.code }) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(tool2, tool1)) + assertEquals(listOf("tool2", "tool1"), awaitItem().map { it.code }) + } + } + // endregion getFeaturedToolsFlow() + // region getPersonalizedToolsFlow() @Test fun `getPersonalizedToolsFlow() - empty when no order stored`() = testScope.runTest { @@ -712,13 +784,57 @@ abstract class ToolsRepositoryIT { } // endregion storeFavoriteToolsFromSync() + // region storeFeaturedToolsFromSync() + @Test + fun `storeFeaturedToolsFromSync() - doesn't pave over existing tool data`() = testScope.runTest { + val tool = randomTool("tool", Tool.Type.TRACT) + repository.storeInitialTools(listOf(tool)) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(randomTool("tool", apiId = tool.apiId))) + assertEquals(tool, repository.findTool("tool")) + } + + @Test + fun `storeFeaturedToolsFromSync() - order is preserved when tools are stored after`() = testScope.runTest { + val tool1 = randomTool("tool1", Tool.Type.TRACT) + val tool2 = randomTool("tool2", Tool.Type.TRACT) + val tool3 = randomTool("tool3", Tool.Type.TRACT) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(tool2, tool3, tool1)) + repository.storeToolsFromSync(listOf(tool1, tool2, tool3)) + assertEquals( + listOf("tool2", "tool3", "tool1"), + repository.getFeaturedToolsFlow(Locale.ENGLISH, null).first().map { it.code } + ) + } + + @Test + fun `storeFeaturedToolsFromSync() - different locales are independent`() = testScope.runTest { + val tool1 = randomTool("tool1", Tool.Type.TRACT) + val tool2 = randomTool("tool2", Tool.Type.TRACT) + repository.storeInitialTools(listOf(tool1, tool2)) + + repository.storeFeaturedToolsFromSync(Locale.ENGLISH, null, listOf(tool1, tool2)) + repository.storeFeaturedToolsFromSync(Locale.FRENCH, null, listOf(tool2, tool1)) + assertEquals( + listOf("tool1", "tool2"), + repository.getFeaturedToolsFlow(Locale.ENGLISH, null).first().map { it.code } + ) + assertEquals( + listOf("tool2", "tool1"), + repository.getFeaturedToolsFlow(Locale.FRENCH, null).first().map { it.code } + ) + } + // endregion storeFeaturedToolsFromSync() + // region storePersonalizedToolOrderFromSync() @Test fun `storePersonalizedToolOrderFromSync() - doesn't pave over existing tool data`() = testScope.runTest { val tool = randomTool("tool", Tool.Type.TRACT) repository.storeInitialTools(listOf(tool)) - repository.storePersonalizedToolOrderFromSync(Locale.ENGLISH, null, listOf(tool)) + val order = listOf(randomTool(code = "tool", apiId = tool.apiId)) + repository.storePersonalizedToolOrderFromSync(Locale.ENGLISH, null, order) assertEquals(tool, repository.findTool("tool")) } 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 dcb662d832..18013588be 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 @@ -193,6 +193,40 @@ class GodToolsRoomDatabaseMigrationIT { } } + @Test + fun testMigrate26To27() { + val featuredOrderQuery = "SELECT * FROM personalized_featured_tool_order" + + // create v26 database + helper.createDatabase(GodToolsRoomDatabase.DATABASE_NAME, 26).use { db -> + db.execSQL("INSERT INTO tools (code, type) VALUES (?, ?)", arrayOf("kgp", Tool.Type.TRACT)) + assertFailsWith { db.query(featuredOrderQuery) } + } + + // run migration + helper.runMigrationsAndValidate(GodToolsRoomDatabase.DATABASE_NAME, 27, true, *MIGRATIONS).use { db -> + db.query("SELECT code, type FROM tools WHERE code = 'kgp'").use { + assertEquals(1, it.count) + it.moveToFirst() + assertEquals("kgp", it.getStringOrNull(0)) + assertEquals("TRACT", it.getStringOrNull(1)) + } + db.query(featuredOrderQuery).close() + db.execSQL( + "INSERT INTO personalized_featured_tool_order (locale, country, tool, `order`) VALUES (?, ?, ?, ?)", + arrayOf("en", "US", "kgp", 0) + ) + db.query("SELECT locale, country, tool, `order` FROM personalized_featured_tool_order").use { + assertEquals(1, it.count) + it.moveToFirst() + assertEquals("en", it.getStringOrNull(0)) + assertEquals("US", it.getStringOrNull(1)) + assertEquals("kgp", it.getStringOrNull(2)) + assertEquals(0, it.getIntOrNull(3)) + } + } + } + private fun SupportSQLiteDatabase.dumpIndices(table: String) = query("PRAGMA index_list($table)").use { it -> it.map { it.getString(1) }.associateWith { name -> query("PRAGMA index_info($name)").use { it.map { it.getString(2) }.toSet() } 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 cba6cd5e97..dce0fd5431 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 @@ -86,8 +86,11 @@ class GodToolsSyncService @VisibleForTesting internal constructor( suspend fun syncTool(toolCode: String, force: Boolean = false) = executeSync { syncTool(toolCode, force) } + suspend fun syncFeaturedTools(locale: Locale, country: String?, force: Boolean = false) = + executeSync { syncFeaturedTools(locale, country, force) } + suspend fun syncToolOrder(locale: Locale, country: String?, force: Boolean = false) = - executeSync { this.syncToolOrder(locale, country, force) } + executeSync { syncToolOrder(locale, country, force) } suspend fun syncGlobalActivity(force: Boolean = false) = executeSync { syncGlobalActivity(force) } diff --git a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/ToolSyncTasks.kt b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/ToolSyncTasks.kt index bd25c27956..1610e10ba0 100644 --- a/library/sync/src/main/kotlin/org/cru/godtools/sync/task/ToolSyncTasks.kt +++ b/library/sync/src/main/kotlin/org/cru/godtools/sync/task/ToolSyncTasks.kt @@ -39,6 +39,7 @@ internal class ToolSyncTasks @Inject internal constructor( ) : BaseSyncTasks() { internal companion object { const val SYNC_TIME_TOOLS = "last_synced.tools" + const val SYNC_TIME_FEATURED_TOOLS = "last_synced.featured_tools." const val SYNC_TIME_TOOL_ORDER = "last_synced.tool_order." private val INCLUDES_GET_TOOL = Includes( @@ -52,7 +53,7 @@ internal class ToolSyncTasks @Inject internal constructor( .fields(Tool.JSONAPI_TYPE, *Tool.JSONAPI_FIELDS) .fields(Language.JSONAPI_TYPE, *Language.JSONAPI_FIELDS) - private fun buildToolOrderApiParams() = JsonApiParams() + private fun buildToolPlaceholderParams() = JsonApiParams() .fields(Tool.JSONAPI_TYPE, Tool.JSON_CODE) } @@ -102,6 +103,35 @@ internal class ToolSyncTasks @Inject internal constructor( true } + // region Featured Tools + private val featuredToolsMutex = MutexMap() + + internal suspend fun syncFeaturedTools(locale: Locale, country: String?, force: Boolean = false): Boolean { + val normalizedCountry = country?.uppercase() + + featuredToolsMutex.withLock(locale to normalizedCountry) { + if (!force && + !lastSyncTimeRepository.isLastSyncStale( + SYNC_TIME_FEATURED_TOOLS, + locale, + normalizedCountry.orEmpty(), + staleAfter = STALE_DURATION_TOOLS, + ) + ) { + return true + } + + val tools = toolsApi.getFeaturedTools(locale, normalizedCountry, buildToolPlaceholderParams()) + .takeIf { it.code() == HTTP_OK }?.body()?.data ?: return false + + toolsRepository.storeFeaturedToolsFromSync(locale, normalizedCountry, tools) + lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_FEATURED_TOOLS, locale, normalizedCountry.orEmpty()) + + return true + } + } + // endregion Featured Tools + // region Tool Order private val toolOrderMutex = MutexMap() @@ -120,10 +150,10 @@ internal class ToolSyncTasks @Inject internal constructor( return true } - val tools = toolsApi.getToolOrder(locale, country, buildToolOrderApiParams()) + val tools = toolsApi.getToolOrder(locale, normalizedCountry, buildToolPlaceholderParams()) .takeIf { it.code() == HTTP_OK }?.body()?.data ?: return false - toolsRepository.storePersonalizedToolOrderFromSync(locale, country, tools) + toolsRepository.storePersonalizedToolOrderFromSync(locale, normalizedCountry, tools) lastSyncTimeRepository.updateLastSyncTime(SYNC_TIME_TOOL_ORDER, locale, normalizedCountry.orEmpty()) return true diff --git a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/ToolSyncTasksTest.kt b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/ToolSyncTasksTest.kt index e3cbea87f1..23f8af6405 100644 --- a/library/sync/src/test/kotlin/org/cru/godtools/sync/task/ToolSyncTasksTest.kt +++ b/library/sync/src/test/kotlin/org/cru/godtools/sync/task/ToolSyncTasksTest.kt @@ -18,6 +18,7 @@ import org.cru.godtools.db.repository.InMemoryLastSyncTimeRepository import org.cru.godtools.db.repository.ToolsRepository import org.cru.godtools.model.randomTool import org.cru.godtools.sync.repository.SyncRepository +import org.cru.godtools.sync.task.ToolSyncTasks.Companion.SYNC_TIME_FEATURED_TOOLS import org.cru.godtools.sync.task.ToolSyncTasks.Companion.SYNC_TIME_TOOL_ORDER import retrofit2.Response @@ -27,10 +28,13 @@ class ToolSyncTasksTest { private val tool = randomTool() private val existingTools = listOf(randomTool()) + private val apiFeaturedTools = listOf(randomTool(), randomTool()) private val apiToolOrder = listOf(randomTool(), randomTool()) private val toolsApi: ToolsApi = mockk { coEvery { list(any()) } returns Response.success(JsonApiObject.single(tool)) + coEvery { getFeaturedTools(any(), any(), any()) } returns + Response.success(JsonApiObject.of(*apiFeaturedTools.toTypedArray())) coEvery { getToolOrder(any(), any(), any()) } returns Response.success(JsonApiObject.of(*apiToolOrder.toTypedArray())) } @@ -40,6 +44,7 @@ class ToolSyncTasksTest { } private val toolsRepository: ToolsRepository = mockk { coEvery { getAllTools() } returns existingTools + coEvery { storeFeaturedToolsFromSync(any(), any(), any()) } just Runs coEvery { storePersonalizedToolOrderFromSync(any(), any(), any()) } just Runs } private val lastSyncTimeRepository = InMemoryLastSyncTimeRepository() @@ -99,6 +104,61 @@ class ToolSyncTasksTest { } // endregion syncTools() + // region syncFeaturedTools() + @Test + fun `syncFeaturedTools()`() = runTest { + tasks.syncFeaturedTools(locale, country) + coVerifyAll { + toolsApi.getFeaturedTools(locale, country, any()) + toolsRepository.storeFeaturedToolsFromSync(locale, country, apiFeaturedTools) + } + assertFalse( + lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_FEATURED_TOOLS, locale, country, staleAfter = 60_000) + ) + } + + @Test + fun `syncFeaturedTools(country = null)`() = runTest { + tasks.syncFeaturedTools(locale, null) + coVerifyAll { + toolsApi.getFeaturedTools(locale, null, any()) + toolsRepository.storeFeaturedToolsFromSync(locale, null, apiFeaturedTools) + } + assertFalse( + lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_FEATURED_TOOLS, locale, "", staleAfter = 60_000) + ) + } + + @Test + fun `syncFeaturedTools(force = false) - already synced`() = runTest { + with(lastSyncTimeRepository) { + setLastSyncTime(SYNC_TIME_FEATURED_TOOLS, locale, country, time = System.currentTimeMillis()) + } + + tasks.syncFeaturedTools(locale, country, force = false) + coVerifyAll { + toolsApi wasNot Called + toolsRepository wasNot Called + } + } + + @Test + fun `syncFeaturedTools(force = true) - already synced`() = runTest { + with(lastSyncTimeRepository) { + setLastSyncTime(SYNC_TIME_FEATURED_TOOLS, locale, country, time = System.currentTimeMillis()) + } + + tasks.syncFeaturedTools(locale, country, force = true) + coVerifyAll { + toolsApi.getFeaturedTools(locale, country, any()) + toolsRepository.storeFeaturedToolsFromSync(locale, country, apiFeaturedTools) + } + assertFalse( + lastSyncTimeRepository.isLastSyncStale(SYNC_TIME_FEATURED_TOOLS, locale, country, staleAfter = 60_000) + ) + } + // endregion syncFeaturedTools() + // region syncToolOrder() @Test fun `syncToolOrder()`() = runTest {