Skip to content

Commit

Permalink
For mozilla-mobile#21593 - Persist stories categories selections in a…
Browse files Browse the repository at this point in the history
… Proto DataStore

A fast and easy solution with all the ACID requirements.
Also supports easy migrations if later the data we need persisted changes.
  • Loading branch information
Mugurell authored and mergify[bot] committed Oct 4, 2021
1 parent 565beb8 commit e4489b8
Show file tree
Hide file tree
Showing 10 changed files with 391 additions and 9 deletions.
22 changes: 22 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/org/mozilla/fenix/datastore/DataStores.kt
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,7 +249,9 @@ class HomeFragment : Fragment() {
),
listOf(
PocketUpdatesMiddleware(
lifecycleScope, requireComponents.core.pocketStoriesService
lifecycleScope,
requireComponents.core.pocketStoriesService,
requireContext().pocketStoriesSelectedCategoriesDataStore
)
)
)
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)
)
Expand Down
105 changes: 104 additions & 1 deletion app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
}
)
)
}
}
}
26 changes: 26 additions & 0 deletions app/src/main/proto/selected_pocket_stories_categories.proto
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 35 additions & 4 deletions app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -195,22 +195,23 @@ class HomeFragmentStoreTest {
val filteredStories = listOf(mockk<PocketRecommendedStory>())
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory, anotherStoriesCategory
pocketStoriesCategories = listOf(otherStoriesCategory, anotherStoriesCategory),
pocketStoriesCategoriesSelections = listOf(
PocketRecommendedStoriesSelectedCategory(otherStoriesCategory.name),
)
)
)

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)
}
Expand Down Expand Up @@ -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)
}
}
}

0 comments on commit e4489b8

Please sign in to comment.