For #24220 and #24223: Connect GleanPlumb messages with the new tab ui card.

Co-authored-by: Jonathan Almeida <jalmeida@mozilla.com>
upstream-sync
Jonathan Almeida 2 years ago committed by mergify[bot]
parent 02728bc260
commit f953c5ec94

@ -109,4 +109,10 @@ object FeatureFlags {
* Enables the Unified Search feature.
*/
val unifiedSearchFeature = Config.channel.isNightlyOrDebug
/**
* Enables receiving from the messaging framework.
*/
@Suppress("MayBeConst")
val messagingFeature = false
}

@ -65,6 +65,7 @@ import org.mozilla.fenix.GleanMetrics.SearchDefaultEngine
import org.mozilla.fenix.GleanMetrics.TopSites
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.Core
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricServiceType
import org.mozilla.fenix.components.metrics.MozillaProductDetector
@ -159,6 +160,9 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
GlobalScope.launch(Dispatchers.IO) {
setStartupMetrics(store, settings())
}
if (FeatureFlags.messagingFeature && settings().isExperimentationEnabled) {
components.appStore.dispatch(AppAction.MessagingAction.Restore)
}
}
@CallSuper

@ -1016,18 +1016,22 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
}
}
fun processIntent(intent: Intent): Boolean {
return externalSourceIntentProcessors.any {
it.process(
intent,
navHost.navController,
this.intent
)
}
}
@VisibleForTesting
internal fun getSettings(): Settings = settings()
private fun shouldNavigateToBrowserOnColdStart(savedInstanceState: Bundle?): Boolean {
return isActivityColdStarted(intent, savedInstanceState) &&
!externalSourceIntentProcessors.any {
it.process(
intent,
navHost.navController,
this.intent
)
}
!processIntent(intent)
}
companion object {

@ -25,6 +25,8 @@ import org.mozilla.fenix.components.metrics.GleanMetricsService
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.experiments.createNimbus
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.KeyPairMessageMetadataStorage
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.perf.lazyMonitored
import org.mozilla.geckoview.BuildConfig.MOZ_APP_BUILDID
@ -116,6 +118,15 @@ class Analytics(
FxNimbus.api = api
}
}
val messagingStorage by lazyMonitored {
NimbusMessagingStorage(
context = context,
metadataStorage = KeyPairMessageMetadataStorage(),
gleanPlumb = experiments,
messagingFeature = FxNimbus.features.messaging,
)
}
}
fun isSentryEnabled() = !BuildConfig.SENTRY_TOKEN.isNullOrEmpty()

@ -33,6 +33,7 @@ import org.mozilla.fenix.ext.asRecentTabs
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.filterState
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.state.MessagingMiddleware
import org.mozilla.fenix.ext.sort
import org.mozilla.fenix.home.PocketUpdatesMiddleware
import org.mozilla.fenix.home.blocklist.BlocklistHandler
@ -196,6 +197,7 @@ class Components(private val context: Context) {
val appStartReasonProvider by lazyMonitored { AppStartReasonProvider() }
val startupActivityLog by lazyMonitored { StartupActivityLog() }
val startupStateProvider by lazyMonitored { StartupStateProvider(startupActivityLog, appStartReasonProvider) }
val appStore by lazyMonitored {
val blocklistHandler = BlocklistHandler(settings)
@ -206,7 +208,6 @@ class Components(private val context: Context) {
topSites = core.topSitesStorage.cachedTopSites.sort(),
recentBookmarks = emptyList(),
showCollectionPlaceholder = settings.showCollectionsPlaceholderOnHome,
showSetAsDefaultBrowserCard = settings.shouldShowSetAsDefaultBrowserCard(),
// Provide an initial state for recent tabs to prevent re-rendering on the home screen.
// This will otherwise cause a visual jump as the section gets rendered from no state
// to some state.
@ -222,7 +223,8 @@ class Components(private val context: Context) {
PocketUpdatesMiddleware(
core.pocketStoriesService,
context.pocketStoriesSelectedCategoriesDataStore
)
),
MessagingMiddleware(messagingStorage = analytics.messagingStorage)
)
)
}

@ -16,6 +16,8 @@ import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessagingState
/**
* [Action] implementation related to [AppStore].
@ -61,5 +63,48 @@ sealed class AppAction : Action {
val categoriesSelected: List<PocketRecommendedStoriesSelectedCategory>
) : AppAction()
object RemoveCollectionsPlaceholder : AppAction()
object RemoveSetDefaultBrowserCard : AppAction()
/**
* [Action]s related to interactions with the Messaging Framework.
*/
sealed class MessagingAction : AppAction() {
/**
* Restores the [Message] state from the storage.
*/
object Restore : MessagingAction()
/**
* Evaluates if a new messages should be shown to users.
*/
object Evaluate : MessagingAction()
/**
* Updates [MessagingState.messageToShow] with the given [message].
*/
data class UpdateMessageToShow(val message: Message) : MessagingAction()
/**
* Updates [MessagingState.messageToShow] with the given [message].
*/
object ConsumeMessageToShow : MessagingAction()
/**
* Updates [MessagingState.messages] with the given [messages].
*/
data class UpdateMessages(val messages: List<Message>) : MessagingAction()
/**
* Indicates the given [message] was clicked.
*/
data class MessageClicked(val message: Message) : MessagingAction()
/**
* Indicates the given [message] was shown.
*/
data class MessageDisplayed(val message: Message) : MessagingAction()
/**
* Indicates the given [message] was dismissed.
*/
data class MessageDismissed(val message: Message) : MessagingAction()
}
}

@ -17,6 +17,7 @@ import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.gleanplumb.MessagingState
/**
* Value type that represents the state of the tabs tray.
@ -30,12 +31,12 @@ import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
* @property mode The state of the [HomeFragment] UI.
* @property topSites The list of [TopSite] in the [HomeFragment].
* @property showCollectionPlaceholder If true, shows a placeholder when there are no collections.
* @property showSetAsDefaultBrowserCard If true, shows the default browser card
* @property recentTabs The list of recent [RecentTab] in the [HomeFragment].
* @property recentBookmarks The list of recently saved [BookmarkNode]s to show on the [HomeFragment].
* @property recentHistory The list of [RecentlyVisitedItem]s.
* @property pocketStories The list of currently shown [PocketRecommendedStory]s.
* @property pocketStoriesCategories All [PocketRecommendedStory] categories.
* @property messaging State related messages.
* Also serves as an in memory cache of all stories mapped by category allowing for quick stories filtering.
*/
data class AppState(
@ -46,11 +47,11 @@ data class AppState(
val mode: Mode = Mode.Normal,
val topSites: List<TopSite> = emptyList(),
val showCollectionPlaceholder: Boolean = false,
val showSetAsDefaultBrowserCard: Boolean = false,
val recentTabs: List<RecentTab> = emptyList(),
val recentBookmarks: List<RecentBookmark> = emptyList(),
val recentHistory: List<RecentlyVisitedItem> = emptyList(),
val pocketStories: List<PocketRecommendedStory> = emptyList(),
val pocketStoriesCategories: List<PocketRecommendedStoriesCategory> = emptyList(),
val pocketStoriesCategoriesSelections: List<PocketRecommendedStoriesSelectedCategory> = emptyList()
val pocketStoriesCategoriesSelections: List<PocketRecommendedStoriesSelectedCategory> = emptyList(),
val messaging: MessagingState = MessagingState(),
) : State

@ -14,6 +14,7 @@ import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.gleanplumb.state.MessagingReducer
/**
* Reducer for [AppStore].
@ -30,6 +31,8 @@ internal object AppStoreReducer {
is AppAction.RemoveAllNonFatalCrashes ->
state.copy(nonFatalCrashes = emptyList())
is AppAction.MessagingAction -> MessagingReducer.reduce(state, action)
is AppAction.Change -> state.copy(
collections = action.collections,
mode = action.mode,
@ -60,7 +63,6 @@ internal object AppStoreReducer {
is AppAction.RemoveCollectionsPlaceholder -> {
state.copy(showCollectionPlaceholder = false)
}
is AppAction.RemoveSetDefaultBrowserCard -> state.copy(showSetAsDefaultBrowserCard = false)
is AppAction.RecentTabsChange -> {
val recentSearchGroup = action.recentTabs.find { it is RecentTab.SearchGroup } as RecentTab.SearchGroup?
state.copy(

@ -0,0 +1,60 @@
/* 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.gleanplumb
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDismissed
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDisplayed
/**
* Handles default interactions with the ui of GleanPlumb messages.
*/
class DefaultMessageController(
private val appStore: AppStore,
private val messagingStorage: NimbusMessagingStorage,
private val homeActivity: HomeActivity
) : MessageController {
override fun onMessagePressed(message: Message) {
// Report telemetry event
// This will be covered on https://github.com/mozilla-mobile/fenix/issues/24224
val action = messagingStorage.getMessageAction(message)
handleAction(action)
appStore.dispatch(MessageClicked(message))
}
override fun onMessageDismissed(message: Message) {
// Report telemetry event
// This will be covered on https://github.com/mozilla-mobile/fenix/issues/24224
appStore.dispatch(MessageDismissed(message))
}
override fun onMessageDisplayed(message: Message) {
// Report telemetry event
// This will be covered on https://github.com/mozilla-mobile/fenix/issues/24224
appStore.dispatch(MessageDisplayed(message))
}
@VisibleForTesting
internal fun handleAction(action: String): Intent {
val partialAction = if (action.startsWith("http", ignoreCase = true)) {
"://open?url=${Uri.encode(action)}"
} else {
action
}
val intent =
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEP_LINK_SCHEME}$partialAction".toUri())
homeActivity.processIntent(intent)
return intent
}
}

@ -0,0 +1,29 @@
/* 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.gleanplumb
/* Dummy implementation until we provide full implementation.
* This will covered on https://github.com/mozilla-mobile/fenix/issues/24222
* */
class KeyPairMessageMetadataStorage : MessageMetadataStorage {
override fun getMetadata(): List<Message.Metadata> {
return listOf(
Message.Metadata(
id = "eu-tracking-protection-for-ireland",
displayCount = 0,
pressed = false,
dismissed = false
)
)
}
override fun addMetadata(metadata: Message.Metadata): Message.Metadata {
return metadata
}
@SuppressWarnings("EmptyFunctionBlock")
override fun updateMetadata(metadata: Message.Metadata) {
}
}

@ -0,0 +1,44 @@
/* 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.gleanplumb
import org.mozilla.fenix.nimbus.MessageData
import org.mozilla.fenix.nimbus.StyleData
/**
* A data class that holds a representation of GleanPlum message from Nimbus.
*
* @param id identifies a message as unique.
* @param data Data information provided from Nimbus.
* @param action A strings that represents which action should be performed
* after a message is clicked.
* @param style Indicates how a message should be styled.
* @param triggers A list of strings corresponding to targeting expressions. The message
* will be shown if all expressions `true`.
* @param metadata Metadata that help to identify if a message should shown.
*/
data class Message(
val id: String,
val data: MessageData,
val action: String,
val style: StyleData,
val triggers: List<String>,
val metadata: Metadata
) {
/**
* A data class that holds metadata that help to identify if a message should shown.
*
* @param id identifies a message as unique.
* @param displayCount Indicates how many times a message is displayed.
* @param pressed Indicates if a message has been clicked.
* @param dismissed Indicates if a message has been closed.
*/
data class Metadata(
val id: String,
val displayCount: Int = 0,
val pressed: Boolean = false,
val dismissed: Boolean = false
)
}

@ -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.gleanplumb
/**
* Controls all the interactions with a [Message].
*/
interface MessageController {
/**
* Indicates the provided [message] was pressed by a user.
*/
fun onMessagePressed(message: Message)
/**
* Indicates the provided [message] was dismissed by a user.
*/
fun onMessageDismissed(message: Message)
/**
* Indicates the provided [message] was displayed to a user.
*/
fun onMessageDisplayed(message: Message)
}

@ -0,0 +1,23 @@
/* 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.gleanplumb
interface MessageMetadataStorage {
/**
* Provide all the message metadata saved in the storage.
*/
fun getMetadata(): List<Message.Metadata>
/**
* Given a [metadata] add the message metadata on the storage.
* @return the added message on the [MessageMetadataStorage]
*/
fun addMetadata(metadata: Message.Metadata): Message.Metadata
/**
* Given a [metadata] update the message metadata on the storage.
*/
fun updateMetadata(metadata: Message.Metadata)
}

@ -0,0 +1,24 @@
/* 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.gleanplumb
import mozilla.components.support.base.feature.LifecycleAwareFeature
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
/**
* A message observer that updates the provided.
*/
class MessagingFeature(val store: AppStore) : LifecycleAwareFeature {
override fun start() {
if (FeatureFlags.messagingFeature) {
store.dispatch(MessagingAction.Evaluate)
}
}
override fun stop() = Unit
}

@ -0,0 +1,16 @@
/* 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.gleanplumb
/**
* Represent all the state related to the Messaging framework.
* @param messages Indicates all the available messages.
* @param messageToShow Indicates the message that should be shown to users,
* if it is null means there is not message that is eligible to be shown to users.
*/
data class MessagingState(
val messages: List<Message> = emptyList(),
val messageToShow: Message? = null
)

@ -0,0 +1,174 @@
/* 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.gleanplumb
import android.content.Context
import androidx.annotation.VisibleForTesting
import mozilla.components.support.base.log.logger.Logger
import org.json.JSONObject
import org.mozilla.experiments.nimbus.GleanPlumbInterface
import org.mozilla.experiments.nimbus.GleanPlumbMessageHelper
import org.mozilla.experiments.nimbus.internal.FeatureHolder
import org.mozilla.experiments.nimbus.internal.NimbusException
import org.mozilla.fenix.nimbus.Messaging
import org.mozilla.fenix.nimbus.StyleData
/**
* Provides messages from [messagingFeature] and combine with the metadata store on [metadataStorage].
*/
class NimbusMessagingStorage(
private val context: Context,
private val metadataStorage: MessageMetadataStorage,
private val gleanPlumb: GleanPlumbInterface,
private val messagingFeature: FeatureHolder<Messaging>
) {
private val logger = Logger("MessagingStorage")
private val nimbusFeature = messagingFeature.value()
private val customAttributes: JSONObject
get() = JSONObject()
/**
* Returns a list of available messages descending sorted by their priority.
*/
fun getMessages(): List<Message> {
val nimbusTriggers = nimbusFeature.triggers
val nimbusStyles = nimbusFeature.styles
val nimbusActions = nimbusFeature.actions
val nimbusMessages = nimbusFeature.messages
val defaultStyle = StyleData(context)
val storageMetadata = metadataStorage.getMetadata().associateBy {
it.id
}
return nimbusMessages.mapNotNull { (key, value) ->
val action = sanitizeAction(value.action, nimbusActions) ?: return@mapNotNull null
Message(
id = key,
data = value,
action = action,
style = nimbusStyles[value.style] ?: defaultStyle,
metadata = storageMetadata[key] ?: addMetadata(key),
triggers = sanitizeTriggers(value.trigger, nimbusTriggers) ?: return@mapNotNull null
)
}.filter {
it.data.maxDisplayCount >= it.metadata.displayCount &&
!it.metadata.dismissed &&
!it.metadata.pressed
}.sortedByDescending {
it.style.priority
}
}
/**
* Returns the next higher priority message which all their triggers are true.
*/
fun getNextMessage(availableMessages: List<Message>): Message? {
val helper = gleanPlumb.createMessageHelper(customAttributes)
var message = availableMessages.firstOrNull {
isMessageEligible(it, helper)
} ?: return null
if (isMessageUnderExperiment(message, nimbusFeature.messageUnderExperiment)) {
messagingFeature.recordExposure()
if (message.data.isControl) {
message = availableMessages.firstOrNull {
!it.data.isControl && isMessageEligible(it, helper)
} ?: return null
}
}
return message
}
/**
* Returns a valid action for the provided [message].
*/
fun getMessageAction(message: Message): String {
val helper = gleanPlumb.createMessageHelper(customAttributes)
val uuid = helper.getUuid(message.action)
return helper.stringFormat(message.action, uuid)
}
/**
* Updated the provided [metadata] in the storage.
*/
fun updateMetadata(metadata: Message.Metadata) {
metadataStorage.updateMetadata(metadata)
}
@VisibleForTesting
internal fun sanitizeAction(
unsafeAction: String,
nimbusActions: Map<String, String>
): String? {
return if (unsafeAction.startsWith("http")) {
unsafeAction
} else {
val safeAction = nimbusActions[unsafeAction]
if (safeAction.isNullOrBlank() || safeAction.isEmpty()) {
return null
}
safeAction
}
}
@VisibleForTesting
internal fun sanitizeTriggers(
unsafeTriggers: List<String>,
nimbusTriggers: Map<String, String>
): List<String>? {
return unsafeTriggers.map {
val safeTrigger = nimbusTriggers[it]
if (safeTrigger.isNullOrBlank() || safeTrigger.isEmpty()) {
return null
}
safeTrigger
}
}
@VisibleForTesting
internal fun isMessageUnderExperiment(message: Message, expression: String?): Boolean {
return when {
expression.isNullOrBlank() -> {
false
}
expression.endsWith("-") -> {
message.id.startsWith(expression)
}
else -> {
message.id == expression
}
}
}
@VisibleForTesting
internal fun isMessageEligible(
message: Message,
helper: GleanPlumbMessageHelper
): Boolean {
return message.triggers.all { condition ->
try {
helper.evalJexl(condition)
} catch (e: NimbusException.EvaluationException) {
// Report to glean as malformed message
// Will be addressed on https://github.com/mozilla-mobile/fenix/issues/24224
logger.info("Unable to evaluate $condition")
false
}
}
}
private fun addMetadata(id: String): Message.Metadata {
// This will be improve on https://github.com/mozilla-mobile/fenix/issues/24222
return metadataStorage.addMetadata(
Message.Metadata(
id = id,
)
)
}
}

@ -0,0 +1,139 @@
/* 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.gleanplumb.state
import androidx.annotation.VisibleForTesting
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.ConsumeMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Evaluate
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDismissed
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDisplayed
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Restore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessages
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
typealias AppStoreMiddlewareContext = MiddlewareContext<AppState, AppAction>
class MessagingMiddleware(
private val messagingStorage: NimbusMessagingStorage
) : Middleware<AppState, AppAction> {
override fun invoke(
context: AppStoreMiddlewareContext,
next: (AppAction) -> Unit,
action: AppAction
) {
when (action) {
is Restore -> {
val messages = messagingStorage.getMessages()
context.dispatch(UpdateMessages(messages))
}
is Evaluate -> {
val message = messagingStorage.getNextMessage(context.state.messaging.messages)
if (message != null) {
context.dispatch(UpdateMessageToShow(message))
} else {
context.dispatch(ConsumeMessageToShow)
}
}
is MessageClicked -> onMessageClicked(action.message, context)
is MessageDismissed -> onMessageDismissed(context, action.message)
is MessageDisplayed -> onMessagedDisplayed(action.message, context)
}
next(action)
}
@VisibleForTesting
internal fun onMessagedDisplayed(
oldMessage: Message,
context: AppStoreMiddlewareContext
) {
val newMetadata = oldMessage.metadata.copy(
displayCount = oldMessage.metadata.displayCount + 1
)
val newMessage = oldMessage.copy(
metadata = newMetadata
)
val newMessages = if (newMetadata.displayCount < oldMessage.data.maxDisplayCount) {
updateMessage(context, oldMessage, newMessage)
} else {
consumeMessageToShowIfNeeded(context, oldMessage)
removeMessage(context, oldMessage)
}
context.dispatch(UpdateMessages(newMessages))
messagingStorage.updateMetadata(newMetadata)
}
@VisibleForTesting
internal fun onMessageDismissed(
context: AppStoreMiddlewareContext,
message: Message
) {
val newMessages = removeMessage(context, message)
val updatedMetadata = message.metadata.copy(dismissed = true)
messagingStorage.updateMetadata(updatedMetadata)
context.dispatch(UpdateMessages(newMessages))
consumeMessageToShowIfNeeded(context, message)
}
@VisibleForTesting
internal fun onMessageClicked(
message: Message,
context: AppStoreMiddlewareContext
) {
// Update Nimbus storage.
val updatedMetadata = message.metadata.copy(pressed = true)
messagingStorage.updateMetadata(updatedMetadata)
// Update app state.
val newMessages = removeMessage(context, message)
context.dispatch(UpdateMessages(newMessages))
consumeMessageToShowIfNeeded(context, message)
}
@VisibleForTesting
internal fun consumeMessageToShowIfNeeded(
context: AppStoreMiddlewareContext,
message: Message
) {
if (context.state.messaging.messageToShow?.id == message.id) {
context.dispatch(ConsumeMessageToShow)
}
}
@VisibleForTesting
internal fun removeMessage(
context: AppStoreMiddlewareContext,
message: Message
): List<Message> {
return context.state.messaging.messages.filter { it.id != message.id }
}
@VisibleForTesting
internal fun updateMessage(
context: AppStoreMiddlewareContext,
oldMessage: Message,
updatedMessage: Message
): List<Message> {
val actualMessageToShow = context.state.messaging.messageToShow
if (actualMessageToShow?.id == oldMessage.id) {
context.dispatch(UpdateMessageToShow(updatedMessage))
}
return removeMessage(context, oldMessage) + updatedMessage
}
}

@ -0,0 +1,42 @@
/* 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.gleanplumb.state
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.ConsumeMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessages
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.MessagingState
/**
* Reducer for [MessagingState].
*/
internal object MessagingReducer {
fun reduce(state: AppState, action: AppAction.MessagingAction): AppState = when (action) {
is UpdateMessageToShow -> {
state.copy(
messaging = state.messaging.copy(
messageToShow = action.message
)
)
}
is UpdateMessages -> {
state.copy(
messaging = state.messaging.copy(
messages = action.messages
)
)
}
is ConsumeMessageToShow -> {
state.copy(
messaging = state.messaging.copy(
messageToShow = null
)
)
}
else -> state
}
}

@ -97,6 +97,8 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.DefaultMessageController
import org.mozilla.fenix.gleanplumb.MessagingFeature
import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow
import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
@ -172,6 +174,7 @@ class HomeFragment : Fragment() {
private lateinit var currentMode: CurrentMode
private val topSitesFeature = ViewBoundFeatureWrapper<TopSitesFeature>()
private val messagingFeature = ViewBoundFeatureWrapper<MessagingFeature>()
private val recentTabsListFeature = ViewBoundFeatureWrapper<RecentTabsListFeature>()
private val recentBookmarksFeature = ViewBoundFeatureWrapper<RecentBookmarksFeature>()
private val historyMetadataFeature = ViewBoundFeatureWrapper<RecentVisitsFeature>()
@ -239,6 +242,16 @@ class HomeFragment : Fragment() {
}
}
if (requireContext().settings().isExperimentationEnabled) {
messagingFeature.set(
feature = MessagingFeature(
store = requireComponents.appStore,
),
owner = viewLifecycleOwner,
view = binding.root
)
}
if (requireContext().settings().showTopSitesFeature) {
topSitesFeature.set(
feature = TopSitesFeature(
@ -298,6 +311,11 @@ class HomeFragment : Fragment() {
settings = components.settings,
engine = components.core.engine,
metrics = components.analytics.metrics,
messageController = DefaultMessageController(
appStore = components.appStore,
messagingStorage = components.analytics.messagingStorage,
homeActivity = activity
),
store = store,
tabCollectionStorage = components.core.tabCollectionStorage,
addTabUseCase = components.useCases.tabsUseCases.addTab,

@ -18,6 +18,7 @@ import mozilla.components.feature.top.sites.TopSite
import mozilla.components.ui.widgets.WidgetSiteItemView
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.BottomSpacerViewHolder
import org.mozilla.fenix.home.TopPlaceholderViewHolder
import org.mozilla.fenix.home.pocket.PocketCategoriesViewHolder
@ -35,7 +36,7 @@ import org.mozilla.fenix.home.sessioncontrol.viewholders.CustomizeHomeButtonView
import org.mozilla.fenix.home.sessioncontrol.viewholders.NoCollectionsMessageViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.PrivateBrowsingDescriptionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.TabInCollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.ExperimentDefaultBrowserCardViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.MessageCardViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingFinishViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.onboarding.OnboardingManualSignInViewHolder
@ -142,7 +143,12 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) {
object OnboardingManualSignIn : AdapterItem(OnboardingManualSignInViewHolder.LAYOUT_ID)
object ExperimentDefaultBrowserCard : AdapterItem(ExperimentDefaultBrowserCardViewHolder.LAYOUT_ID)
data class NimbusMessageCard(
val message: Message
) : AdapterItem(MessageCardViewHolder.LAYOUT_ID) {
override fun sameAs(other: AdapterItem) =
other is NimbusMessageCard && message.id == other.message.id
}
object OnboardingThemePicker : AdapterItem(OnboardingThemePickerViewHolder.LAYOUT_ID)
object OnboardingTrackingProtection :
@ -283,7 +289,7 @@ class SessionControlAdapter(
OnboardingToolbarPositionPickerViewHolder.LAYOUT_ID -> OnboardingToolbarPositionPickerViewHolder(
view
)
ExperimentDefaultBrowserCardViewHolder.LAYOUT_ID -> ExperimentDefaultBrowserCardViewHolder(view, interactor)
MessageCardViewHolder.LAYOUT_ID -> MessageCardViewHolder(view, interactor)
RecentTabsHeaderViewHolder.LAYOUT_ID -> RecentTabsHeaderViewHolder(view, interactor)
RecentBookmarksHeaderViewHolder.LAYOUT_ID -> RecentBookmarksHeaderViewHolder(view, interactor)
RecentVisitsHeaderViewHolder.LAYOUT_ID -> RecentVisitsHeaderViewHolder(
@ -345,6 +351,9 @@ class SessionControlAdapter(
is TopSitePagerViewHolder -> {
holder.bind((item as AdapterItem.TopSitePager).topSites)
}
is MessageCardViewHolder -> {
holder.bind((item as AdapterItem.NimbusMessageCard).message)
}
is CollectionViewHolder -> {
val (collection, expanded) = item as AdapterItem.CollectionItem
holder.bindSession(collection, expanded)

@ -46,8 +46,9 @@ import org.mozilla.fenix.components.metrics.MetricsUtils
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.metrics
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.openSetDefaultBrowserOption
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessageController
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.Mode
@ -173,14 +174,19 @@ interface SessionControlController {
fun handleMenuOpened()
/**
* @see [ExperimentCardInteractor.onSetDefaultBrowserClicked]
* @see [MessageCardInteractor.onMessageClicked]
*/
fun handleSetDefaultBrowser()
fun handleMessageClicked(message: Message)
/**
* @see [ExperimentCardInteractor.onCloseExperimentCardClicked]
* @see [MessageCardInteractor.onMessageClosedClicked]
*/
fun handleCloseExperimentCard()
fun handleMessageClosed(message: Message)
/**
* @see [MessageCardInteractor.onMessageDisplayed]
*/
fun handleMessageDisplayed(message: Message)
/**
* @see [TabSessionInteractor.onPrivateModeButtonClicked]
@ -209,6 +215,7 @@ class DefaultSessionControlController(
private val settings: Settings,
private val engine: Engine,
private val metrics: MetricController,
private val messageController: MessageController,
private val store: BrowserStore,
private val tabCollectionStorage: TabCollectionStorage,
private val addTabUseCase: TabsUseCases.AddNewTabUseCase,
@ -606,14 +613,16 @@ class DefaultSessionControlController(
navController.nav(R.id.homeFragment, directions)
}
override fun handleSetDefaultBrowser() {
settings.userDismissedExperimentCard = true
activity.openSetDefaultBrowserOption()
override fun handleMessageClicked(message: Message) {
messageController.onMessagePressed(message)
}
override fun handleMessageClosed(message: Message) {
messageController.onMessageDismissed(message)
}
override fun handleCloseExperimentCard() {
settings.userDismissedExperimentCard = true
appStore.dispatch(AppAction.RemoveSetDefaultBrowserCard)
override fun handleMessageDisplayed(message: Message) {
messageController.onMessageDisplayed(message)
}
override fun handlePrivateModeButtonClicked(

@ -10,6 +10,7 @@ import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketStoriesController
import org.mozilla.fenix.home.pocket.PocketStoriesInteractor
@ -225,16 +226,21 @@ interface TopSiteInteractor {
fun onTopSiteMenuOpened()
}
interface ExperimentCardInteractor {
interface MessageCardInteractor {
/**
* Called when set default browser button is clicked
* Called when a [Message]'s button is clicked
*/
fun onSetDefaultBrowserClicked()
fun onMessageClicked(message: Message)
/**
* Called when close button on experiment card
* Called when close button on a [Message] card.
*/
fun onCloseExperimentCardClicked()
fun onMessageClosedClicked(message: Message)
/**
* Called when close button on a [Message] card.
*/
fun onMessageDisplayed(message: Message)
}
/**
@ -255,7 +261,7 @@ class SessionControlInteractor(
TopSiteInteractor,
TabSessionInteractor,
ToolbarInteractor,
ExperimentCardInteractor,
MessageCardInteractor,
RecentTabInteractor,
RecentBookmarksInteractor,
RecentVisitsInteractor,
@ -362,14 +368,6 @@ class SessionControlInteractor(
controller.handleMenuOpened()
}
override fun onSetDefaultBrowserClicked() {
controller.handleSetDefaultBrowser()
}
override fun onCloseExperimentCardClicked() {
controller.handleCloseExperimentCard()
}
override fun onRecentTabClicked(tabId: String) {
recentTabController.handleRecentTabClicked(tabId)
}
@ -447,4 +445,16 @@ class SessionControlInteractor(
override fun reportSessionMetrics(state: AppState) {
controller.handleReportSessionMetrics(state)
}
override fun onMessageClicked(message: Message) {
controller.handleMessageClicked(message)
}
override fun onMessageClosedClicked(message: Message) {
controller.handleMessageClosed(message)
}
override fun onMessageDisplayed(message: Message) {
controller.handleMessageDisplayed(message)
}
}

@ -15,6 +15,7 @@ import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.home.Mode
@ -36,7 +37,7 @@ internal fun normalModeAdapterItems(
expandedCollections: Set<Long>,
recentBookmarks: List<RecentBookmark>,
showCollectionsPlaceholder: Boolean,
showSetAsDefaultBrowserCard: Boolean,
nimbusMessageCard: Message? = null,
recentTabs: List<RecentTab>,
recentVisits: List<RecentlyVisitedItem>,
pocketStories: List<PocketRecommendedStory>
@ -47,8 +48,8 @@ internal fun normalModeAdapterItems(
// Add a synchronous, unconditional and invisible placeholder so home is anchored to the top when created.
items.add(AdapterItem.TopPlaceholderItem)
if (showSetAsDefaultBrowserCard) {
items.add(AdapterItem.ExperimentDefaultBrowserCard)
nimbusMessageCard?.let {
items.add(AdapterItem.NimbusMessageCard(it))
}
if (settings.showTopSitesFeature && topSites.isNotEmpty()) {
@ -157,7 +158,7 @@ private fun AppState.toAdapterList(settings: Settings): List<AdapterItem> = when
expandedCollections,
recentBookmarks,
showCollectionPlaceholder,
showSetAsDefaultBrowserCard,
messaging.messageToShow,
recentTabs,
recentHistory,
pocketStories

@ -1,37 +0,0 @@
/* 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.home.sessioncontrol.viewholders.onboarding
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.ExperimentDefaultBrowserBinding
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
class ExperimentDefaultBrowserCardViewHolder(
view: View,
private val interactor: SessionControlInteractor
) : RecyclerView.ViewHolder(view) {
init {
val binding = ExperimentDefaultBrowserBinding.bind(view)
binding.setDefaultBrowser.setOnClickListener {
interactor.onSetDefaultBrowserClicked()
}
binding.close.apply {
increaseTapArea(CLOSE_BUTTON_EXTRA_DPS)
setOnClickListener {
interactor.onCloseExperimentCardClicked()
}
}
}
companion object {
internal const val LAYOUT_ID = R.layout.experiment_default_browser
private const val CLOSE_BUTTON_EXTRA_DPS = 38
}
}

@ -0,0 +1,57 @@
/* 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.home.sessioncontrol.viewholders.onboarding
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.NimbusMessageCardBinding
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
class MessageCardViewHolder(
view: View,
private val interactor: SessionControlInteractor
) : RecyclerView.ViewHolder(view) {
fun bind(message: Message) {
val binding = NimbusMessageCardBinding.bind(itemView)
if (message.data.title.isNullOrBlank()) {
binding.titleText.isVisible = false
} else {
binding.titleText.text = message.data.title
}
binding.descriptionText.text = message.data.text
if (message.data.buttonLabel.isNullOrBlank()) {
binding.messageButton.isVisible = false
binding.experimentCard.setOnClickListener {
interactor.onMessageClicked(message)
}
} else {
binding.messageButton.text = message.data.buttonLabel
binding.messageButton.setOnClickListener {
interactor.onMessageClicked(message)
}
}
binding.close.apply {
increaseTapArea(CLOSE_BUTTON_EXTRA_DPS)
setOnClickListener {
interactor.onMessageClosedClicked(message)
}
}
interactor.onMessageDisplayed(message)
}
companion object {
internal const val LAYOUT_ID = R.layout.nimbus_message_card
private const val CLOSE_BUTTON_EXTRA_DPS = 38
}
}

@ -311,24 +311,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = false
)
/**
* Shows if the user has chosen to close the set default browser experiment card
* on home screen or has clicked the set as default browser button.
*/
var userDismissedExperimentCard by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_experiment_card_home),
default = false
)
/**
* Shows if the set default browser experiment card should be shown on home screen.
*/
fun shouldShowSetAsDefaultBrowserCard(): Boolean {
return isDefaultBrowserMessageLocation(MessageSurfaceId.HOMESCREEN_BANNER) &&
!userDismissedExperimentCard &&
numberOfAppLaunches > APP_LAUNCHES_TO_SHOW_DEFAULT_BROWSER_CARD
}
private val defaultBrowserFeature: DefaultBrowserMessage by lazy {
FxNimbus.features.defaultBrowserMessage.value()
}
@ -1212,7 +1194,7 @@ class Settings(private val appContext: Context) : PreferencesHolder {
)
private val homescreenSections: Map<HomeScreenSection, Boolean> by lazy {
FxNimbus.features.homescreen.value().sectionsEnabled
FxNimbus.features.homescreen.value(appContext).sectionsEnabled
}
var historyMetadataUIFeature by lazyFeatureFlagPreference(

@ -10,6 +10,18 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/home_item_horizontal_margin">
<TextView
android:id="@+id/title_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
tools:text="Title"
android:textAppearance="@style/Header16TextStyle"
app:layout_constraintBottom_toTopOf="@id/description_text"
app:layout_constraintEnd_toStartOf="@id/close"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/close"
android:layout_width="10dp"
@ -26,16 +38,15 @@
android:id="@+id/description_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/default_browser_experiment_card_text"
android:textAppearance="@style/Body14TextStyle"
app:layout_constraintBottom_toTopOf="@id/set_default_browser"
app:layout_constraintBottom_toTopOf="@id/message_button"
app:layout_constraintEnd_toStartOf="@id/close"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/set_default_browser"
android:id="@+id/message_button"
style="@style/PositiveButton"
android:layout_height="36dp"
android:background="@drawable/rounded_button_background"

@ -270,9 +270,6 @@
<string name="pref_key_open_next_tab_desktop_mode" translatable="false">pref_key_open_next_tab_desktop_mode</string>
<!-- Set default browser experiment card-->
<string name="pref_key_experiment_card_home" translatable="false">pref_key_experiment_card_home</string>
<!-- Secret Info Setting Keys -->
<string name="pref_key_secret_debug_info" translatable="false">pref_key_secret_debug_info</string>

@ -16,6 +16,8 @@ import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.pocket.PocketRecommendedStory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
@ -23,6 +25,7 @@ import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.filterOut
import org.mozilla.fenix.ext.components
@ -71,7 +74,6 @@ class AppStoreTest {
mode = currentMode.getCurrentMode(),
topSites = emptyList(),
showCollectionPlaceholder = true,
showSetAsDefaultBrowserCard = true,
recentTabs = emptyList()
)
@ -92,6 +94,16 @@ class AppStoreTest {
assertEquals(Mode.Normal, appStore.state.mode)
}
@Test
fun `GIVEN a new value for messageToShow WHEN NimbusMessageChange is called THEN update the current value`() =
runBlocking {
assertNull(appStore.state.messaging.messageToShow)
appStore.dispatch(UpdateMessageToShow(mockk())).join()
assertNotNull(appStore.state.messaging.messageToShow)
}
@Test
fun `Test changing the collections in AppStore`() = runBlocking {
assertEquals(0, appStore.state.collections.size)

@ -0,0 +1,109 @@
/* 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.gleanplumb
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDisplayed
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.nimbus.MessageData
@RunWith(FenixRobolectricTestRunner::class)
class DefaultMessageControllerTest {
private val activity: HomeActivity = mockk(relaxed = true)
private val storageNimbus: NimbusMessagingStorage = mockk(relaxed = true)
private lateinit var controller: DefaultMessageController
private val store: AppStore = mockk(relaxed = true)
@Before
fun setup() {
controller = DefaultMessageController(
messagingStorage = storageNimbus,
appStore = store,
homeActivity = activity
)
}
@Test
fun `WHEN calling onMessagePressed THEN update the store and handle the action`() {
val customController = spyk(controller)
every { customController.handleAction(any()) } returns mockk()
val message = mockMessage()
customController.onMessagePressed(message)
verify { customController.handleAction(any()) }
verify { store.dispatch(MessageClicked(message)) }
}
@Test
fun `GIVEN an URL WHEN calling handleAction THEN process the intent with an open uri`() {
val intent = controller.handleAction("http://mozilla.org")
verify { activity.processIntent(any()) }
val encodedUrl = Uri.encode("http://mozilla.org")
assertEquals(
"${BuildConfig.DEEP_LINK_SCHEME}://open?url=$encodedUrl",
intent.data.toString()
)
}
@Test
fun `GIVEN an deeplink WHEN calling handleAction THEN process the intent with an deeplink uri`() {
val intent = controller.handleAction("://settings_privacy")
verify { activity.processIntent(any()) }
assertEquals("${BuildConfig.DEEP_LINK_SCHEME}://settings_privacy", intent.data.toString())
}
@Test
fun `WHEN calling onMessageDismissed THEN report to the messageManager`() {
val message = mockMessage()
controller.onMessageDismissed(message)
verify { store.dispatch(AppAction.MessagingAction.MessageDismissed(message)) }
}
@Test
fun `WHEN calling onMessageDisplayed THEN report to the messageManager`() {
val message = mockMessage()
controller.onMessageDisplayed(message)
verify { store.dispatch(MessageDisplayed(message)) }
}
private fun mockMessage() = Message(
id = "id",
data = MessageData(_context = testContext),
style = mockk(),
action = "action",
triggers = emptyList(),
metadata = Message.Metadata(
id = "id",
displayCount = 0,
pressed = false,
dismissed = false
)
)
}

@ -0,0 +1,46 @@
/* 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.gleanplumb
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.spyk
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.mock
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.Config
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
class MessagingFeatureTest {
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test
fun `WHEN start is called THEN evaluate messages`() {
val store: AppStore = spyk(AppStore())
val binding = MessagingFeature(store)
mockkObject(FeatureFlags)
every { FeatureFlags.messagingFeature } returns true
binding.start()
store.dispatch(UpdateMessageToShow(mock()))
store.waitUntilIdle()
verify { store.dispatch(MessagingAction.Evaluate) }
unmockkObject(Config)
}
}

@ -0,0 +1,487 @@
/* 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.gleanplumb
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.experiments.nimbus.GleanPlumbInterface
import org.mozilla.experiments.nimbus.GleanPlumbMessageHelper
import org.mozilla.experiments.nimbus.internal.FeatureHolder
import org.mozilla.experiments.nimbus.internal.NimbusException
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.nimbus.MessageData
import org.mozilla.fenix.nimbus.Messaging
import org.mozilla.fenix.nimbus.StyleData
@RunWith(FenixRobolectricTestRunner::class)
class NimbusMessagingStorageTest {
private val activity: HomeActivity = mockk(relaxed = true)
private val storageNimbus: NimbusMessagingStorage = mockk(relaxed = true)
private lateinit var storage: NimbusMessagingStorage
private lateinit var metadataStorage: MessageMetadataStorage
private lateinit var gleanPlumb: GleanPlumbInterface
private lateinit var messagingFeature: FeatureHolder<Messaging>
private lateinit var messaging: Messaging
@Before
fun setup() {
gleanPlumb = mockk(relaxed = true)
metadataStorage = mockk(relaxed = true)
messagingFeature = createMessagingFeature()
every { metadataStorage.getMetadata() } returns listOf(Message.Metadata(id = "message-1"))
storage = NimbusMessagingStorage(
testContext,
metadataStorage,
gleanPlumb,
messagingFeature
)
}
@Test
fun `WHEN calling getMessages THEN provide a list of available messages`() {
val message = storage.getMessages().first()
assertEquals("message-1", message.id)
assertEquals("message-1", message.metadata.id)
}
@Test
fun `WHEN calling getMessages THEN provide a list of sorted messages by priority`() {
val messages = mapOf(
"low-message" to createMessageData(style = "low-priority"),
"high-message" to createMessageData(style = "high-priority"),
"medium-message" to createMessageData(style = "medium-priority"),
)
val styles = mapOf(
"high-priority" to createStyle(priority = 100),
"medium-priority" to createStyle(priority = 50),
"low-priority" to createStyle(priority = 1)
)
val metadataStorage: MessageMetadataStorage = mockk(relaxed = true)
val messagingFeature = createMessagingFeature(
styles = styles,
messages = messages
)
every { metadataStorage.getMetadata() } returns listOf(Message.Metadata(id = "message-1"))
val storage = NimbusMessagingStorage(
testContext,
metadataStorage,
gleanPlumb,
messagingFeature
)
val results = storage.getMessages()
assertEquals("high-message", results[0].id)
assertEquals("medium-message", results[1].id)
assertEquals("low-message", results[2].id)
}
@Test
fun `GIVEN pressed message WHEN calling getMessages THEN filter out the pressed message`() {
val metadataList = listOf(
Message.Metadata(id = "pressed-message", pressed = true),
Message.Metadata(id = "normal-message", pressed = false)
)
val messages = mapOf(
"pressed-message" to createMessageData(style = "high-priority"),
"normal-message" to createMessageData(style = "high-priority"),
)
val styles = mapOf(
"high-priority" to createStyle(priority = 100),
)
val metadataStorage: MessageMetadataStorage = mockk(relaxed = true)
val messagingFeature = createMessagingFeature(
styles = styles,
messages = messages
)
every { metadataStorage.getMetadata() } returns metadataList
val storage = NimbusMessagingStorage(
testContext,
metadataStorage,
gleanPlumb,
messagingFeature
)
val results = storage.getMessages()
assertEquals(1, results.size)
assertEquals("normal-message", results[0].id)
}
@Test
fun `GIVEN dismissed message WHEN calling getMessages THEN filter out the dismissed message`() {
val metadataList = listOf(
Message.Metadata(id = "dismissed-message", dismissed = true),
Message.Metadata(id = "normal-message", dismissed = false)
)
val messages = mapOf(
"dismissed-message" to createMessageData(style = "high-priority"),
"normal-message" to createMessageData(style = "high-priority"),
)
val styles = mapOf(
"high-priority" to createStyle(priority = 100),
)
val metadataStorage: MessageMetadataStorage = mockk(relaxed = true)
val messagingFeature = createMessagingFeature(
styles = styles,
messages = messages
)
every { metadataStorage.getMetadata() } returns metadataList
val storage = NimbusMessagingStorage(
testContext,
metadataStorage,
gleanPlumb,
messagingFeature
)
val results = storage.getMessages()
assertEquals(1, results.size)
assertEquals("normal-message", results[0].id)
}
@Test
fun `GIVEN a message that the maxDisplayCount WHEN calling getMessages THEN filter out the message`() {
val metadataList = listOf(
Message.Metadata(id = "shown-many-times-message", displayCount = 10),
Message.Metadata(id = "normal-message", displayCount = 0)
)
val messages = mapOf(
"shown-many-times-message" to createMessageData(
style = "high-priority",
maxDisplayCount = 2
),
"normal-message" to createMessageData(style = "high-priority"),
)
val styles = mapOf(
"high-priority" to createStyle(priority = 100),
)
val metadataStorage: MessageMetadataStorage = mockk(relaxed = true)
val messagingFeature = createMessagingFeature(
styles = styles,
messages = messages
)
every { metadataStorage.getMetadata() } returns metadataList
val storage = NimbusMessagingStorage(
testContext,
metadataStorage,
gleanPlumb,
messagingFeature
)
val results = storage.getMessages()
assertEquals(1, results.size)
assertEquals("normal-message", results[0].id)
}
@Test
fun `GIVEN a malformed message WHEN calling getMessages THEN provide a list of messages ignoring the malformed one`() {
val messages = storage.getMessages()
val firstMessage = messages.first()
assertEquals("message-1", firstMessage.id)
assertEquals("message-1", firstMessage.metadata.id)
assertTrue(messages.size == 1)
}
@Test
fun `GIVEN a malformed action WHEN calling sanitizeAction THEN return null`() {
val actionsMap = mapOf("action-1" to "action-1-url")
val notFoundAction = storage.sanitizeAction("no-found-action", actionsMap)
val emptyAction = storage.sanitizeAction("", actionsMap)
val blankAction = storage.sanitizeAction(" ", actionsMap)
assertNull(notFoundAction)
assertNull(emptyAction)
assertNull(blankAction)
}
@Test
fun `WHEN calling updateMetadata THEN delegate to metadataStorage`() {
storage.updateMetadata(mockk())
verify { metadataStorage.updateMetadata(any()) }
}
@Test
fun `GIVEN a valid action WHEN calling sanitizeAction THEN return the action`() {
val actionsMap = mapOf("action-1" to "action-1-url")
val validAction = storage.sanitizeAction("action-1", actionsMap)
assertEquals("action-1-url", validAction)
}
@Test
fun `GIVEN a trigger action WHEN calling sanitizeTriggers THEN return null`() {
val triggersMap = mapOf("trigger-1" to "trigger-1-expression")
val notFoundTrigger = storage.sanitizeTriggers(listOf("no-found-trigger"), triggersMap)
val emptyTrigger = storage.sanitizeTriggers(listOf(""), triggersMap)
val blankTrigger = storage.sanitizeTriggers(listOf(" "), triggersMap)
assertNull(notFoundTrigger)
assertNull(emptyTrigger)
assertNull(blankTrigger)
}
@Test
fun `GIVEN a valid trigger WHEN calling sanitizeAction THEN return the trigger`() {
val triggersMap = mapOf("trigger-1" to "trigger-1-expression")
val validTrigger = storage.sanitizeTriggers(listOf("trigger-1"), triggersMap)
assertEquals(listOf("trigger-1-expression"), validTrigger)
}
@Test
fun `GIVEN a null or black expression WHEN calling isMessageUnderExperiment THEN return false`() {
val message = Message(
"id", mockk(),
action = "action",
mock(),
emptyList(),
Message.Metadata("id")
)
val result = storage.isMessageUnderExperiment(message, null)
assertFalse(result)
}
@Test
fun `GIVEN messages id that ends with - WHEN calling isMessageUnderExperiment THEN return true`() {
val message = Message(
"end-", mockk(),
action = "action",
mock(),
emptyList(),
Message.Metadata("end-")
)
val result = storage.isMessageUnderExperiment(message, "end-")
assertTrue(result)
}
@Test
fun `GIVEN message under experiment WHEN calling isMessageUnderExperiment THEN return true`() {
val message = Message(
"same-id", mockk(),
action = "action",
mock(),
emptyList(),
Message.Metadata("same-id")
)
val result = storage.isMessageUnderExperiment(message, "same-id")
assertTrue(result)
}
@Test
fun `GIVEN an eligible message WHEN calling isMessageEligible THEN return true`() {
val helper: GleanPlumbMessageHelper = mockk(relaxed = true)
val message = Message(
"same-id", mockk(),
action = "action",
mock(),
listOf("trigger"),
Message.Metadata("same-id")
)
every { helper.evalJexl(any()) } returns true
val result = storage.isMessageEligible(message, helper)
assertTrue(result)
}
@Test
fun `GIVEN a malformed trigger WHEN calling isMessageEligible THEN return false`() {
val helper: GleanPlumbMessageHelper = mockk(relaxed = true)
val message = Message(
"same-id", mockk(),
action = "action",
mock(),
listOf("trigger"),
Message.Metadata("same-id")
)
every { helper.evalJexl(any()) } throws NimbusException.EvaluationException("")
val result = storage.isMessageEligible(message, helper)
assertFalse(result)
}
@Test
fun `GIVEN none available messages are eligible WHEN calling getNextMessage THEN return null`() {
val spiedStorage = spyk(storage)
val message = Message(
"same-id", mockk(),
action = "action",
mock(),
listOf("trigger"),
Message.Metadata("same-id")
)
every { spiedStorage.isMessageEligible(any(), any()) } returns false
val result = spiedStorage.getNextMessage(listOf(message))
assertNull(result)
}
@Test
fun `GIVEN an eligible message WHEN calling getNextMessage THEN return the message`() {
val spiedStorage = spyk(storage)
val message = Message(
"same-id", mockk(),
action = "action",
mock(),
listOf("trigger"),
Message.Metadata("same-id")
)
every { spiedStorage.isMessageEligible(any(), any()) } returns true
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns false
val result = spiedStorage.getNextMessage(listOf(message))
assertEquals(message.id, result!!.id)
}
@Test
fun `GIVEN a message under experiment WHEN calling getNextMessage THEN call recordExposure`() {
val spiedStorage = spyk(storage)
val messageData: MessageData = mockk(relaxed = true)
every { messageData.isControl } returns false
val message = Message(
"same-id",
messageData,
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
every { spiedStorage.isMessageEligible(any(), any()) } returns true
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns true
val result = spiedStorage.getNextMessage(listOf(message))
verify { messagingFeature.recordExposure() }
assertEquals(message.id, result!!.id)
}
@Test
fun `GIVEN a control message WHEN calling getNextMessage THEN return the next eligible message`() {
val spiedStorage = spyk(storage)
val messageData: MessageData = mockk(relaxed = true)
val controlMessageData: MessageData = mockk(relaxed = true)
every { messageData.isControl } returns false
every { controlMessageData.isControl } returns true
val message = Message(
"id",
messageData,
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val controlMessage = Message(
"control-id",
controlMessageData,
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
every { spiedStorage.isMessageEligible(any(), any()) } returns true
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns true
val result = spiedStorage.getNextMessage(listOf(controlMessage, message))
verify { messagingFeature.recordExposure() }
assertEquals(message.id, result!!.id)
}
private fun createMessageData(
action: String = "action-1",
style: String = "style-1",
triggers: List<String> = listOf("trigger-1"),
maxDisplayCount: Int = 5
): MessageData {
val messageData1: MessageData = mockk(relaxed = true)
every { messageData1.action } returns action
every { messageData1.style } returns style
every { messageData1.trigger } returns triggers
every { messageData1.maxDisplayCount } returns maxDisplayCount
return messageData1
}
private fun createMessagingFeature(
triggers: Map<String, String> = mapOf("trigger-1" to "trigger-1-expression"),
styles: Map<String, StyleData> = mapOf("style-1" to createStyle()),
actions: Map<String, String> = mapOf("action-1" to "action-1-url"),
messages: Map<String, MessageData> = mapOf(
"message-1" to createMessageData(),
"malformed" to mockk(relaxed = true)
),
): FeatureHolder<Messaging> {
val messagingFeature: FeatureHolder<Messaging> = mockk(relaxed = true)
messaging = mockk(relaxed = true)
every { messaging.triggers } returns triggers
every { messaging.styles } returns styles
every { messaging.actions } returns actions
every { messaging.messages } returns messages
every { messagingFeature.value() } returns messaging
return messagingFeature
}
private fun createStyle(priority: Int = 1): StyleData {
val style1: StyleData = mockk(relaxed = true)
every { style1.priority } returns priority
return style1
}
}

@ -0,0 +1,327 @@
/* 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.gleanplumb.state
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.ConsumeMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Evaluate
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDismissed
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDisplayed
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Restore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessages
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessagingState
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.nimbus.MessageData
@RunWith(FenixRobolectricTestRunner::class)
class MessagingMiddlewareTest {
private lateinit var store: AppStore
private lateinit var middleware: MessagingMiddleware
private lateinit var messagingStorage: NimbusMessagingStorage
private lateinit var middlewareContext: MiddlewareContext<AppState, AppAction>
@get:Rule
val gleanTestRule = GleanTestRule(testContext)
@Before
fun setUp() {
messagingStorage = mockk(relaxed = true)
middlewareContext = mockk(relaxed = true)
middleware = MessagingMiddleware(
messagingStorage
)
}
@Test
fun `WHEN Restore THEN get messages from the storage and UpdateMessages`() {
val messages: List<Message> = emptyList()
every { messagingStorage.getMessages() } returns messages
middleware.invoke(middlewareContext, {}, Restore)
verify { middlewareContext.dispatch(UpdateMessages(messages)) }
}
@Test
fun `WHEN Restore THEN getNextMessage from the storage and UpdateMessageToShow`() {
val message: Message = mockk(relaxed = true)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messages } returns emptyList()
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
every { messagingStorage.getNextMessage(any()) } returns message
middleware.invoke(middlewareContext, {}, Evaluate)
verify { middlewareContext.dispatch(UpdateMessageToShow(message)) }
}
@Test
fun `WHEN MessageClicked THEN update storage`() {
val message = Message(
"control-id",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messages } returns emptyList()
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
middleware.invoke(middlewareContext, {}, MessageClicked(message))
verify { messagingStorage.updateMetadata(message.metadata.copy(pressed = true)) }
verify { middlewareContext.dispatch(UpdateMessages(emptyList())) }
}
@Test
fun `WHEN MessageDismissed THEN update storage`() {
val message = Message(
"control-id",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messages } returns emptyList()
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
middleware.invoke(
middlewareContext, {},
MessageDismissed(message)
)
verify { messagingStorage.updateMetadata(message.metadata.copy(dismissed = true)) }
verify { middlewareContext.dispatch(UpdateMessages(emptyList())) }
}
@Test
fun `WHEN MessageDisplayed THEN update storage`() {
val message = Message(
"control-id",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messages } returns emptyList()
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
middleware.invoke(
middlewareContext, {},
MessageDisplayed(message)
)
verify { messagingStorage.updateMetadata(message.metadata.copy(displayCount = 1)) }
verify { middlewareContext.dispatch(UpdateMessages(emptyList())) }
}
@Test
fun `WHEN onMessageDismissed THEN updateMetadata,removeMessage , UpdateMessages and removeMessageToShowIfNeeded`() {
val message = Message(
"control-id",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val spiedMiddleware = spyk(middleware)
every { spiedMiddleware.removeMessage(middlewareContext, message) } returns emptyList()
every { spiedMiddleware.consumeMessageToShowIfNeeded(middlewareContext, message) } just Runs
spiedMiddleware.onMessageDismissed(middlewareContext, message)
verify { messagingStorage.updateMetadata(message.metadata.copy(dismissed = true)) }
verify { middlewareContext.dispatch(UpdateMessages(emptyList())) }
verify { spiedMiddleware.removeMessage(middlewareContext, message) }
}
@Test
fun `WHEN removeMessage THEN remove the message`() {
val message = Message(
"control-id",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val messages = listOf(message)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messages } returns messages
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
val results = middleware.removeMessage(middlewareContext, message)
assertTrue(results.isEmpty())
}
@Test
fun `WHEN consumeMessageToShowIfNeeded THEN consume the message`() {
val message = Message(
"control-id",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id")
)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messageToShow } returns message
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
middleware.consumeMessageToShowIfNeeded(middlewareContext, message)
verify { middlewareContext.dispatch(ConsumeMessageToShow) }
}
@Test
fun `WHEN updateMessage THEN update available messages`() {
val oldMessage = Message(
"oldMessage",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id", pressed = false)
)
val updatedMessage = Message(
"oldMessage",
mockk(relaxed = true),
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id", pressed = true)
)
val spiedMiddleware = spyk(middleware)
val appState: AppState = mockk(relaxed = true)
val messagingState: MessagingState = mockk(relaxed = true)
every { messagingState.messageToShow } returns oldMessage
every { appState.messaging } returns messagingState
every { middlewareContext.state } returns appState
every { spiedMiddleware.removeMessage(middlewareContext, oldMessage) } returns emptyList()
val results = spiedMiddleware.updateMessage(middlewareContext, oldMessage, updatedMessage)
verify { middlewareContext.dispatch(UpdateMessageToShow(updatedMessage)) }
verify { spiedMiddleware.removeMessage(middlewareContext, oldMessage) }
assertTrue(results.size == 1)
assertTrue(results.first().metadata.pressed)
}
@Test
fun `GIVEN a message with that not surpassed the maxDisplayCount WHEN onMessagedDisplayed THEN update the available messages and the updateMetadata`() {
val oldMessageData: MessageData = mockk(relaxed = true)
val oldMessage = Message(
"oldMessage",
oldMessageData,
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id", displayCount = 0)
)
val updatedMessage = oldMessage.copy(metadata = oldMessage.metadata.copy(displayCount = 1))
val spiedMiddleware = spyk(middleware)
every { oldMessageData.maxDisplayCount } returns 2
every {
spiedMiddleware.updateMessage(
middlewareContext,
oldMessage,
updatedMessage
)
} returns emptyList()
spiedMiddleware.onMessagedDisplayed(oldMessage, middlewareContext)
verify { spiedMiddleware.updateMessage(middlewareContext, oldMessage, updatedMessage) }
verify { middlewareContext.dispatch(UpdateMessages(emptyList())) }
verify { messagingStorage.updateMetadata(updatedMessage.metadata) }
}
@Test
fun `GIVEN a message with that surpassed the maxDisplayCount WHEN onMessagedDisplayed THEN remove the message and consume it`() {
val oldMessageData: MessageData = mockk(relaxed = true)
val oldMessage = Message(
"oldMessage",
oldMessageData,
action = "action",
mockk(relaxed = true),
listOf("trigger"),
Message.Metadata("same-id", displayCount = 0)
)
val updatedMessage = oldMessage.copy(metadata = oldMessage.metadata.copy(displayCount = 1))
val spiedMiddleware = spyk(middleware)
every { oldMessageData.maxDisplayCount } returns 1
every {
spiedMiddleware.consumeMessageToShowIfNeeded(
middlewareContext,
oldMessage
)
} just Runs
every { spiedMiddleware.removeMessage(middlewareContext, oldMessage) } returns emptyList()
spiedMiddleware.onMessagedDisplayed(oldMessage, middlewareContext)
verify { spiedMiddleware.consumeMessageToShowIfNeeded(middlewareContext, oldMessage) }
verify { spiedMiddleware.removeMessage(middlewareContext, oldMessage) }
verify { middlewareContext.dispatch(UpdateMessages(emptyList())) }
verify { messagingStorage.updateMetadata(updatedMessage.metadata) }
}
}

@ -0,0 +1,61 @@
/* 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.gleanplumb.state
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.ConsumeMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessages
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.AppStoreReducer
import org.mozilla.fenix.gleanplumb.MessagingState
class MessagingReducerTest {
@Test
fun `GIVEN a new value for messageToShow WHEN UpdateMessageToShow is called THEN update the current value`() {
val initialState = AppState(
messaging = MessagingState(
messageToShow = null
)
)
var updatedState = MessagingReducer.reduce(
initialState,
UpdateMessageToShow(mockk())
)
assertNotNull(updatedState.messaging.messageToShow)
updatedState = AppStoreReducer.reduce(updatedState, ConsumeMessageToShow)
assertNull(updatedState.messaging.messageToShow)
}
@Test
fun `GIVEN a new value for messages WHEN UpdateMessages is called THEN update the current value`() {
val initialState = AppState(
messaging = MessagingState(
messages = emptyList()
)
)
var updatedState = MessagingReducer.reduce(
initialState,
UpdateMessages(listOf(mockk()))
)
assertFalse(updatedState.messaging.messages.isEmpty())
updatedState = AppStoreReducer.reduce(updatedState, UpdateMessages(emptyList()))
assertTrue(updatedState.messaging.messages.isEmpty())
}
}

@ -59,6 +59,8 @@ import org.mozilla.fenix.components.metrics.Event.PerformedSearch.EngineSource
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessageController
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
@ -76,6 +78,7 @@ class DefaultSessionControlControllerTest {
private val appStore: AppStore = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private val messageController: MessageController = mockk(relaxed = true)
private val engine: Engine = mockk(relaxed = true)
private val tabCollectionStorage: TabCollectionStorage = mockk(relaxed = true)
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
@ -129,7 +132,6 @@ class DefaultSessionControlControllerTest {
mode = Mode.Normal,
topSites = emptyList(),
showCollectionPlaceholder = true,
showSetAsDefaultBrowserCard = true,
recentTabs = emptyList(),
recentBookmarks = emptyList()
)
@ -1076,6 +1078,26 @@ class DefaultSessionControlControllerTest {
}
}
@Test
fun `WHEN handleMessageClicked,handleMessageClosed and handleMessageDisplayed are called THEN delegate to messageController`() {
val controller = createController()
val message = mockk<Message>()
controller.handleMessageClicked(message)
controller.handleMessageClosed(message)
controller.handleMessageDisplayed(message)
verify {
messageController.onMessagePressed(message)
}
verify {
messageController.onMessageDismissed(message)
}
verify {
messageController.onMessageDisplayed(message)
}
}
private fun createController(
hideOnboarding: () -> Unit = { },
registerCollectionStorageObserver: () -> Unit = { },
@ -1088,6 +1110,7 @@ class DefaultSessionControlControllerTest {
engine = engine,
metrics = metrics,
store = store,
messageController = messageController,
tabCollectionStorage = tabCollectionStorage,
addTabUseCase = tabsUseCases.addTab,
restoreUseCase = mockk(relaxed = true),

@ -19,6 +19,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
@ -155,7 +156,7 @@ class SessionControlViewTest {
expandedCollections,
recentBookmarks,
false,
false,
null,
recentTabs,
historyMetadata,
pocketArticles
@ -167,6 +168,40 @@ class SessionControlViewTest {
assertTrue(results[3] is AdapterItem.CustomizeHomeButton)
}
@Test
fun `GIVEN a nimbusMessageCard WHEN normalModeAdapterItems is called THEN add a NimbusMessageCard`() {
val settings: Settings = mockk()
val topSites = emptyList<TopSite>()
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf(RecentBookmark())
val recentTabs = emptyList<RecentTab.Tab>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketArticles = emptyList<PocketRecommendedStory>()
val nimbusMessageCard: Message = mockk()
every { settings.showTopSitesFeature } returns true
every { settings.showRecentTabsFeature } returns true
every { settings.showRecentBookmarksFeature } returns true
every { settings.historyMetadataUIFeature } returns true
every { settings.showPocketRecommendationsFeature } returns true
val results = normalModeAdapterItems(
settings,
topSites,
collections,
expandedCollections,
recentBookmarks,
false,
nimbusMessageCard,
recentTabs,
historyMetadata,
pocketArticles
)
assertTrue(results.contains(AdapterItem.NimbusMessageCard(nimbusMessageCard)))
}
@Test
fun `GIVEN recent tabs WHEN normalModeAdapterItems is called THEN add a customize home button`() {
val settings: Settings = mockk()
@ -191,7 +226,7 @@ class SessionControlViewTest {
expandedCollections,
recentBookmarks,
false,
false,
null,
recentTabs,
historyMetadata,
pocketArticles
@ -227,7 +262,7 @@ class SessionControlViewTest {
expandedCollections,
recentBookmarks,
false,
false,
null,
recentTabs,
historyMetadata,
pocketArticles
@ -263,7 +298,7 @@ class SessionControlViewTest {
expandedCollections,
recentBookmarks,
false,
false,
null,
recentTabs,
historyMetadata,
pocketArticles
@ -300,7 +335,7 @@ class SessionControlViewTest {
expandedCollections,
recentBookmarks,
false,
false,
null,
recentTabs,
historyMetadata,
pocketArticles
@ -336,7 +371,7 @@ class SessionControlViewTest {
expandedCollections,
recentBookmarks,
false,
false,
null,
recentTabs,
historyMetadata,
pocketArticles

@ -70,31 +70,260 @@ features:
description: Where is the message to be put.
type: Option<MessageSurfaceId>
default: null
messaging:
description: |
Configuration for the messaging system.
In practice this is a set of growable lookup tables for the
message controller to piece together.
variables:
message-under-experiment:
description: Id or prefix of the message under experiment.
type: Option<String>
default: null
messages:
description: A growable collection of messages
type: Map<String, MessageData>
default: {}
triggers:
description: >
A collection of out the box trigger
expressions. Each entry maps to a
valid JEXL expression.
type: Map<String, String>
default:
english-speaking: "'en' in locale"
ALWAYS: "true"
NEW_USER: "days_since_install < 7"
styles:
description: >
A map of styles to configure message
appearance.
type: Map<String, StyleData>
default:
urgent:
background-color: red
text-color: white
button-background: bright-blue
button-text-color: white
priority: 70
warning:
background-color: cyan,
text-color: black,
button-background: bright-blue
button-text-color: white
priority: 55
default:
background-color: blue
text-color: white
button-background: bright-blue
button-text-color: white
priority: 50
excited:
background-color: blue
text-color: white
button-background: bright-blue
button-text-color: white
priority: 60
actions:
type: Map<String, String>
description: A growable map of action URLs.
default:
OPEN_SYNC_SETTINGS: firefox://settings/sync
OPEN_POCKET_SETTINGS: https:///getpocket.com/settings?fxa={fxa-token}
OPEN_SETTINGS: ://settings
defaults:
- channel: developer
value: {
"messages": {
"my-viewpoint-survey": {
"title": "Message tile",
"text": "Love Firefox? Fill in our survey!",
"action": "https://surveyprovider.com/survey-id/{uuid}",
"trigger": [ "ALWAYS" ],
"max-display-count": 5,
"style": "warning",
"button-label": "Go to the survey"
}
},
"message-under-experiment": "my-viewpoint-survey"
}
- channel: developer
value: {
"messages": {
"private-tabs-auto-close": {
"action": "OPEN_SETTINGS",
"text": "Sharing your phone? Autoclosing private tabs is for you!",
"style": "warning",
"trigger": [
"NEW_USER",
"first-private-tabs-opened"
],
"max-display-count": 5
}
},
"message-under-experiment": "private-tabs-auto-close"
}
- channel: developer
value: {
"triggers": {
"ireland": "'IE' in locale"
},
"styles": {
"irish-green": {
"background-color": "green",
"text-color": "dark-green",
"button-background": "foo",
"button-text-color": "very-green",
"priority": 50
}
},
"actions": {
"OPEN_SETTINGS": "://settings"
},
"messages": {
"eu-tracking-protection-for-ireland": {
"action": "OPEN_SETTINGS",
"text": "GDPR has you covered. Firefox has GDPR covered",
"style": "irish-green",
"trigger": [
"NEW_USER",
"ireland"
],
"max-display-count": 5
}
},
"message-under-experiment": "eu-tracking-protection-for-"
}
types:
objects: {}
objects:
MessageData:
description: >
An object to describe a message. It uses human
readable strings to describe the triggers, action and
style of the message as well as the text of the message
and call to action.
fields:
action:
type: String
description: >
A URL of a page or a deeplink.
This may have substitution variables in.
# This should never be defaulted.
default: empty_string
title:
type: Option<Text>
description: "The title text displayed to the user"
default: null
text:
type: Text
description: "The message text displayed to the user"
# This should never be defaulted.
default: empty_string
is-control:
type: Boolean
description: "Indicates if this message is the control message, if true shouldn't be displayed"
default: false
button-label:
type: Option<Text>
description: >
The text on the button. If no text
is present, the whole message is clickable.
default: null
style:
type: String
description: >
The style as described in a
`StyleData` from the styles table.
default: "default"
max-display-count:
type: Int
description: >
The number of sessions the user is
shown the message before the message expires.
If the user is able to dismiss the message,
this is the number of times they dismiss
it before the message expires.
A count of -1 means that the message will
never expire.
default: 5
# These triggers aren't part of the MVP,
# so may be excluded.
trigger:
type: List<String>
description: >
A list of strings corresponding to
targeting expressions. The message will be
shown if all expressions `true`.
default: []
StyleData:
description: >
A group of properities (predominantly visual) to
describe the style of the message.
fields:
# How the string is transformed into a color is unspecified
background-color:
type: String
description: The color of the background.
default: "blue"
text-color:
type: String
description: The color of the background.
default: "white"
button-background:
type: String
description: The color of the button background.
default: "bright-blue"
button-text-color:
type: String
description: The color of the button text.
default: "white"
priority:
type: Int
description: >
The importance of this message.
0 is not very important, 100 is very important.
default: 50
enums:
HomeScreenSection:
description: The identifiers for the sections of the homescreen.
variants:
top-sites:
description: The frecency and pinned sites.
recently-saved:
description: The sites the user has bookmarked recently.
jump-back-in:
description: The tabs the user was looking immediately before being interrupted.
recent-explorations:
description: The tab groups
pocket:
description: The pocket section. This should only be available in the US.
contile-top-sites:
description: The sponsored shortcuts in the homescreen.
description: The identifiers for the sections of the homescreen.
variants:
top-sites:
description: The frecency and pinned sites.
recently-saved:
description: The sites the user has bookmarked recently.
jump-back-in:
description: The tabs the user was looking immediately before being interrupted.
recent-explorations:
description: The tab groups
pocket:
description: The pocket section. This should only be available in the US.
contile-top-sites:
description: The sponsored shortcuts in the homescreen.
MessageSurfaceId:
description: The identity of a message surface, used in the default browser experiments
variants:
app-menu-item:
description: An item in the default toolbar menu.
settings:
description: A setting in the settings screen.
homescreen-banner:
description: A banner in the homescreen.
description: The identity of a message surface, used in the default browser experiments
variants:
app-menu-item:
description: An item in the default toolbar menu.
settings:
description: A setting in the settings screen.
homescreen-banner:
description: A banner in the homescreen.

Loading…
Cancel
Save