Bug 1805683 - upstream Nimbus Messaging from Fenix to Android Components
parent
578541199d
commit
e9246a0aa3
@ -1,269 +0,0 @@
|
||||
---
|
||||
features:
|
||||
nimbus-system:
|
||||
description: |
|
||||
Configuration of the Nimbus System in Fenix.
|
||||
variables:
|
||||
refresh-interval-foreground:
|
||||
description: |
|
||||
The minimum interval in minutes between fetching experiment
|
||||
recipes in the foreground.
|
||||
type: Int
|
||||
default: 60 # 1 hour
|
||||
|
||||
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: {}
|
||||
styles:
|
||||
description: >
|
||||
A map of styles to configure message
|
||||
appearance.
|
||||
type: Map<String, StyleData>
|
||||
default: {}
|
||||
|
||||
actions:
|
||||
type: Map<String, String>
|
||||
description: A growable map of action URLs.
|
||||
default: {}
|
||||
on-control:
|
||||
type: ControlMessageBehavior
|
||||
description: What should be displayed when a control message is selected.
|
||||
default: show-next-message
|
||||
notification-config:
|
||||
description: Configuration of the notification worker for all notification messages.
|
||||
type: NotificationConfig
|
||||
default: {}
|
||||
defaults:
|
||||
- value:
|
||||
triggers:
|
||||
# Using attributes built into the Nimbus SDK
|
||||
USER_RECENTLY_INSTALLED: days_since_install < 7
|
||||
USER_RECENTLY_UPDATED: days_since_update < 7 && days_since_install != days_since_update
|
||||
USER_TIER_ONE_COUNTRY: ('US' in locale || 'GB' in locale || 'CA' in locale || 'DE' in locale || 'FR' in locale)
|
||||
USER_EN_SPEAKER: "'en' in locale"
|
||||
USER_ES_SPEAKER: "'es' in locale"
|
||||
USER_DE_SPEAKER: "'de' in locale"
|
||||
USER_FR_SPEAKER: "'fr' in locale"
|
||||
DEVICE_ANDROID: os == 'Android'
|
||||
DEVICE_IOS: os == 'iOS'
|
||||
ALWAYS: "true"
|
||||
NEVER: "false"
|
||||
DAY_1_AFTER_INSTALL: days_since_install == 1
|
||||
DAY_2_AFTER_INSTALL: days_since_install == 2
|
||||
DAY_3_AFTER_INSTALL: days_since_install == 3
|
||||
DAY_4_AFTER_INSTALL: days_since_install == 4
|
||||
DAY_5_AFTER_INSTALL: days_since_install == 5
|
||||
|
||||
# Using custom attributes for the browser
|
||||
I_AM_DEFAULT_BROWSER: "is_default_browser"
|
||||
I_AM_NOT_DEFAULT_BROWSER: "is_default_browser == false"
|
||||
USER_ESTABLISHED_INSTALL: "number_of_app_launches >=4"
|
||||
|
||||
FUNNEL_PAID: "adjust_campaign != ''"
|
||||
FUNNEL_ORGANIC: "adjust_campaign == ''"
|
||||
|
||||
# Using Glean events, specific to the browser
|
||||
INACTIVE_1_DAY: "'app_launched'|eventLastSeen('Hours') >= 24"
|
||||
INACTIVE_2_DAYS: "'app_launched'|eventLastSeen('Days', 0) >= 2"
|
||||
INACTIVE_3_DAYS: "'app_launched'|eventLastSeen('Days', 0) >= 3"
|
||||
INACTIVE_4_DAYS: "'app_launched'|eventLastSeen('Days', 0) >= 4"
|
||||
INACTIVE_5_DAYS: "'app_launched'|eventLastSeen('Days', 0) >= 5"
|
||||
|
||||
# Has the user signed in the last 4 years
|
||||
FXA_SIGNED_IN: "'sync_auth.sign_in'|eventLastSeen('Years', 0) <= 4"
|
||||
FXA_NOT_SIGNED_IN: "'sync_auth.sign_in'|eventLastSeen('Years', 0) > 4"
|
||||
|
||||
# https://mozilla-hub.atlassian.net/wiki/spaces/FJT/pages/11469471/Core+Active
|
||||
USER_INFREQUENT: "'app_launched'|eventCountNonZero('Days', 28) >= 1 && 'app_launched'|eventCountNonZero('Days', 28) < 7"
|
||||
USER_CASUAL: "'app_launched'|eventCountNonZero('Days', 28) >= 7 && 'app_launched'|eventCountNonZero('Days', 28) < 14"
|
||||
USER_REGULAR: "'app_launched'|eventCountNonZero('Days', 28) >= 14 && 'app_launched'|eventCountNonZero('Days', 28) < 21"
|
||||
USER_CORE_ACTIVE: "'app_launched'|eventCountNonZero('Days', 28) >= 21"
|
||||
|
||||
LAUNCHED_ONCE_THIS_WEEK: "'app_launched'|eventSum('Days', 7) == 1"
|
||||
|
||||
actions:
|
||||
ENABLE_PRIVATE_BROWSING: ://enable_private_browsing
|
||||
INSTALL_SEARCH_WIDGET: ://install_search_widget
|
||||
MAKE_DEFAULT_BROWSER: ://make_default_browser
|
||||
VIEW_BOOKMARKS: ://urls_bookmarks
|
||||
VIEW_COLLECTIONS: ://home_collections
|
||||
VIEW_HISTORY: ://urls_history
|
||||
VIEW_HOMESCREEN: ://home
|
||||
OPEN_SETTINGS_ACCESSIBILITY: ://settings_accessibility
|
||||
OPEN_SETTINGS_ADDON_MANAGER: ://settings_addon_manager
|
||||
OPEN_SETTINGS_DELETE_BROWSING_DATA: ://settings_delete_browsing_data
|
||||
OPEN_SETTINGS_LOGINS: ://settings_logins
|
||||
OPEN_SETTINGS_NOTIFICATIONS: ://settings_notifications
|
||||
OPEN_SETTINGS_PRIVACY: ://settings_privacy
|
||||
OPEN_SETTINGS_SEARCH_ENGINE: ://settings_search_engine
|
||||
OPEN_SETTINGS_TRACKING_PROTECTION: ://settings_tracking_protection
|
||||
OPEN_SETTINGS_WALLPAPERS: ://settings_wallpapers
|
||||
OPEN_SETTINGS: ://settings
|
||||
TURN_ON_SYNC: ://turn_on_sync
|
||||
styles:
|
||||
DEFAULT:
|
||||
priority: 50
|
||||
max-display-count: 5
|
||||
SURVEY:
|
||||
priority: 55
|
||||
max-display-count: 10
|
||||
PERSISTENT:
|
||||
priority: 50
|
||||
max-display-count: 20
|
||||
WARNING:
|
||||
priority: 60
|
||||
max-display-count: 10
|
||||
URGENT:
|
||||
priority: 100
|
||||
max-display-count: 10
|
||||
NOTIFICATION:
|
||||
priority: 50
|
||||
max-display-count: 1
|
||||
messages:
|
||||
default-browser:
|
||||
text: default_browser_experiment_card_text
|
||||
surface: homescreen
|
||||
action: "MAKE_DEFAULT_BROWSER"
|
||||
trigger: [ "I_AM_NOT_DEFAULT_BROWSER","USER_ESTABLISHED_INSTALL" ]
|
||||
style: "PERSISTENT"
|
||||
button-label: preferences_set_as_default_browser
|
||||
default-browser-notification:
|
||||
title: nimbus_notification_default_browser_title
|
||||
text: nimbus_notification_default_browser_text
|
||||
surface: notification
|
||||
style: NOTIFICATION
|
||||
trigger:
|
||||
- I_AM_NOT_DEFAULT_BROWSER
|
||||
- DAY_3_AFTER_INSTALL
|
||||
action: MAKE_DEFAULT_BROWSER
|
||||
|
||||
- channel: developer
|
||||
value:
|
||||
styles:
|
||||
DEFAULT:
|
||||
priority: 50
|
||||
max-display-count: 100
|
||||
EXPIRES_QUICKLY:
|
||||
priority: 100
|
||||
max-display-count: 1
|
||||
notification-config:
|
||||
refresh-interval: 120 # minutes (2 hours)
|
||||
|
||||
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: Text
|
||||
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
|
||||
surface:
|
||||
description:
|
||||
The surface identifier for this message.
|
||||
type: MessageSurfaceId
|
||||
default: homescreen
|
||||
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 properties (predominantly visual) to
|
||||
describe the style of the message.
|
||||
fields:
|
||||
priority:
|
||||
type: Int
|
||||
description: >
|
||||
The importance of this message.
|
||||
0 is not very important, 100 is very important.
|
||||
default: 50
|
||||
max-display-count:
|
||||
type: Int
|
||||
description: >
|
||||
How many sessions will this message be shown to the user
|
||||
before it is expired.
|
||||
default: 5
|
||||
NotificationConfig:
|
||||
description: Attributes controlling the global configuration of notification messages.
|
||||
fields:
|
||||
refresh-interval:
|
||||
type: Int
|
||||
description: >
|
||||
How often, in minutes, the notification message worker will wake up and check for new
|
||||
messages.
|
||||
default: 240 # 4 hours
|
||||
|
||||
enums:
|
||||
ControlMessageBehavior:
|
||||
description: An enum to influence what should be displayed when a control message is selected.
|
||||
variants:
|
||||
show-next-message:
|
||||
description: The next eligible message should be shown.
|
||||
show-none:
|
||||
description: The surface should show no message.
|
||||
MessageSurfaceId:
|
||||
description: The identity of a message surface
|
||||
variants:
|
||||
homescreen:
|
||||
description: A banner in the homescreen.
|
||||
notification:
|
||||
description: A local notification in the background, like a push notification.
|
||||
survey:
|
||||
description: A survey dialog that is intended to be disruptive.
|
@ -1,62 +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.gleanplumb
|
||||
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
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,
|
||||
) {
|
||||
val maxDisplayCount: Int
|
||||
get() = style.maxDisplayCount
|
||||
|
||||
val priority: Int
|
||||
get() = style.priority
|
||||
|
||||
val surface: MessageSurfaceId
|
||||
get() = data.surface
|
||||
|
||||
val isExpired: Boolean
|
||||
get() = metadata.displayCount >= maxDisplayCount
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param lastTimeShown A timestamp indicating when was the last time, the message was shown.
|
||||
* @param latestBootIdentifier A unique boot identifier for when the message was last displayed
|
||||
* (this may be a boot count or a boot id).
|
||||
*/
|
||||
data class Metadata(
|
||||
val id: String,
|
||||
val displayCount: Int = 0,
|
||||
val pressed: Boolean = false,
|
||||
val dismissed: Boolean = false,
|
||||
val lastTimeShown: Long = 0L,
|
||||
val latestBootIdentifier: String? = null,
|
||||
)
|
||||
}
|
@ -1,23 +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.gleanplumb
|
||||
|
||||
interface MessageMetadataStorage {
|
||||
/**
|
||||
* Provide all the message metadata saved in the storage.
|
||||
*/
|
||||
suspend fun getMetadata(): Map<String, Message.Metadata>
|
||||
|
||||
/**
|
||||
* Given a [metadata] add the message metadata on the storage.
|
||||
* @return the added message on the [MessageMetadataStorage]
|
||||
*/
|
||||
suspend fun addMetadata(metadata: Message.Metadata): Message.Metadata
|
||||
|
||||
/**
|
||||
* Given a [metadata] update the message metadata on the storage.
|
||||
*/
|
||||
suspend fun updateMetadata(metadata: Message.Metadata)
|
||||
}
|
@ -1,133 +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.gleanplumb
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import org.mozilla.fenix.BuildConfig
|
||||
import org.mozilla.fenix.GleanMetrics.Messaging
|
||||
|
||||
/**
|
||||
* Bookkeeping for message actions in terms of Glean messages and the messaging store.
|
||||
*/
|
||||
class NimbusMessagingController(
|
||||
private val messagingStorage: NimbusMessagingStorage,
|
||||
private val now: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
/**
|
||||
* Called when a message is just about to be shown to the user.
|
||||
*
|
||||
* Update the display count, time shown and boot identifier metadata for the given [message].
|
||||
*/
|
||||
fun updateMessageAsDisplayed(message: Message, bootIdentifier: String? = null): Message {
|
||||
val updatedMetadata = message.metadata.copy(
|
||||
displayCount = message.metadata.displayCount + 1,
|
||||
lastTimeShown = now(),
|
||||
latestBootIdentifier = bootIdentifier,
|
||||
)
|
||||
return message.copy(
|
||||
metadata = updatedMetadata,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records telemetry and metadata for a newly processed displayed message.
|
||||
*/
|
||||
suspend fun onMessageDisplayed(message: Message) {
|
||||
sendShownMessageTelemetry(message.id)
|
||||
if (message.isExpired) {
|
||||
sendExpiredMessageTelemetry(message.id)
|
||||
}
|
||||
messagingStorage.updateMetadata(message.metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a message has been dismissed by the user.
|
||||
*
|
||||
* Records a messageDismissed event, and records that the message
|
||||
* has been dismissed.
|
||||
*/
|
||||
suspend fun onMessageDismissed(messageMetadata: Message.Metadata) {
|
||||
sendDismissedMessageTelemetry(messageMetadata.id)
|
||||
val updatedMetadata = messageMetadata.copy(dismissed = true)
|
||||
messagingStorage.updateMetadata(updatedMetadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once the user has clicked on a message.
|
||||
*
|
||||
* This records that the message has been clicked on, but does not record a
|
||||
* glean event. That should be done via [processMessageActionToUri].
|
||||
*/
|
||||
suspend fun onMessageClicked(messageMetadata: Message.Metadata) {
|
||||
val updatedMetadata = messageMetadata.copy(pressed = true)
|
||||
messagingStorage.updateMetadata(updatedMetadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and return the relevant [Intent] for the given [action].
|
||||
*
|
||||
* @param action the [Message.action] to create the [Intent] for.
|
||||
* @return an [Intent] using the processed [Message.action].
|
||||
*/
|
||||
fun getIntentForMessageAction(action: String): Intent {
|
||||
return Intent(Intent.ACTION_VIEW, action.toDeepLinkSchemeUri())
|
||||
}
|
||||
|
||||
/**
|
||||
* Will attempt to get the [Message] for the given [id].
|
||||
*
|
||||
* @param id the [Message.id] of the [Message] to try to match.
|
||||
* @return the [Message] with a matching [id], or null if no [Message] has a matching [id].
|
||||
*/
|
||||
suspend fun getMessage(id: String): Message? {
|
||||
return messagingStorage.getMessage(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The [message] action needs to be examined for string substitutions
|
||||
* and any `uuid` needs to be recorded in the Glean event.
|
||||
*
|
||||
* We call this `process` as it has a side effect of logging a Glean event while it
|
||||
* creates a URI string for the message action.
|
||||
*/
|
||||
fun processMessageActionToUri(message: Message): Uri {
|
||||
val (uuid, action) = messagingStorage.generateUuidAndFormatAction(message.action)
|
||||
sendClickedMessageTelemetry(message.id, uuid)
|
||||
|
||||
return action.toDeepLinkSchemeUri()
|
||||
}
|
||||
|
||||
private fun sendDismissedMessageTelemetry(messageId: String) {
|
||||
Messaging.messageDismissed.record(Messaging.MessageDismissedExtra(messageId))
|
||||
}
|
||||
|
||||
private fun sendShownMessageTelemetry(messageId: String) {
|
||||
Messaging.messageShown.record(Messaging.MessageShownExtra(messageId))
|
||||
}
|
||||
|
||||
private fun sendExpiredMessageTelemetry(messageId: String) {
|
||||
Messaging.messageExpired.record(Messaging.MessageExpiredExtra(messageId))
|
||||
}
|
||||
|
||||
private fun sendClickedMessageTelemetry(messageId: String, uuid: String?) {
|
||||
Messaging.messageClicked.record(
|
||||
Messaging.MessageClickedExtra(messageKey = messageId, actionUuid = uuid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toDeepLinkSchemeUri(): Uri {
|
||||
val actionWithDeepLinkScheme = if (startsWith("http", ignoreCase = true)) {
|
||||
"${BuildConfig.DEEP_LINK_SCHEME}://open?url=${Uri.encode(this)}"
|
||||
} else if (startsWith("://")) {
|
||||
"${BuildConfig.DEEP_LINK_SCHEME}$this"
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return actionWithDeepLinkScheme.toUri()
|
||||
}
|
@ -1,268 +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.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.ControlMessageBehavior
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.Messaging
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
/**
|
||||
* This ID must match the name given in the `nimbus.fml.yaml` file, which
|
||||
* itself generates the classname for [org.mozilla.fenix.nimbus.Messaging].
|
||||
*
|
||||
* If that ever changes, it should also change here.
|
||||
*
|
||||
* This constant is the id for the messaging feature (the Nimbus feature). We declare it here
|
||||
* so as to afford the best chance of it being changed if a rename operation is needed.
|
||||
*
|
||||
* It is used in the Studies view, to filter out any experiments which only use a messaging surface.
|
||||
*/
|
||||
const val MESSAGING_FEATURE_ID = "messaging"
|
||||
|
||||
/**
|
||||
* Provides messages from [messagingFeature] and combine with the metadata store on [metadataStorage].
|
||||
*/
|
||||
class NimbusMessagingStorage(
|
||||
private val context: Context,
|
||||
private val metadataStorage: MessageMetadataStorage,
|
||||
private val reportMalformedMessage: (String) -> Unit,
|
||||
private val gleanPlumb: GleanPlumbInterface,
|
||||
private val messagingFeature: FeatureHolder<Messaging>,
|
||||
private val attributeProvider: CustomAttributeProvider? = null,
|
||||
) {
|
||||
/**
|
||||
* Contains all malformed messages where they key can be the value or a trigger of the message
|
||||
* and the value is the message id.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal val malFormedMap = mutableMapOf<String, String>()
|
||||
private val logger = Logger("MessagingStorage")
|
||||
private val nimbusFeature = messagingFeature
|
||||
private val customAttributes: JSONObject
|
||||
get() = attributeProvider?.getCustomAttributes(context) ?: JSONObject()
|
||||
|
||||
/**
|
||||
* Returns the [Message] for the given [key] or returns null if none found.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
suspend fun getMessage(key: String): Message? {
|
||||
val featureValue = messagingFeature.value()
|
||||
val value = featureValue.messages[key] ?: return null
|
||||
val trigger = sanitizeTriggers(key, value.trigger, featureValue.triggers) ?: return null
|
||||
val action = sanitizeAction(key, value.action, featureValue.actions, value.isControl) ?: return null
|
||||
val defaultStyle = StyleData()
|
||||
val storageMetadata = metadataStorage.getMetadata()
|
||||
|
||||
return Message(
|
||||
id = key,
|
||||
data = value,
|
||||
action = action,
|
||||
style = featureValue.styles[value.style] ?: defaultStyle,
|
||||
metadata = storageMetadata[key] ?: addMetadata(key),
|
||||
triggers = trigger,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of currently available messages descending sorted by their priority.
|
||||
* This list of messages will not include any expired, pressed or dismissed messages.
|
||||
*/
|
||||
suspend fun getMessages(): List<Message> {
|
||||
val featureValue = messagingFeature.value()
|
||||
val nimbusTriggers = featureValue.triggers
|
||||
val nimbusStyles = featureValue.styles
|
||||
val nimbusActions = featureValue.actions
|
||||
|
||||
val nimbusMessages = featureValue.messages
|
||||
val defaultStyle = StyleData()
|
||||
val storageMetadata = metadataStorage.getMetadata()
|
||||
|
||||
return nimbusMessages
|
||||
.mapNotNull { (key, value) ->
|
||||
val action = sanitizeAction(key, value.action, nimbusActions, value.isControl) ?: return@mapNotNull null
|
||||
Message(
|
||||
id = key,
|
||||
data = value,
|
||||
action = action,
|
||||
style = nimbusStyles[value.style] ?: defaultStyle,
|
||||
metadata = storageMetadata[key] ?: addMetadata(key),
|
||||
triggers = sanitizeTriggers(key, value.trigger, nimbusTriggers)
|
||||
?: return@mapNotNull null,
|
||||
)
|
||||
}.filter {
|
||||
!it.isExpired &&
|
||||
!it.metadata.dismissed &&
|
||||
!it.metadata.pressed
|
||||
}.sortedByDescending {
|
||||
it.style.priority
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next higher priority message which all their triggers are true.
|
||||
*/
|
||||
fun getNextMessage(surface: MessageSurfaceId, availableMessages: List<Message>): Message? {
|
||||
val jexlCache = HashMap<String, Boolean>()
|
||||
val helper = gleanPlumb.createMessageHelper(customAttributes)
|
||||
val message = availableMessages.firstOrNull {
|
||||
surface == it.surface && isMessageEligible(it, helper, jexlCache)
|
||||
} ?: return null
|
||||
|
||||
// Check this isn't an experimental message. If not, we can go ahead and return it.
|
||||
if (!isMessageUnderExperiment(message, nimbusFeature.value().messageUnderExperiment)) {
|
||||
return message
|
||||
}
|
||||
// If the message is under experiment, then we need to record the exposure
|
||||
messagingFeature.recordExposure()
|
||||
|
||||
// If this is an experimental message, but not a placebo, then just return the message.
|
||||
return if (!message.data.isControl) {
|
||||
message
|
||||
} else {
|
||||
// This is a control, so we need to either return the next message (there may not be one)
|
||||
// or not display anything.
|
||||
when (getOnControlBehavior()) {
|
||||
ControlMessageBehavior.SHOW_NEXT_MESSAGE -> availableMessages.firstOrNull {
|
||||
// There should only be one control message, and we've just detected it.
|
||||
!it.data.isControl && isMessageEligible(it, helper, jexlCache)
|
||||
}
|
||||
ControlMessageBehavior.SHOW_NONE -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a pair of uuid and valid action for the provided [action].
|
||||
*
|
||||
* Uses Nimbus' targeting attributes to do basic string interpolation.
|
||||
*
|
||||
* e.g.
|
||||
* `https://example.com/{locale}/whatsnew.html?version={app_version}`
|
||||
*
|
||||
* If the string `{uuid}` is detected in the [action] string, then it is
|
||||
* replaced with a random UUID. This is returned as the first value of the returned
|
||||
* [Pair].
|
||||
*
|
||||
* The fully resolved (with all substitutions) action is returned as the second value
|
||||
* of the [Pair].
|
||||
*/
|
||||
fun generateUuidAndFormatAction(action: String): Pair<String?, String> {
|
||||
val helper = gleanPlumb.createMessageHelper(customAttributes)
|
||||
val uuid = helper.getUuid(action)
|
||||
|
||||
return Pair(uuid, helper.stringFormat(action, uuid))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updated the provided [metadata] in the storage.
|
||||
*/
|
||||
suspend fun updateMetadata(metadata: Message.Metadata) {
|
||||
metadataStorage.updateMetadata(metadata)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun sanitizeAction(
|
||||
messageId: String,
|
||||
unsafeAction: String,
|
||||
nimbusActions: Map<String, String>,
|
||||
isControl: Boolean,
|
||||
): String? {
|
||||
return when {
|
||||
unsafeAction.startsWith("http") -> {
|
||||
unsafeAction
|
||||
}
|
||||
isControl -> "CONTROL_ACTION"
|
||||
else -> {
|
||||
val safeAction = nimbusActions[unsafeAction]
|
||||
if (safeAction.isNullOrBlank() || safeAction.isEmpty()) {
|
||||
if (!malFormedMap.containsKey(unsafeAction)) {
|
||||
reportMalformedMessage(messageId)
|
||||
}
|
||||
malFormedMap[unsafeAction] = messageId
|
||||
return null
|
||||
}
|
||||
safeAction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun sanitizeTriggers(
|
||||
messageId: String,
|
||||
unsafeTriggers: List<String>,
|
||||
nimbusTriggers: Map<String, String>,
|
||||
): List<String>? {
|
||||
return unsafeTriggers.map {
|
||||
val safeTrigger = nimbusTriggers[it]
|
||||
if (safeTrigger.isNullOrBlank() || safeTrigger.isEmpty()) {
|
||||
if (!malFormedMap.containsKey(it)) {
|
||||
reportMalformedMessage(messageId)
|
||||
}
|
||||
malFormedMap[it] = messageId
|
||||
return null
|
||||
}
|
||||
safeTrigger
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun isMessageUnderExperiment(message: Message, expression: String?): Boolean {
|
||||
return message.data.isControl || when {
|
||||
expression.isNullOrBlank() -> {
|
||||
false
|
||||
}
|
||||
expression.endsWith("-") -> {
|
||||
message.id.startsWith(expression)
|
||||
}
|
||||
else -> {
|
||||
message.id == expression
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun isMessageEligible(
|
||||
message: Message,
|
||||
helper: GleanPlumbMessageHelper,
|
||||
jexlCache: MutableMap<String, Boolean> = mutableMapOf(),
|
||||
): Boolean {
|
||||
return message.triggers.all { condition ->
|
||||
jexlCache[condition]
|
||||
?: try {
|
||||
if (malFormedMap.containsKey(condition)) {
|
||||
return false
|
||||
}
|
||||
helper.evalJexl(condition).also { result ->
|
||||
jexlCache[condition] = result
|
||||
}
|
||||
} catch (e: NimbusException.EvaluationException) {
|
||||
reportMalformedMessage(message.id)
|
||||
malFormedMap[condition] = message.id
|
||||
logger.info("Unable to evaluate $condition")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getOnControlBehavior(): ControlMessageBehavior = nimbusFeature.value().onControl
|
||||
|
||||
private suspend fun addMetadata(id: String): Message.Metadata {
|
||||
return metadataStorage.addMetadata(
|
||||
Message.Metadata(
|
||||
id = id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
@ -1,96 +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.gleanplumb
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import mozilla.components.support.ktx.util.readAndDeserialize
|
||||
import mozilla.components.support.ktx.util.writeString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
internal const val FILE_NAME = "nimbus_messages_metadata.json"
|
||||
|
||||
/**
|
||||
* A storage that persists [Message.Metadata] into disk.
|
||||
*/
|
||||
class OnDiskMessageMetadataStorage(
|
||||
private val context: Context,
|
||||
) : MessageMetadataStorage {
|
||||
private val diskCacheLock = Any()
|
||||
|
||||
@VisibleForTesting
|
||||
internal var metadataMap: MutableMap<String, Message.Metadata> = hashMapOf()
|
||||
|
||||
override suspend fun getMetadata(): Map<String, Message.Metadata> {
|
||||
if (metadataMap.isEmpty()) {
|
||||
metadataMap = readFromDisk().toMutableMap()
|
||||
}
|
||||
return metadataMap
|
||||
}
|
||||
|
||||
override suspend fun addMetadata(metadata: Message.Metadata): Message.Metadata {
|
||||
metadataMap[metadata.id] = metadata
|
||||
writeToDisk()
|
||||
return metadata
|
||||
}
|
||||
|
||||
override suspend fun updateMetadata(metadata: Message.Metadata) {
|
||||
addMetadata(metadata)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun readFromDisk(): Map<String, Message.Metadata> {
|
||||
synchronized(diskCacheLock) {
|
||||
return getFile().readAndDeserialize {
|
||||
JSONArray(it).toMetadataMap()
|
||||
} ?: emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun writeToDisk() {
|
||||
synchronized(diskCacheLock) {
|
||||
val json = metadataMap.values.toList().fold("") { acc, next ->
|
||||
if (acc.isEmpty()) {
|
||||
next.toJson()
|
||||
} else {
|
||||
"$acc,${next.toJson()}"
|
||||
}
|
||||
}
|
||||
getFile().writeString { "[$json]" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFile(): AtomicFile {
|
||||
return AtomicFile(File(context.filesDir, FILE_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun JSONArray.toMetadataMap(): Map<String, Message.Metadata> {
|
||||
return (0 until length()).map { index ->
|
||||
getJSONObject(index).toMetadata()
|
||||
}.associateBy {
|
||||
it.id
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength") // To avoid adding any extra space to the string.
|
||||
internal fun Message.Metadata.toJson(): String {
|
||||
return """{"id":"$id","displayCount":$displayCount,"pressed":$pressed,"dismissed":$dismissed,"lastTimeShown":$lastTimeShown,"latestBootIdentifier":"$latestBootIdentifier"}"""
|
||||
}
|
||||
|
||||
internal fun JSONObject.toMetadata(): Message.Metadata {
|
||||
return Message.Metadata(
|
||||
id = optString("id"),
|
||||
displayCount = optInt("displayCount"),
|
||||
pressed = optBoolean("pressed"),
|
||||
dismissed = optBoolean("dismissed"),
|
||||
lastTimeShown = optLong("lastTimeShown"),
|
||||
latestBootIdentifier = optString("latestBootIdentifier"),
|
||||
)
|
||||
}
|
@ -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.messaging
|
||||
|
||||
/**
|
||||
* The identity of a message surface for Fenix
|
||||
*/
|
||||
object FenixMessageSurfaceId {
|
||||
/**
|
||||
* A local notification in the background, like a push notification.
|
||||
*/
|
||||
const val NOTIFICATION = "notification"
|
||||
|
||||
/**
|
||||
* A banner in the homescreen.
|
||||
*/
|
||||
const val HOMESCREEN = "homescreen"
|
||||
|
||||
/**
|
||||
* A survey dialog that is intended to be disruptive.
|
||||
*/
|
||||
const val SURVEY = "survey"
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package org.mozilla.fenix.messaging
|
||||
|
||||
import mozilla.components.service.nimbus.messaging.NimbusMessagingController
|
||||
import mozilla.components.service.nimbus.messaging.NimbusMessagingStorage
|
||||
import org.mozilla.fenix.BuildConfig
|
||||
|
||||
/**
|
||||
* Bookkeeping for message actions in terms of Glean messages and the messaging store.
|
||||
* Specialized implementation of NimbusMessagingController defining the deepLinkScheme
|
||||
* used by Fenix
|
||||
*/
|
||||
class FenixNimbusMessagingController(
|
||||
messagingStorage: NimbusMessagingStorage,
|
||||
now: () -> Long = { System.currentTimeMillis() },
|
||||
) : NimbusMessagingController(
|
||||
messagingStorage = messagingStorage,
|
||||
deepLinkScheme = BuildConfig.DEEP_LINK_SCHEME,
|
||||
now = now,
|
||||
)
|
@ -1,63 +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.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Provides access to system properties.
|
||||
*/
|
||||
interface BootUtils {
|
||||
|
||||
/**
|
||||
* Gets the device boot count.
|
||||
*
|
||||
* **Only for Android versions N(24) and above.**
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
fun getDeviceBootCount(context: Context): String
|
||||
|
||||
val deviceBootId: String?
|
||||
|
||||
val bootIdFileExists: Boolean
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* @return either the boot count or a boot id depending on the device Android version.
|
||||
*/
|
||||
fun getBootIdentifier(context: Context, bootUtils: BootUtils = BootUtilsImpl()): String {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
bootUtils.getDeviceBootCount(context)
|
||||
} else {
|
||||
return if (bootUtils.bootIdFileExists) {
|
||||
bootUtils.deviceBootId ?: NO_BOOT_IDENTIFIER
|
||||
} else {
|
||||
NO_BOOT_IDENTIFIER
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of [BootUtils].
|
||||
*/
|
||||
class BootUtilsImpl : BootUtils {
|
||||
private val bootIdFile by lazy { File("/proc/sys/kernel/random/boot_id") }
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
override fun getDeviceBootCount(context: Context): String =
|
||||
Settings.Global.getString(context.contentResolver, Settings.Global.BOOT_COUNT)
|
||||
|
||||
override val deviceBootId: String? by lazy { bootIdFile.readLines().firstOrNull()?.trim() }
|
||||
|
||||
override val bootIdFileExists: Boolean by lazy { bootIdFile.exists() }
|
||||
}
|
||||
|
||||
private const val NO_BOOT_IDENTIFIER = "no boot identifier available"
|
@ -1,316 +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.gleanplumb
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import mozilla.components.support.test.robolectric.testContext
|
||||
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||
import mozilla.telemetry.glean.testing.GleanTestRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.experiments.nimbus.NullVariables
|
||||
import org.mozilla.fenix.BuildConfig
|
||||
import org.mozilla.fenix.GleanMetrics.Messaging
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
import java.util.UUID
|
||||
|
||||
private const val MOCK_TIME_MILLIS = 1000L
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class NimbusMessagingControllerTest {
|
||||
private val storage: NimbusMessagingStorage = mockk(relaxed = true)
|
||||
|
||||
@get:Rule
|
||||
val gleanTestRule = GleanTestRule(testContext)
|
||||
|
||||
private val coroutinesTestRule = MainCoroutineRule()
|
||||
private val coroutineScope = coroutinesTestRule.scope
|
||||
|
||||
private val controller = NimbusMessagingController(storage) { MOCK_TIME_MILLIS }
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
NullVariables.instance.setContext(testContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling updateMessageAsDisplayed with message & no boot id THEN metadata for count and lastTimeShown is updated`() =
|
||||
coroutineScope.runTest {
|
||||
val message = createMessage("id-1")
|
||||
assertEquals(0, message.metadata.displayCount)
|
||||
assertEquals(0L, message.metadata.lastTimeShown)
|
||||
assertNull(message.metadata.latestBootIdentifier)
|
||||
|
||||
val expectedMessage = with(message) {
|
||||
copy(
|
||||
metadata = metadata.copy(
|
||||
displayCount = 1,
|
||||
lastTimeShown = MOCK_TIME_MILLIS,
|
||||
latestBootIdentifier = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(expectedMessage, controller.updateMessageAsDisplayed(message))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling updateMessageAsDisplayed with message & boot id THEN metadata for count, lastTimeShown & latestBootIdentifier is updated`() =
|
||||
coroutineScope.runTest {
|
||||
val message = createMessage("id-1")
|
||||
assertEquals(0, message.metadata.displayCount)
|
||||
assertEquals(0L, message.metadata.lastTimeShown)
|
||||
assertNull(message.metadata.latestBootIdentifier)
|
||||
|
||||
val bootId = "test boot id"
|
||||
val expectedMessage = with(message) {
|
||||
copy(
|
||||
metadata = metadata.copy(
|
||||
displayCount = 1,
|
||||
lastTimeShown = MOCK_TIME_MILLIS,
|
||||
latestBootIdentifier = bootId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(expectedMessage, controller.updateMessageAsDisplayed(message, bootId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN message not expired WHEN calling onMessageDisplayed THEN record a messageShown event and update storage`() =
|
||||
coroutineScope.runTest {
|
||||
val message = createMessage("id-1", style = StyleData(maxDisplayCount = 1))
|
||||
// Assert telemetry is initially null
|
||||
assertNull(Messaging.messageShown.testGetValue())
|
||||
assertNull(Messaging.messageExpired.testGetValue())
|
||||
|
||||
controller.onMessageDisplayed(message)
|
||||
|
||||
// Shown telemetry
|
||||
assertNotNull(Messaging.messageShown.testGetValue())
|
||||
val shownEvent = Messaging.messageShown.testGetValue()!!
|
||||
assertEquals(1, shownEvent.size)
|
||||
assertEquals(message.id, shownEvent.single().extra!!["message_key"])
|
||||
|
||||
// Expired telemetry
|
||||
assertNull(Messaging.messageExpired.testGetValue())
|
||||
|
||||
coVerify { storage.updateMetadata(message.metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN message is expired WHEN calling onMessageDisplayed THEN record messageShown, messageExpired events and update storage`() =
|
||||
coroutineScope.runTest {
|
||||
val message =
|
||||
createMessage("id-1", style = StyleData(maxDisplayCount = 1), displayCount = 1)
|
||||
// Assert telemetry is initially null
|
||||
assertNull(Messaging.messageShown.testGetValue())
|
||||
assertNull(Messaging.messageExpired.testGetValue())
|
||||
|
||||
controller.onMessageDisplayed(message)
|
||||
|
||||
// Shown telemetry
|
||||
assertNotNull(Messaging.messageShown.testGetValue())
|
||||
val shownEvent = Messaging.messageShown.testGetValue()!!
|
||||
assertEquals(1, shownEvent.size)
|
||||
assertEquals(message.id, shownEvent.single().extra!!["message_key"])
|
||||
|
||||
// Expired telemetry
|
||||
assertNotNull(Messaging.messageExpired.testGetValue())
|
||||
val expiredEvent = Messaging.messageExpired.testGetValue()!!
|
||||
assertEquals(1, expiredEvent.size)
|
||||
assertEquals(message.id, expiredEvent.single().extra!!["message_key"])
|
||||
|
||||
coVerify { storage.updateMetadata(message.metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling onMessageDismissed THEN record a messageDismissed event and update metadata`() =
|
||||
coroutineScope.runTest {
|
||||
val message = createMessage("id-1")
|
||||
assertNull(Messaging.messageDismissed.testGetValue())
|
||||
|
||||
controller.onMessageDismissed(message.metadata)
|
||||
|
||||
assertNotNull(Messaging.messageDismissed.testGetValue())
|
||||
val event = Messaging.messageDismissed.testGetValue()!!
|
||||
assertEquals(1, event.size)
|
||||
assertEquals(message.id, event.single().extra!!["message_key"])
|
||||
|
||||
coVerify { storage.updateMetadata(message.metadata.copy(dismissed = true)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN action is URL WHEN calling processMessageActionToUri THEN record a clicked telemetry event and return an open URI`() {
|
||||
val url = "http://mozilla.org"
|
||||
val message = createMessage("id-1", action = url)
|
||||
every { storage.generateUuidAndFormatAction(message.action) } returns Pair(
|
||||
null,
|
||||
message.action,
|
||||
)
|
||||
// Assert telemetry is initially null
|
||||
assertNull(Messaging.messageClicked.testGetValue())
|
||||
|
||||
val encodedUrl = Uri.encode(url)
|
||||
val expectedUri = "${BuildConfig.DEEP_LINK_SCHEME}://open?url=$encodedUrl".toUri()
|
||||
|
||||
val actualUri = controller.processMessageActionToUri(message)
|
||||
|
||||
// Updated telemetry
|
||||
assertNotNull(Messaging.messageClicked.testGetValue())
|
||||
val clickedEvent = Messaging.messageClicked.testGetValue()!!
|
||||
assertEquals(1, clickedEvent.size)
|
||||
assertEquals(message.id, clickedEvent.single().extra!!["message_key"])
|
||||
|
||||
assertEquals(expectedUri, actualUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a URL with a {uuid} WHEN calling processMessageActionToUri THEN record a clicked telemetry event and return an open URI`() {
|
||||
val url = "http://mozilla.org?uuid={uuid}"
|
||||
val message = createMessage("id-1", action = url)
|
||||
val uuid = UUID.randomUUID().toString()
|
||||
every { storage.generateUuidAndFormatAction(any()) } returns Pair(uuid, message.action)
|
||||
|
||||
// Assert telemetry is initially null
|
||||
assertNull(Messaging.messageClicked.testGetValue())
|
||||
|
||||
val encodedUrl = Uri.encode(url)
|
||||
val expectedUri = "${BuildConfig.DEEP_LINK_SCHEME}://open?url=$encodedUrl".toUri()
|
||||
|
||||
val actualUri = controller.processMessageActionToUri(message)
|
||||
|
||||
// Updated telemetry
|
||||
val clickedEvents = Messaging.messageClicked.testGetValue()
|
||||
assertNotNull(clickedEvents)
|
||||
val clickedEvent = clickedEvents!!.single()
|
||||
assertEquals(message.id, clickedEvent.extra!!["message_key"])
|
||||
assertEquals(uuid, clickedEvent.extra!!["action_uuid"])
|
||||
|
||||
assertEquals(expectedUri, actualUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN action is deeplink WHEN calling processMessageActionToUri THEN return a deeplink URI`() {
|
||||
val message = createMessage("id-1", action = "://a-deep-link")
|
||||
every { storage.generateUuidAndFormatAction(message.action) } returns Pair(
|
||||
null,
|
||||
message.action,
|
||||
)
|
||||
// Assert telemetry is initially null
|
||||
assertNull(Messaging.messageClicked.testGetValue())
|
||||
|
||||
val expectedUri = "${BuildConfig.DEEP_LINK_SCHEME}${message.action}".toUri()
|
||||
val actualUri = controller.processMessageActionToUri(message)
|
||||
|
||||
// Updated telemetry
|
||||
assertNotNull(Messaging.messageClicked.testGetValue())
|
||||
val clickedEvent = Messaging.messageClicked.testGetValue()!!
|
||||
assertEquals(1, clickedEvent.size)
|
||||
assertEquals(message.id, clickedEvent.single().extra!!["message_key"])
|
||||
|
||||
assertEquals(expectedUri, actualUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN action unknown format WHEN calling processMessageActionToUri THEN return the action URI`() {
|
||||
val message = createMessage("id-1", action = "unknown")
|
||||
every { storage.generateUuidAndFormatAction(message.action) } returns Pair(
|
||||
null,
|
||||
message.action,
|
||||
)
|
||||
// Assert telemetry is initially null
|
||||
assertNull(Messaging.messageClicked.testGetValue())
|
||||
|
||||
val expectedUri = message.action.toUri()
|
||||
val actualUri = controller.processMessageActionToUri(message)
|
||||
|
||||
// Updated telemetry
|
||||
assertNotNull(Messaging.messageClicked.testGetValue())
|
||||
val clickedEvent = Messaging.messageClicked.testGetValue()!!
|
||||
assertEquals(1, clickedEvent.size)
|
||||
assertEquals(message.id, clickedEvent.single().extra!!["message_key"])
|
||||
|
||||
assertEquals(expectedUri, actualUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling onMessageClicked THEN update stored metadata for message`() =
|
||||
coroutineScope.runTest {
|
||||
val message = createMessage("id-1")
|
||||
assertFalse(message.metadata.pressed)
|
||||
|
||||
controller.onMessageClicked(message.metadata)
|
||||
|
||||
val updatedMetadata = message.metadata.copy(pressed = true)
|
||||
coVerify { storage.updateMetadata(updatedMetadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN getIntentForMessageAction is called THEN return a generated Intent with the processed Message action`() {
|
||||
val message = createMessage("id-1", action = "unknown")
|
||||
every { storage.generateUuidAndFormatAction(message.action) } returns Pair(
|
||||
null,
|
||||
message.action,
|
||||
)
|
||||
|
||||
val actualIntent = controller.getIntentForMessageAction(message.action)
|
||||
|
||||
// The processed Intent data
|
||||
assertEquals(Intent.ACTION_VIEW, actualIntent.action)
|
||||
val expectedUri = message.action.toUri()
|
||||
assertEquals(expectedUri, actualIntent.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored messages contains a matching message WHEN calling getMessage THEN return the matching message`() =
|
||||
coroutineScope.runTest {
|
||||
val message1 = createMessage("1")
|
||||
coEvery { storage.getMessage(message1.id) }.returns(message1)
|
||||
val actualMessage = controller.getMessage(message1.id)
|
||||
|
||||
assertEquals(message1, actualMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored messages doesn't contain a matching message WHEN calling getMessage THEN return null`() =
|
||||
coroutineScope.runTest {
|
||||
coEvery { storage.getMessage("unknown id") }.returns(null)
|
||||
val actualMessage = controller.getMessage("unknown id")
|
||||
|
||||
assertNull(actualMessage)
|
||||
}
|
||||
|
||||
private fun createMessage(
|
||||
id: String,
|
||||
messageData: MessageData = MessageData(),
|
||||
action: String = messageData.action,
|
||||
style: StyleData = StyleData(),
|
||||
displayCount: Int = 0,
|
||||
): Message =
|
||||
Message(
|
||||
id,
|
||||
data = messageData,
|
||||
style = style,
|
||||
metadata = Message.Metadata(id, displayCount),
|
||||
triggers = emptyList(),
|
||||
action = action,
|
||||
)
|
||||
}
|
@ -1,686 +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.gleanplumb
|
||||
|
||||
import io.mockk.Called
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.NullVariables
|
||||
import org.mozilla.experiments.nimbus.Res
|
||||
import org.mozilla.experiments.nimbus.internal.FeatureHolder
|
||||
import org.mozilla.experiments.nimbus.internal.NimbusException
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.nimbus.ControlMessageBehavior.SHOW_NEXT_MESSAGE
|
||||
import org.mozilla.fenix.nimbus.MessageData
|
||||
import org.mozilla.fenix.nimbus.MessageSurfaceId
|
||||
import org.mozilla.fenix.nimbus.Messaging
|
||||
import org.mozilla.fenix.nimbus.StyleData
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class NimbusMessagingStorageTest {
|
||||
private lateinit var storage: NimbusMessagingStorage
|
||||
private lateinit var metadataStorage: MessageMetadataStorage
|
||||
private lateinit var gleanPlumb: GleanPlumbInterface
|
||||
private lateinit var messagingFeature: FeatureHolder<Messaging>
|
||||
private var malformedWasReported = false
|
||||
private val reportMalformedMessage: (String) -> Unit = {
|
||||
malformedWasReported = true
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
gleanPlumb = mockk(relaxed = true)
|
||||
metadataStorage = mockk(relaxed = true)
|
||||
malformedWasReported = false
|
||||
NullVariables.instance.setContext(testContext)
|
||||
messagingFeature = createMessagingFeature()
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns mapOf("message-1" to Message.Metadata(id = "message-1"))
|
||||
|
||||
storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
gleanPlumb,
|
||||
messagingFeature,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling getMessages THEN provide a list of available messages for a given surface`() = runTest {
|
||||
val homescreenMessage = storage.getMessages().first()
|
||||
|
||||
assertEquals("message-1", homescreenMessage.id)
|
||||
assertEquals("message-1", homescreenMessage.metadata.id)
|
||||
|
||||
val notificationMessage = storage.getMessages().last()
|
||||
assertEquals("message-2", notificationMessage.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling getMessages THEN provide a list of sorted messages by priority`() =
|
||||
runTest {
|
||||
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,
|
||||
)
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns mapOf(
|
||||
"message-1" to Message.Metadata(
|
||||
id = "message-1",
|
||||
),
|
||||
)
|
||||
|
||||
val storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
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`() =
|
||||
runTest {
|
||||
val metadataList = mapOf(
|
||||
"pressed-message" to Message.Metadata(id = "pressed-message", pressed = true),
|
||||
"normal-message" to 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,
|
||||
)
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns metadataList
|
||||
|
||||
val storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
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`() =
|
||||
runTest {
|
||||
val metadataList = mapOf(
|
||||
"dismissed-message" to Message.Metadata(id = "dismissed-message", dismissed = true),
|
||||
"normal-message" to 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,
|
||||
)
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns metadataList
|
||||
|
||||
val storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
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`() =
|
||||
runTest {
|
||||
val metadataList = mapOf(
|
||||
"shown-many-times-message" to Message.Metadata(
|
||||
id = "shown-many-times-message",
|
||||
displayCount = 10,
|
||||
),
|
||||
"shown-two-times-message" to Message.Metadata(
|
||||
id = "shown-two-times-message",
|
||||
displayCount = 2,
|
||||
),
|
||||
"normal-message" to Message.Metadata(id = "normal-message", displayCount = 0),
|
||||
)
|
||||
val messages = mapOf(
|
||||
"shown-many-times-message" to createMessageData(
|
||||
style = "high-priority",
|
||||
),
|
||||
"shown-two-times-message" to createMessageData(
|
||||
style = "high-priority",
|
||||
),
|
||||
"normal-message" to createMessageData(style = "high-priority"),
|
||||
)
|
||||
val styles = mapOf(
|
||||
"high-priority" to createStyle(priority = 100, maxDisplayCount = 2),
|
||||
)
|
||||
val metadataStorage: MessageMetadataStorage = mockk(relaxed = true)
|
||||
val messagingFeature = createMessagingFeature(
|
||||
styles = styles,
|
||||
messages = messages,
|
||||
)
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns metadataList
|
||||
|
||||
val storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
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`() = runTest {
|
||||
val messages = storage.getMessages()
|
||||
val firstMessage = messages.first()
|
||||
|
||||
assertEquals("message-1", firstMessage.id)
|
||||
assertEquals("message-1", firstMessage.metadata.id)
|
||||
assertTrue(messages.size == 2)
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@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("messageId", "no-found-action", actionsMap, false)
|
||||
val emptyAction = storage.sanitizeAction("messageId", "", actionsMap, false)
|
||||
val blankAction = storage.sanitizeAction("messageId", " ", actionsMap, false)
|
||||
|
||||
assertNull(notFoundAction)
|
||||
assertNull(emptyAction)
|
||||
assertNull(blankAction)
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a previously stored malformed action WHEN calling sanitizeAction THEN return null and not report malFormed`() {
|
||||
val actionsMap = mapOf("action-1" to "action-1-url")
|
||||
|
||||
storage.malFormedMap["malformed-action"] = "messageId"
|
||||
|
||||
val action = storage.sanitizeAction("messageId", "malformed-action", actionsMap, false)
|
||||
|
||||
assertNull(action)
|
||||
assertFalse(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a non-previously stored malformed action WHEN calling sanitizeAction THEN return null and report malFormed`() {
|
||||
val actionsMap = mapOf("action-1" to "action-1-url")
|
||||
|
||||
val action = storage.sanitizeAction("messageId", "malformed-action", actionsMap, false)
|
||||
|
||||
assertNull(action)
|
||||
assertTrue(storage.malFormedMap.containsKey("malformed-action"))
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling updateMetadata THEN delegate to metadataStorage`() = runTest {
|
||||
storage.updateMetadata(mockk(relaxed = true))
|
||||
|
||||
coEvery { 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("messageId", "action-1", actionsMap, false)
|
||||
|
||||
assertEquals("action-1-url", validAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a valid action for control message WHEN calling sanitizeAction THEN return a empty action`() {
|
||||
val actionsMap = mapOf("action-1" to "action-1-url")
|
||||
|
||||
val validAction = storage.sanitizeAction("messageId", "", actionsMap, true)
|
||||
|
||||
assertEquals("CONTROL_ACTION", validAction)
|
||||
assertFalse(malformedWasReported)
|
||||
}
|
||||
|
||||
@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("messageId", listOf("no-found-trigger"), triggersMap)
|
||||
val emptyTrigger = storage.sanitizeTriggers("messageId", listOf(""), triggersMap)
|
||||
val blankTrigger = storage.sanitizeTriggers("messageId", listOf(" "), triggersMap)
|
||||
|
||||
assertNull(notFoundTrigger)
|
||||
assertNull(emptyTrigger)
|
||||
assertNull(blankTrigger)
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a previously stored malformed trigger WHEN calling sanitizeTriggers THEN no report malformed and return null`() {
|
||||
val triggersMap = mapOf("trigger-1" to "trigger-1-expression")
|
||||
|
||||
storage.malFormedMap[" "] = "messageId"
|
||||
|
||||
val trigger = storage.sanitizeTriggers("messageId", listOf(" "), triggersMap)
|
||||
|
||||
assertNull(trigger)
|
||||
assertFalse(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a non previously stored malformed trigger WHEN calling sanitizeTriggers THEN report malformed and return null`() {
|
||||
val triggersMap = mapOf("trigger-1" to "trigger-1-expression")
|
||||
|
||||
val trigger = storage.sanitizeTriggers("messageId", listOf(" "), triggersMap)
|
||||
|
||||
assertNull(trigger)
|
||||
assertTrue(storage.malFormedMap.containsKey(" "))
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@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("messageId", 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(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
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(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
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(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
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(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
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(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
Message.Metadata("same-id"),
|
||||
)
|
||||
|
||||
every { helper.evalJexl(any()) } throws NimbusException.EvaluationException("")
|
||||
|
||||
val result = storage.isMessageEligible(message, helper)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a previously malformed trigger WHEN calling isMessageEligible THEN return false and not evaluate`() {
|
||||
val helper: GleanPlumbMessageHelper = mockk(relaxed = true)
|
||||
val message = Message(
|
||||
"same-id",
|
||||
mockk(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
Message.Metadata("same-id"),
|
||||
)
|
||||
|
||||
storage.malFormedMap["trigger"] = "same-id"
|
||||
|
||||
every { helper.evalJexl(any()) } throws NimbusException.EvaluationException("")
|
||||
|
||||
val result = storage.isMessageEligible(message, helper)
|
||||
|
||||
assertFalse(result)
|
||||
verify(exactly = 0) { helper.evalJexl("trigger") }
|
||||
assertFalse(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a non previously malformed trigger WHEN calling isMessageEligible THEN return false and not evaluate`() {
|
||||
val helper: GleanPlumbMessageHelper = mockk(relaxed = true)
|
||||
val message = Message(
|
||||
"same-id",
|
||||
mockk(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
Message.Metadata("same-id"),
|
||||
)
|
||||
|
||||
every { helper.evalJexl(any()) } throws NimbusException.EvaluationException("")
|
||||
|
||||
assertFalse(storage.malFormedMap.containsKey("trigger"))
|
||||
|
||||
val result = storage.isMessageEligible(message, helper)
|
||||
|
||||
assertFalse(result)
|
||||
assertTrue(storage.malFormedMap.containsKey("trigger"))
|
||||
assertTrue(malformedWasReported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN none available messages are eligible WHEN calling getNextMessage THEN return null`() {
|
||||
val spiedStorage = spyk(storage)
|
||||
val message = Message(
|
||||
"same-id",
|
||||
mockk(relaxed = true),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
Message.Metadata("same-id"),
|
||||
)
|
||||
|
||||
every { spiedStorage.isMessageEligible(any(), any()) } returns false
|
||||
|
||||
val result = spiedStorage.getNextMessage(MessageSurfaceId.HOMESCREEN, 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",
|
||||
createMessageData(surface = MessageSurfaceId.HOMESCREEN),
|
||||
action = "action",
|
||||
mockk(relaxed = true),
|
||||
listOf("trigger"),
|
||||
Message.Metadata("same-id"),
|
||||
)
|
||||
|
||||
every { spiedStorage.isMessageEligible(any(), any()) } returns true
|
||||
every { spiedStorage.isMessageUnderExperiment(any(), any()) } returns false
|
||||
|
||||
val result = spiedStorage.getNextMessage(MessageSurfaceId.HOMESCREEN, 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 = createMessageData(isControl = 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(MessageSurfaceId.HOMESCREEN, 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 = createMessageData()
|
||||
val controlMessageData: MessageData = createMessageData(isControl = true)
|
||||
|
||||
every { spiedStorage.getOnControlBehavior() } returns SHOW_NEXT_MESSAGE
|
||||
|
||||
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(
|
||||
MessageSurfaceId.HOMESCREEN,
|
||||
listOf(controlMessage, message),
|
||||
)
|
||||
|
||||
verify { messagingFeature.recordExposure() }
|
||||
assertEquals(message.id, result!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN a storage instance is created THEN do not invoke the feature`() = runTest {
|
||||
storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
gleanPlumb,
|
||||
messagingFeature,
|
||||
)
|
||||
|
||||
// We should not be using the feature holder until getMessages is called.
|
||||
verify { messagingFeature wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling getMessage THEN return message with matching key OR null if doesn't exist`() =
|
||||
runTest {
|
||||
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,
|
||||
)
|
||||
|
||||
coEvery { metadataStorage.getMetadata() } returns mapOf(
|
||||
"message-1" to Message.Metadata(
|
||||
id = "message-1",
|
||||
),
|
||||
)
|
||||
|
||||
val storage = NimbusMessagingStorage(
|
||||
testContext,
|
||||
metadataStorage,
|
||||
reportMalformedMessage,
|
||||
gleanPlumb,
|
||||
messagingFeature,
|
||||
)
|
||||
|
||||
assertEquals("high-message", storage.getMessage("high-message")?.id)
|
||||
assertEquals("medium-message", storage.getMessage("medium-message")?.id)
|
||||
assertEquals("low-message", storage.getMessage("low-message")?.id)
|
||||
assertEquals(null, storage.getMessage("no-message")?.id)
|
||||
}
|
||||
|
||||
private fun createMessageData(
|
||||
action: String = "action-1",
|
||||
style: String = "style-1",
|
||||
triggers: List<String> = listOf("trigger-1"),
|
||||
surface: MessageSurfaceId = MessageSurfaceId.HOMESCREEN,
|
||||
isControl: Boolean = false,
|
||||
) = MessageData(
|
||||
action = Res.string(action),
|
||||
style = style,
|
||||
trigger = triggers,
|
||||
surface = surface,
|
||||
isControl = isControl,
|
||||
)
|
||||
|
||||
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(surface = MessageSurfaceId.HOMESCREEN),
|
||||
"message-2" to createMessageData(surface = MessageSurfaceId.NOTIFICATION),
|
||||
"malformed" to createMessageData(action = "malformed-action"),
|
||||
),
|
||||
): FeatureHolder<Messaging> {
|
||||
val messaging = Messaging(
|
||||
actions = actions,
|
||||
triggers = triggers,
|
||||
messages = messages,
|
||||
styles = styles,
|
||||
)
|
||||
val messagingFeature = FeatureHolder({ mockk(relaxed = true) }, "messaging") {
|
||||
messaging
|
||||
}
|
||||
messagingFeature.withCachedValue(messaging)
|
||||
|
||||
return spyk(messagingFeature)
|
||||
}
|
||||
|
||||
private fun createStyle(priority: Int = 1, maxDisplayCount: Int = 5): StyleData {
|
||||
val style1: StyleData = mockk(relaxed = true)
|
||||
every { style1.priority } returns priority
|
||||
every { style1.maxDisplayCount } returns maxDisplayCount
|
||||
return style1
|
||||
}
|
||||
}
|
@ -1,140 +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.gleanplumb
|
||||
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.just
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import mozilla.components.support.test.robolectric.testContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class OnDiskMessageMetadataStorageTest {
|
||||
|
||||
private lateinit var storage: OnDiskMessageMetadataStorage
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
storage = OnDiskMessageMetadataStorage(
|
||||
testContext,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN metadata is not loaded from disk WHEN calling getMetadata THEN load it`() =
|
||||
runTest {
|
||||
val spiedStorage = spyk(storage)
|
||||
|
||||
coEvery { spiedStorage.readFromDisk() } returns emptyMap()
|
||||
|
||||
spiedStorage.getMetadata()
|
||||
|
||||
verify { spiedStorage.readFromDisk() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN metadata is loaded from disk WHEN calling getMetadata THEN do not load it from disk`() =
|
||||
runTest {
|
||||
val spiedStorage = spyk(storage)
|
||||
|
||||
spiedStorage.metadataMap = hashMapOf("" to Message.Metadata("id"))
|
||||
|
||||
spiedStorage.getMetadata()
|
||||
|
||||
verify(exactly = 0) { spiedStorage.readFromDisk() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling addMetadata THEN add in memory and disk`() = runTest {
|
||||
val spiedStorage = spyk(storage)
|
||||
|
||||
assertTrue(spiedStorage.metadataMap.isEmpty())
|
||||
|
||||
coEvery { spiedStorage.writeToDisk() } just Runs
|
||||
|
||||
spiedStorage.addMetadata(Message.Metadata("id"))
|
||||
|
||||
assertFalse(spiedStorage.metadataMap.isEmpty())
|
||||
coVerify { spiedStorage.writeToDisk() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling updateMetadata THEN delegate to addMetadata`() = runTest {
|
||||
val spiedStorage = spyk(storage)
|
||||
val metadata = Message.Metadata("id")
|
||||
coEvery { spiedStorage.writeToDisk() } just Runs
|
||||
|
||||
spiedStorage.updateMetadata(metadata)
|
||||
|
||||
coVerify { spiedStorage.addMetadata(metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling toJson THEN return an string json representation`() {
|
||||
val metadata = Message.Metadata(
|
||||
id = "id",
|
||||
displayCount = 1,
|
||||
pressed = false,
|
||||
dismissed = false,
|
||||
lastTimeShown = 0L,
|
||||
latestBootIdentifier = "9",
|
||||
)
|
||||
|
||||
val expected =
|
||||
"""{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}"""
|
||||
|
||||
assertEquals(expected, metadata.toJson())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling toMetadata THEN return Metadata representation`() {
|
||||
val json =
|
||||
"""{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}"""
|
||||
|
||||
val jsonObject = JSONObject(json)
|
||||
|
||||
val metadata = Message.Metadata(
|
||||
id = "id",
|
||||
displayCount = 1,
|
||||
pressed = false,
|
||||
dismissed = false,
|
||||
lastTimeShown = 0L,
|
||||
latestBootIdentifier = "9",
|
||||
)
|
||||
|
||||
assertEquals(metadata, jsonObject.toMetadata())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN calling toMetadataMap THEN return map representation`() {
|
||||
val json =
|
||||
"""[{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}]"""
|
||||
|
||||
val jsonArray = JSONArray(json)
|
||||
|
||||
val metadata = Message.Metadata(
|
||||
id = "id",
|
||||
displayCount = 1,
|
||||
pressed = false,
|
||||
dismissed = false,
|
||||
lastTimeShown = 0L,
|
||||
latestBootIdentifier = "9",
|
||||
)
|
||||
|
||||
assertEquals(metadata, jsonArray.toMetadataMap()[metadata.id])
|
||||
}
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
package org.mozilla.fenix.utils
|
||||
|
||||
import android.os.Build
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
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.helpers.FenixRobolectricTestRunner
|
||||
import org.mozilla.fenix.utils.BootUtils.Companion.getBootIdentifier
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
private const val NO_BOOT_IDENTIFIER = "no boot identifier available"
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class BootUtilsTest {
|
||||
|
||||
private lateinit var bootUtils: BootUtils
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
bootUtils = mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.M])
|
||||
fun `WHEN no boot id file & Android version is less than N(24) THEN getBootIdentifier returns NO_BOOT_IDENTIFIER`() {
|
||||
every { bootUtils.bootIdFileExists }.returns(false)
|
||||
|
||||
assertEquals(NO_BOOT_IDENTIFIER, getBootIdentifier(testContext, bootUtils))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.M])
|
||||
fun `WHEN boot id file returns null & Android version is less than N(24) THEN getBootIdentifier returns NO_BOOT_IDENTIFIER`() {
|
||||
every { bootUtils.bootIdFileExists }.returns(true)
|
||||
every { bootUtils.deviceBootId }.returns(null)
|
||||
|
||||
assertEquals(NO_BOOT_IDENTIFIER, getBootIdentifier(testContext, bootUtils))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.M])
|
||||
fun `WHEN boot id file has text & Android version is less than N(24) THEN getBootIdentifier returns the boot id`() {
|
||||
every { bootUtils.bootIdFileExists }.returns(true)
|
||||
val bootId = "test"
|
||||
every { bootUtils.deviceBootId }.returns(bootId)
|
||||
|
||||
assertEquals(bootId, getBootIdentifier(testContext, bootUtils))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.M])
|
||||
fun `WHEN boot id file has text with whitespace & Android version is less than N(24) THEN getBootIdentifier returns the trimmed boot id`() {
|
||||
every { bootUtils.bootIdFileExists }.returns(true)
|
||||
val bootId = " test "
|
||||
every { bootUtils.deviceBootId }.returns(bootId)
|
||||
|
||||
assertEquals(bootId, getBootIdentifier(testContext, bootUtils))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.N])
|
||||
fun `WHEN Android version is N(24) THEN getBootIdentifier returns the boot count`() {
|
||||
val bootCount = "9"
|
||||
every { bootUtils.getDeviceBootCount(any()) }.returns(bootCount)
|
||||
assertEquals(bootCount, getBootIdentifier(testContext, bootUtils))
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [Build.VERSION_CODES.O])
|
||||
fun `WHEN Android version is more than N(24) THEN getBootIdentifier returns the boot count`() {
|
||||
val bootCount = "9"
|
||||
every { bootUtils.getDeviceBootCount(any()) }.returns(bootCount)
|
||||
assertEquals(bootCount, getBootIdentifier(testContext, bootUtils))
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue