Co-authored-by: Jonathan Almeida <jalmeida@mozilla.com>upstream-sync
parent
02728bc260
commit
f953c5ec94
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue