For #21593 - Persist stories categories selections in a Proto DataStore

A fast and easy solution with all the ACID requirements.
Also supports easy migrations if later the data we need persisted changes.
This commit is contained in:
Mugurell 2021-10-04 10:00:38 +03:00 committed by mergify[bot]
parent 565beb88c9
commit e4489b8d7d
10 changed files with 391 additions and 9 deletions

View File

@ -1,5 +1,6 @@
plugins {
id "com.jetbrains.python.envs" version "0.0.26"
id "com.google.protobuf" version "0.8.17"
}
apply plugin: 'com.android.application'
@ -525,6 +526,8 @@ dependencies {
implementation Deps.androidx_core_ktx
implementation Deps.androidx_transition
implementation Deps.androidx_work_ktx
implementation Deps.androidx_datastore
implementation Deps.protobuf_javalite
implementation Deps.google_material
implementation Deps.adjust
@ -589,6 +592,25 @@ dependencies {
lintChecks project(":mozilla-lint-rules")
}
protobuf {
protoc {
artifact = Deps.protobuf_compiler
}
// Generates the java Protobuf-lite code for the Protobufs in this project. See
// https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
// for more information.
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option 'lite'
}
}
}
}
}
if (project.hasProperty("coverage")) {
tasks.withType(Test).configureEach {
jacoco.includeNoLocationClasses = true

View File

@ -0,0 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
/**
* Application / process unique [DataStore] for IO operations related to Pocket recommended stories selected categories.
*/
internal val Context.pocketStoriesSelectedCategoriesDataStore: DataStore<SelectedPocketStoriesCategories> by dataStore(
fileName = "pocket_recommendations_selected_categories.pb",
serializer = SelectedPocketStoriesCategorySerializer
)

View File

@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.datastore
import androidx.datastore.core.Serializer
import java.io.InputStream
import java.io.OutputStream
/**
* Serializer for [SelectedPocketStoriesCategories] defined in selected_pocket_stories_categories.proto.
*/
@Suppress("BlockingMethodInNonBlockingContext")
object SelectedPocketStoriesCategorySerializer : Serializer<SelectedPocketStoriesCategories> {
override val defaultValue: SelectedPocketStoriesCategories = SelectedPocketStoriesCategories.getDefaultInstance()
override suspend fun readFrom(input: InputStream): SelectedPocketStoriesCategories {
return SelectedPocketStoriesCategories.parseFrom(input)
}
override suspend fun writeTo(t: SelectedPocketStoriesCategories, output: OutputStream) {
t.writeTo(output)
}
}

View File

@ -93,6 +93,7 @@ import org.mozilla.fenix.components.tips.providers.MasterPasswordTipProvider
import org.mozilla.fenix.components.toolbar.FenixTabCounterMenu
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.databinding.FragmentHomeBinding
import org.mozilla.fenix.datastore.pocketStoriesSelectedCategoriesDataStore
import org.mozilla.fenix.ext.asRecentTabs
import org.mozilla.fenix.experiments.FeatureId
import org.mozilla.fenix.ext.components
@ -248,7 +249,9 @@ class HomeFragment : Fragment() {
),
listOf(
PocketUpdatesMiddleware(
lifecycleScope, requireComponents.core.pocketStoriesService
lifecycleScope,
requireComponents.core.pocketStoriesService,
requireContext().pocketStoriesSelectedCategoriesDataStore
)
)
)

View File

@ -103,6 +103,10 @@ sealed class HomeFragmentAction : Action {
data class PocketStoriesChange(val pocketStories: List<PocketRecommendedStory>) : HomeFragmentAction()
data class PocketStoriesCategoriesChange(val storiesCategories: List<PocketRecommendedStoriesCategory>) :
HomeFragmentAction()
data class PocketStoriesCategoriesSelectionsChange(
val storiesCategories: List<PocketRecommendedStoriesCategory>,
val categoriesSelected: List<PocketRecommendedStoriesSelectedCategory>
) : HomeFragmentAction()
object RemoveCollectionsPlaceholder : HomeFragmentAction()
object RemoveSetDefaultBrowserCard : HomeFragmentAction()
}
@ -172,8 +176,18 @@ private fun homeFragmentStateReducer(
)
}
is HomeFragmentAction.PocketStoriesCategoriesChange -> {
// Whenever categories change stories to be displayed needs to also be changed.
val updatedCategoriesState = state.copy(pocketStoriesCategories = action.storiesCategories)
// Whenever categories change stories to be displayed needs to also be changed.
return updatedCategoriesState.copy(
pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT)
)
}
is HomeFragmentAction.PocketStoriesCategoriesSelectionsChange -> {
val updatedCategoriesState = state.copy(
pocketStoriesCategories = action.storiesCategories,
pocketStoriesCategoriesSelections = action.categoriesSelected
)
// Whenever categories change stories to be displayed needs to also be changed.
return updatedCategoriesState.copy(
pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT)
)

View File

@ -4,25 +4,57 @@
package org.mozilla.fenix.home
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.lib.state.Store
import mozilla.components.service.pocket.PocketStoriesService
import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories
import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories.SelectedPocketStoriesCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory
/**
* [HomeFragmentStore] middleware reacting in response to Pocket related [Action]s.
*
* @param coroutineScope [CoroutineScope] used for long running operations like disk IO.
* @param pocketStoriesService [PocketStoriesService] used for updating details about the Pocket recommended stories.
* @param selectedPocketCategoriesDataStore [DataStore] used for reading or persisting details about the
* currently selected Pocket recommended stories categories.
*/
class PocketUpdatesMiddleware(
private val coroutineScope: CoroutineScope,
private val pocketStoriesService: PocketStoriesService
private val pocketStoriesService: PocketStoriesService,
private val selectedPocketCategoriesDataStore: DataStore<SelectedPocketStoriesCategories>
) : Middleware<HomeFragmentState, HomeFragmentAction> {
override fun invoke(
context: MiddlewareContext<HomeFragmentState, HomeFragmentAction>,
next: (HomeFragmentAction) -> Unit,
action: HomeFragmentAction
) {
// Pre process actions
when (action) {
is HomeFragmentAction.PocketStoriesCategoriesChange -> {
// Intercept the original action which would only update categories and
// dispatch a new action which also updates which categories are selected by the user
// from previous locally persisted data.
restoreSelectedCategories(
coroutineScope = coroutineScope,
currentCategories = action.storiesCategories,
store = context.store,
selectedPocketCategoriesDataStore = selectedPocketCategoriesDataStore
)
}
else -> {
// no-op
}
}
next(action)
// Post process actions
@ -36,9 +68,80 @@ class PocketUpdatesMiddleware(
)
}
}
is HomeFragmentAction.SelectPocketStoriesCategory,
is HomeFragmentAction.DeselectPocketStoriesCategory -> {
persistSelectedCategories(
coroutineScope = coroutineScope,
currentCategoriesSelections = context.state.pocketStoriesCategoriesSelections,
selectedPocketCategoriesDataStore = selectedPocketCategoriesDataStore
)
}
else -> {
// no-op
}
}
}
}
/**
* Persist [currentCategoriesSelections] for making this details available in between app restarts.
*
* @param coroutineScope [CoroutineScope] used for reading the locally persisted data.
* @param currentCategoriesSelections Currently selected Pocket recommended stories categories.
* @param selectedPocketCategoriesDataStore - DataStore used for persisting [currentCategoriesSelections].
*/
@VisibleForTesting
internal fun persistSelectedCategories(
coroutineScope: CoroutineScope,
currentCategoriesSelections: List<PocketRecommendedStoriesSelectedCategory>,
selectedPocketCategoriesDataStore: DataStore<SelectedPocketStoriesCategories>
) {
val selectedCategories = currentCategoriesSelections
.map {
SelectedPocketStoriesCategory.newBuilder().apply {
name = it.name
selectionTimestamp = it.selectionTimestamp
}.build()
}
// Irrespective of the current selections or their number overwrite everything we had.
coroutineScope.launch {
selectedPocketCategoriesDataStore.updateData { data ->
data.newBuilderForType().addAllValues(selectedCategories).build()
}
}
}
/**
* Combines [currentCategories] with the locally persisted data about previously selected categories
* and emits a new [HomeFragmentAction.PocketStoriesCategoriesSelectionsChange] to update these in store.
*
* @param coroutineScope [CoroutineScope] used for reading the locally persisted data.
* @param currentCategories Stories categories currently available
* @param store [Store] that will be updated.
* @param selectedPocketCategoriesDataStore [DataStore] containing details about the previously selected
* stories categories.
*/
@VisibleForTesting
internal fun restoreSelectedCategories(
coroutineScope: CoroutineScope,
currentCategories: List<PocketRecommendedStoriesCategory>,
store: Store<HomeFragmentState, HomeFragmentAction>,
selectedPocketCategoriesDataStore: DataStore<SelectedPocketStoriesCategories>
) {
coroutineScope.launch {
selectedPocketCategoriesDataStore.data.collect { persistedSelectedCategories ->
store.dispatch(
HomeFragmentAction.PocketStoriesCategoriesSelectionsChange(
currentCategories,
persistedSelectedCategories.valuesList.map {
PocketRecommendedStoriesSelectedCategory(
name = it.name,
selectionTimestamp = it.selectionTimestamp
)
}
)
)
}
}
}

View File

@ -0,0 +1,26 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
syntax = "proto3";
package proto;
option java_package = "org.mozilla.fenix.datastore";
option java_multiple_files = true;
// List of currently selected Pocket recommended stories categories.
message SelectedPocketStoriesCategories {
// Details about a selected Pocket recommended stories category.
// See [org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory]
message SelectedPocketStoriesCategory {
// Name of this category.
string name = 1;
// Timestamp for when this category was selected.
int64 selectionTimestamp = 2;
}
// Currently selected Pocket stories categories.
repeated SelectedPocketStoriesCategory values = 1;
}

View File

@ -195,8 +195,9 @@ class HomeFragmentStoreTest {
val filteredStories = listOf(mockk<PocketRecommendedStory>())
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory, anotherStoriesCategory
pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory),
pocketStoriesCategoriesSelections = listOf(
PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
)
)
)
@ -204,13 +205,13 @@ class HomeFragmentStoreTest {
mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") {
every { any<HomeFragmentState>().getFilteredStories(any()) } returns filteredStories
homeFragmentStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory("other")).join()
homeFragmentStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory("another")).join()
verify { any<HomeFragmentState>().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) }
}
val selectedCategories = homeFragmentStore.state.pocketStoriesCategoriesSelections
assertEquals(1, selectedCategories.size)
assertEquals(2, selectedCategories.size)
assertTrue(otherStoriesCategory.name === selectedCategories[0].name)
assertSame(filteredStories, homeFragmentStore.state.pocketStories)
}
@ -293,4 +294,34 @@ class HomeFragmentStoreTest {
assertSame(secondFilteredStories, homeFragmentStore.state.pocketStories)
}
}
@Test
fun `Test updating the list of selected Pocket recommendations categories`() = runBlocking {
val otherStoriesCategory = PocketRecommendedStoriesCategory("other")
val anotherStoriesCategory = PocketRecommendedStoriesCategory("another")
val selectedCategory = PocketRecommendedStoriesSelectedCategory("selected")
homeFragmentStore = HomeFragmentStore(HomeFragmentState())
mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") {
val firstFilteredStories = listOf(mockk<PocketRecommendedStory>())
every { any<HomeFragmentState>().getFilteredStories(any()) } returns firstFilteredStories
homeFragmentStore.dispatch(
HomeFragmentAction.PocketStoriesCategoriesSelectionsChange(
storiesCategories = listOf(otherStoriesCategory, anotherStoriesCategory),
categoriesSelected = listOf(selectedCategory)
)
).join()
verify { any<HomeFragmentState>().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) }
assertTrue(
homeFragmentStore.state.pocketStoriesCategories.containsAll(
listOf(otherStoriesCategory, anotherStoriesCategory)
)
)
assertTrue(
homeFragmentStore.state.pocketStoriesCategoriesSelections.containsAll(listOf(selectedCategory))
)
assertSame(firstFilteredStories, homeFragmentStore.state.pocketStories)
}
}
}

View File

@ -4,17 +4,26 @@
package org.mozilla.fenix.home
import androidx.datastore.core.DataStore
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.service.pocket.PocketRecommendedStory
import mozilla.components.service.pocket.PocketStoriesService
import mozilla.components.support.test.ext.joinBlocking
import org.junit.Test
import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories
import org.mozilla.fenix.datastore.SelectedPocketStoriesCategories.SelectedPocketStoriesCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoriesSelectedCategory
@ExperimentalCoroutinesApi
class PocketUpdatesMiddlewareTest {
@ExperimentalCoroutinesApi
@Test
fun `WHEN PocketStoriesShown is dispatched THEN update PocketStoriesService`() {
val story1 = PocketRecommendedStory("title", "url1", "imageUrl", "publisher", "category", 0, timesShown = 0)
@ -22,7 +31,7 @@ class PocketUpdatesMiddlewareTest {
val story3 = story1.copy("title3", "url3")
val coroutineScope = TestCoroutineScope()
val pocketService: PocketStoriesService = mockk(relaxed = true)
val pocketMiddleware = PocketUpdatesMiddleware(coroutineScope, pocketService)
val pocketMiddleware = PocketUpdatesMiddleware(coroutineScope, pocketService, mockk())
val homeStore = HomeFragmentStore(
HomeFragmentState(
pocketStories = listOf(story1, story2, story3)
@ -34,4 +43,129 @@ class PocketUpdatesMiddlewareTest {
coVerify { pocketService.updateStoriesTimesShown(listOf(story2.copy(timesShown = 1))) }
}
@Test
fun `WHEN PocketStoriesCategoriesChange is dispatched THEN intercept and dispatch PocketStoriesCategoriesSelectionsChange`() {
val persistedSelectedCategory: SelectedPocketStoriesCategory = mockk {
every { name } returns "testCategory"
every { selectionTimestamp } returns 123
}
val persistedSelectedCategories: SelectedPocketStoriesCategories = mockk {
every { valuesList } returns mutableListOf(persistedSelectedCategory)
}
val dataStore: DataStore<SelectedPocketStoriesCategories> = mockk {
every { data } returns flowOf(persistedSelectedCategories)
}
val currentCategories = listOf(mockk<PocketRecommendedStoriesCategory>())
val pocketMiddleware = PocketUpdatesMiddleware(TestCoroutineScope(), mockk(), dataStore)
val homeStore = spyk(
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = currentCategories
),
listOf(pocketMiddleware)
)
)
homeStore.dispatch(HomeFragmentAction.PocketStoriesCategoriesChange(currentCategories)).joinBlocking()
verify {
homeStore.dispatch(
HomeFragmentAction.PocketStoriesCategoriesSelectionsChange(
storiesCategories = currentCategories,
categoriesSelected = listOf(
PocketRecommendedStoriesSelectedCategory("testCategory", 123)
)
)
)
}
}
@Test
fun `WHEN SelectPocketStoriesCategory is dispatched THEN persist details in DataStore`() {
val categ1 = PocketRecommendedStoriesCategory("categ1")
val categ2 = PocketRecommendedStoriesCategory("categ2")
val dataStore: DataStore<SelectedPocketStoriesCategories> = mockk(relaxed = true)
val pocketMiddleware = PocketUpdatesMiddleware(TestCoroutineScope(), mockk(), dataStore)
val homeStore = spyk(
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(categ1, categ2)
),
listOf(pocketMiddleware)
)
)
homeStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(categ2.name)).joinBlocking()
// Seems like the most we can test is that an update was made.
coVerify { dataStore.updateData(any()) }
}
@Test
fun `WHEN DeselectPocketStoriesCategory is dispatched THEN persist details in DataStore`() {
val categ1 = PocketRecommendedStoriesCategory("categ1")
val categ2 = PocketRecommendedStoriesCategory("categ2")
val dataStore: DataStore<SelectedPocketStoriesCategories> = mockk(relaxed = true)
val pocketMiddleware = PocketUpdatesMiddleware(TestCoroutineScope(), mockk(), dataStore)
val homeStore = spyk(
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(categ1, categ2)
),
listOf(pocketMiddleware)
)
)
homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(categ2.name)).joinBlocking()
// Seems like the most we can test is that an update was made.
coVerify { dataStore.updateData(any()) }
}
@Test
fun `WHEN persistCategories is called THEN update dataStore`() {
val dataStore: DataStore<SelectedPocketStoriesCategories> = mockk(relaxed = true)
persistSelectedCategories(TestCoroutineScope(), listOf(mockk(relaxed = true)), dataStore)
// Seems like the most we can test is that an update was made.
coVerify { dataStore.updateData(any()) }
}
@Test
fun `WHEN restoreSelectedCategories is called THEN dispatch PocketStoriesCategoriesSelectionsChange with data read from the persistence layer`() {
val persistedSelectedCategory: SelectedPocketStoriesCategory = mockk {
every { name } returns "testCategory"
every { selectionTimestamp } returns 123
}
val persistedSelectedCategories: SelectedPocketStoriesCategories = mockk {
every { valuesList } returns mutableListOf(persistedSelectedCategory)
}
val dataStore: DataStore<SelectedPocketStoriesCategories> = mockk {
every { data } returns flowOf(persistedSelectedCategories)
}
val currentCategories = listOf(mockk<PocketRecommendedStoriesCategory>())
val homeStore = spyk(
HomeFragmentStore(HomeFragmentState())
)
restoreSelectedCategories(
coroutineScope = TestCoroutineScope(),
currentCategories = currentCategories,
store = homeStore,
selectedPocketCategoriesDataStore = dataStore
)
coVerify {
homeStore.dispatch(
HomeFragmentAction.PocketStoriesCategoriesSelectionsChange(
storiesCategories = currentCategories,
categoriesSelected = listOf(
PocketRecommendedStoriesSelectedCategory("testCategory", 123)
)
)
)
}
}
}

View File

@ -35,6 +35,7 @@ object Versions {
const val androidx_paging = "2.1.2"
const val androidx_transition = "1.4.0"
const val androidx_work = "2.5.0"
const val androidx_datastore = "1.0.0"
const val google_material = "1.2.1"
const val mozilla_android_components = AndroidComponents.VERSION
@ -52,6 +53,8 @@ object Versions {
const val google_ads_id_version = "16.0.0"
const val google_play_store_version = "1.8.0"
const val protobuf = "3.11.4" // keep in sync with the version used in AS.
}
@Suppress("unused")
@ -199,8 +202,12 @@ object Deps {
const val androidx_transition = "androidx.transition:transition:${Versions.androidx_transition}"
const val androidx_work_ktx = "androidx.work:work-runtime-ktx:${Versions.androidx_work}"
const val androidx_work_testing = "androidx.work:work-testing:${Versions.androidx_work}"
const val androidx_datastore = "androidx.datastore:datastore:${Versions.androidx_datastore}"
const val google_material = "com.google.android.material:material:${Versions.google_material}"
const val protobuf_javalite = "com.google.protobuf:protobuf-javalite:${Versions.protobuf}"
const val protobuf_compiler = "com.google.protobuf:protoc:${Versions.protobuf}"
const val adjust = "com.adjust.sdk:adjust-android:${Versions.adjust}"
const val installreferrer = "com.android.installreferrer:installreferrer:${Versions.installreferrer}"