Bug 1805683 - upstream Nimbus Messaging from Fenix to Android Components

fenix/113.0
Titouan Thibaud 1 year ago committed by mergify[bot]
parent 578541199d
commit e9246a0aa3

@ -77,7 +77,7 @@ mr2022:
type: json
description: This property provides a lookup table of whether or not the given section should be enabled.
nimbus-system:
description: "Configuration of the Nimbus System in Fenix.\n"
description: "Configuration of the Nimbus System in Android.\n"
hasExposure: true
exposureDescription: ""
variables:

@ -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.

@ -8712,106 +8712,6 @@ addresses:
tags:
- Autofill
messaging:
message_shown:
type: event
description: |
A message was shown to the user.
extra_keys:
message_key:
description: The id of the message
type: string
bugs:
- https://github.com/mozilla-mobile/fenix/issues/24224
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/24426
- https://github.com/mozilla-mobile/firefox-android/pull/1101
notification_emails:
- android-probes@mozilla.com
- kbrosnan@mozilla.com
data_sensitivity:
- interaction
expires: never
message_dismissed:
type: event
description: |
A message was dismissed by the user.
extra_keys:
message_key:
description: The id of the message
type: string
bugs:
- https://github.com/mozilla-mobile/fenix/issues/24224
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/24224
- https://github.com/mozilla-mobile/firefox-android/pull/1101
notification_emails:
- android-probes@mozilla.com
- kbrosnan@mozilla.com
data_sensitivity:
- interaction
expires: never
message_clicked:
type: event
description: |
A message was clicked by the user.
extra_keys:
message_key:
description: The id of the message
type: string
action_uuid:
description: The uuid of the action
type: string
bugs:
- https://github.com/mozilla-mobile/fenix/issues/24224
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/24224
- https://github.com/mozilla-mobile/firefox-android/pull/1101
notification_emails:
- android-probes@mozilla.com
- kbrosnan@mozilla.com
data_sensitivity:
- interaction
expires: never
message_expired:
type: event
description: |
A message maxDisplayCount has been surpassed.
extra_keys:
message_key:
description: The id of the message
type: string
bugs:
- https://github.com/mozilla-mobile/fenix/issues/24224
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/24224
- https://github.com/mozilla-mobile/firefox-android/pull/1101
notification_emails:
- android-probes@mozilla.com
- kbrosnan@mozilla.com
data_sensitivity:
- interaction
expires: never
malformed:
type: event
description: |
A message was malformed.
extra_keys:
message_key:
description: The id of the message
type: string
bugs:
- https://github.com/mozilla-mobile/fenix/issues/24224
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/24224
- https://github.com/mozilla-mobile/firefox-android/pull/1101
notification_emails:
- android-probes@mozilla.com
- kbrosnan@mozilla.com
data_sensitivity:
- interaction
expires: never
wallpapers:
wallpaper_settings_opened:
type: event

@ -9,8 +9,133 @@ channels:
- beta
- nightly
- developer
includes:
- messaging.fml.yaml
import:
- path: ../../android-components/components/service/nimbus/messaging.fml.yaml
channel: release
features:
messaging:
- 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:
arturo-notification:
text: "This is our notification test"
surface: notification
action: "https://developer.android.com/guide/background/persistent/getting-started/define-work"
trigger: [ "USER_FR_SPEAKER" ]
style: "PERSISTENT"
button-label: "My new button"
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)
features:
homescreen:
description: The homescreen that the user goes to when they press home or new tab.

@ -8,6 +8,8 @@ import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import kotlinx.coroutines.test.runTest
import mozilla.components.service.nimbus.messaging.FxNimbusMessaging
import mozilla.components.service.nimbus.messaging.Messaging
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -16,11 +18,9 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.gleanplumb.CustomAttributeProvider
import org.mozilla.fenix.helpers.HomeActivityIntentTestRule
import org.mozilla.fenix.helpers.TestHelper
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.nimbus.Messaging
import org.mozilla.fenix.messaging.CustomAttributeProvider
/**
* This test is to test the integrity of messages hardcoded in the FML.
@ -45,7 +45,7 @@ class NimbusMessagingMessageTest {
fun setUp() {
context = TestHelper.appContext
mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
feature = FxNimbus.features.messaging.value()
feature = FxNimbusMessaging.features.messaging.value()
}
/**

@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import androidx.test.rule.GrantPermissionRule.grant
import androidx.test.uiautomator.UiDevice
import mozilla.components.service.nimbus.messaging.FxNimbusMessaging
import org.json.JSONObject
import org.junit.Before
import org.junit.Rule
@ -85,7 +86,7 @@ class NimbusMessagingNotificationTest {
mDevice.openNotification()
notificationShade {
val data =
FxNimbus.features.messaging.value().messages["test-default-browser-notification"]
FxNimbusMessaging.features.messaging.value().messages["test-default-browser-notification"]
verifySystemNotificationExists(data!!.title!!)
verifySystemNotificationExists(data.text)
}

@ -6,6 +6,8 @@ package org.mozilla.fenix.ui
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import mozilla.components.service.nimbus.messaging.FxNimbusMessaging
import mozilla.components.service.nimbus.messaging.Messaging
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
@ -13,11 +15,9 @@ import org.junit.Test
import org.mozilla.experiments.nimbus.NimbusInterface
import org.mozilla.experiments.nimbus.internal.NimbusException
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.gleanplumb.CustomAttributeProvider
import org.mozilla.fenix.helpers.HomeActivityIntentTestRule
import org.mozilla.fenix.helpers.TestHelper
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.nimbus.Messaging
import org.mozilla.fenix.messaging.CustomAttributeProvider
/**
* Test to instantiate Nimbus and automatically test all trigger expressions shipping with the app.
@ -39,7 +39,7 @@ class NimbusMessagingTriggerTest {
fun setUp() {
mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
nimbus = TestHelper.appContext.components.analytics.experiments
feature = FxNimbus.features.messaging.value()
feature = FxNimbusMessaging.features.messaging.value()
}
@Test

@ -287,7 +287,7 @@
android:theme="@style/DialogActivityTheme" />
<activity
android:name=".gleanplumb.NotificationClickedReceiverActivity"
android:name=".messaging.NotificationClickedReceiverActivity"
android:exported="false" />
<service
@ -336,7 +336,7 @@
android:exported="false" />
<service
android:name=".gleanplumb.NotificationDismissedService"
android:name=".messaging.NotificationDismissedService"
android:exported="false" />
<service

@ -97,7 +97,6 @@ import org.mozilla.fenix.ext.hasTopDestination
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.setNavigationIcon
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.MessageNotificationWorker
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.intent.AssistIntentProcessor
import org.mozilla.fenix.home.intent.CrashReporterIntentProcessor
@ -112,6 +111,7 @@ import org.mozilla.fenix.library.bookmarks.DesktopFolders
import org.mozilla.fenix.library.history.HistoryFragmentDirections
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
import org.mozilla.fenix.library.recentlyclosed.RecentlyClosedFragmentDirections
import org.mozilla.fenix.messaging.MessageNotificationWorker
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.onboarding.ReEngagementNotificationWorker

@ -15,9 +15,11 @@ import mozilla.components.lib.crash.service.CrashReporterService
import mozilla.components.lib.crash.service.GleanCrashReporterService
import mozilla.components.lib.crash.service.MozillaSocorroService
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.service.nimbus.messaging.FxNimbusMessaging
import mozilla.components.service.nimbus.messaging.NimbusMessagingStorage
import mozilla.components.service.nimbus.messaging.OnDiskMessageMetadataStorage
import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.Config
import org.mozilla.fenix.GleanMetrics.Messaging
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.ReleaseChannel
@ -28,10 +30,7 @@ import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.components.metrics.MetricsStorage
import org.mozilla.fenix.experiments.createNimbus
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.CustomAttributeProvider
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
import org.mozilla.fenix.gleanplumb.OnDiskMessageMetadataStorage
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.messaging.CustomAttributeProvider
import org.mozilla.fenix.perf.lazyMonitored
import org.mozilla.fenix.utils.BrowsersCache
import org.mozilla.geckoview.BuildConfig.MOZ_APP_BUILDID
@ -151,10 +150,7 @@ class Analytics(
context = context,
metadataStorage = OnDiskMessageMetadataStorage(context),
gleanPlumb = experiments,
reportMalformedMessage = {
Messaging.malformed.record(Messaging.MalformedExtra(it))
},
messagingFeature = FxNimbus.features.messaging,
messagingFeature = FxNimbusMessaging.features.messaging,
attributeProvider = CustomAttributeProvider,
)
}

@ -32,10 +32,10 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.filterState
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.sort
import org.mozilla.fenix.gleanplumb.state.MessagingMiddleware
import org.mozilla.fenix.home.PocketUpdatesMiddleware
import org.mozilla.fenix.home.blocklist.BlocklistHandler
import org.mozilla.fenix.home.blocklist.BlocklistMiddleware
import org.mozilla.fenix.messaging.state.MessagingMiddleware
import org.mozilla.fenix.perf.AppStartReasonProvider
import org.mozilla.fenix.perf.StartupActivityLog
import org.mozilla.fenix.perf.StartupStateProvider

@ -8,11 +8,11 @@ import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.lib.crash.Crash.NativeCodeCrash
import mozilla.components.lib.state.Action
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.MessageSurfaceId
import mozilla.components.service.pocket.PocketStory
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessagingState
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
@ -22,7 +22,7 @@ import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.library.history.PendingDeletionHistory
import org.mozilla.fenix.nimbus.MessageSurfaceId
import org.mozilla.fenix.messaging.MessagingState
import org.mozilla.fenix.wallpapers.Wallpaper
/**

@ -12,7 +12,6 @@ import mozilla.components.lib.state.State
import mozilla.components.service.pocket.PocketStory
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
import org.mozilla.fenix.gleanplumb.MessagingState
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
@ -22,6 +21,7 @@ import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.library.history.PendingDeletionHistory
import org.mozilla.fenix.messaging.MessagingState
import org.mozilla.fenix.wallpapers.WallpaperState
/**

@ -11,11 +11,11 @@ import mozilla.components.service.pocket.ext.recordNewImpression
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.filterOutTab
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.gleanplumb.state.MessagingReducer
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.messaging.state.MessagingReducer
/**
* Reducer for [AppStore].

@ -8,6 +8,8 @@ import android.content.Context
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.service.nimbus.NimbusAppInfo
import mozilla.components.service.nimbus.NimbusBuilder
import mozilla.components.service.nimbus.messaging.FxNimbusMessaging
import mozilla.components.service.nimbus.messaging.NimbusSystem
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.experiments.nimbus.NimbusInterface
import org.mozilla.experiments.nimbus.internal.NimbusException
@ -15,9 +17,8 @@ import org.mozilla.fenix.BuildConfig
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.CustomAttributeProvider
import org.mozilla.fenix.messaging.CustomAttributeProvider
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.nimbus.NimbusSystem
import org.mozilla.fenix.utils.Settings
/**
@ -97,7 +98,7 @@ fun NimbusException.isReportableError(): Boolean {
*/
fun NimbusInterface.maybeFetchExperiments(
context: Context,
feature: NimbusSystem = FxNimbus.features.nimbusSystem.value(),
feature: NimbusSystem = FxNimbusMessaging.features.nimbusSystem.value(),
currentTimeMillis: Long = System.currentTimeMillis(),
) {
val minimumPeriodMinutes = if (!context.settings().nimbusUsePreview) {

@ -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"),
)
}

@ -83,9 +83,6 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.scaleToBottomOfView
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.DefaultMessageController
import org.mozilla.fenix.gleanplumb.MessagingFeature
import org.mozilla.fenix.gleanplumb.NimbusMessagingController
import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.privatebrowsing.controller.DefaultPrivateBrowsingController
@ -105,6 +102,9 @@ import org.mozilla.fenix.home.toolbar.DefaultToolbarController
import org.mozilla.fenix.home.toolbar.SearchSelectorBinding
import org.mozilla.fenix.home.toolbar.SearchSelectorMenuBinding
import org.mozilla.fenix.home.topsites.DefaultTopSitesView
import org.mozilla.fenix.messaging.DefaultMessageController
import org.mozilla.fenix.messaging.FenixNimbusMessagingController
import org.mozilla.fenix.messaging.MessagingFeature
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.onboarding.controller.DefaultOnboardingController
@ -356,7 +356,7 @@ class HomeFragment : Fragment() {
engine = components.core.engine,
messageController = DefaultMessageController(
appStore = components.appStore,
messagingController = NimbusMessagingController(components.analytics.messagingStorage),
messagingController = FenixNimbusMessagingController(components.analytics.messagingStorage),
homeActivity = activity,
),
store = store,

@ -15,8 +15,8 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.nimbus.messaging.Message
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.BottomSpacerViewHolder
import org.mozilla.fenix.home.TopPlaceholderViewHolder
import org.mozilla.fenix.home.collections.CollectionViewHolder

@ -26,6 +26,7 @@ import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tab.collections.ext.invoke
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.support.ktx.android.view.showKeyboard
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.BrowserDirection
@ -48,10 +49,9 @@ import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.metrics.MetricsUtils
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessageController
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.messaging.MessageController
import org.mozilla.fenix.onboarding.WallpaperOnboardingDialogFragment.Companion.THUMBNAILS_SELECTION_COUNT
import org.mozilla.fenix.search.toolbar.SearchSelectorInteractor
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu

@ -7,10 +7,10 @@ package org.mozilla.fenix.home.sessioncontrol
import mozilla.components.feature.tab.collections.Tab
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.pocket.PocketStory
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketStoriesController
import org.mozilla.fenix.home.pocket.PocketStoriesInteractor

@ -11,6 +11,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.pocket.PocketStory
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppState
@ -18,11 +19,10 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.shouldShowRecentSyncedTabs
import org.mozilla.fenix.ext.shouldShowRecentTabs
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.nimbus.MessageSurfaceId
import org.mozilla.fenix.messaging.FenixMessageSurfaceId
import org.mozilla.fenix.nimbus.OnboardingPanel
import org.mozilla.fenix.onboarding.HomeCFRPresenter
import org.mozilla.fenix.onboarding.OnboardingState
@ -165,7 +165,7 @@ private fun AppState.toAdapterList(settings: Settings): List<AdapterItem> = when
expandedCollections,
recentBookmarks,
showCollectionPlaceholder,
messaging.messageToShow[MessageSurfaceId.HOMESCREEN],
messaging.messageToShow[FenixMessageSurfaceId.HOMESCREEN],
shouldShowRecentTabs(settings),
shouldShowRecentSyncedTabs(settings),
recentHistory,

@ -13,12 +13,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.service.nimbus.messaging.Message
import org.mozilla.fenix.R
import org.mozilla.fenix.components.components
import org.mozilla.fenix.compose.ComposeViewHolder
import org.mozilla.fenix.compose.MessageCard
import org.mozilla.fenix.compose.MessageCardColors
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.wallpapers.Wallpaper

@ -2,10 +2,11 @@
* 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
package org.mozilla.fenix.messaging
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import mozilla.components.service.nimbus.messaging.JexlAttributeProvider
import org.json.JSONObject
import org.mozilla.fenix.ext.areNotificationsEnabledSafe
import org.mozilla.fenix.ext.settings
@ -18,7 +19,7 @@ import java.util.Locale
* Custom attributes that the messaging framework will use to evaluate if message is eligible
* to be shown.
*/
object CustomAttributeProvider {
object CustomAttributeProvider : JexlAttributeProvider {
private val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
/**
@ -48,7 +49,7 @@ object CustomAttributeProvider {
*
* This is used to drive display triggers of messages.
*/
fun getCustomAttributes(context: Context): JSONObject {
override fun getCustomAttributes(context: Context): JSONObject {
val now = Calendar.getInstance()
val settings = context.settings()
return JSONObject(

@ -2,9 +2,11 @@
* 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
package org.mozilla.fenix.messaging
import android.content.Intent
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.NimbusMessagingController
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageClicked

@ -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,
)

@ -2,7 +2,9 @@
* 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
package org.mozilla.fenix.messaging
import mozilla.components.service.nimbus.messaging.Message
/**
* Controls all the interactions with a [Message].

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.messaging
import android.app.Activity
import android.app.Notification
@ -18,14 +18,14 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import mozilla.components.service.nimbus.messaging.FxNimbusMessaging
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.support.base.ids.SharedIdsHelper
import mozilla.components.support.utils.BootUtils
import org.mozilla.fenix.ext.areNotificationsEnabledSafe
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.nimbus.MessageSurfaceId
import org.mozilla.fenix.onboarding.ensureMarketingChannelExists
import org.mozilla.fenix.perf.runBlockingIncrement
import org.mozilla.fenix.utils.BootUtils
import org.mozilla.fenix.utils.IntentUtils
import org.mozilla.fenix.utils.createBaseNotification
import java.util.concurrent.TimeUnit
@ -55,7 +55,7 @@ class MessageNotificationWorker(
val messagingStorage = context.components.analytics.messagingStorage
val messages = runBlockingIncrement { messagingStorage.getMessages() }
val nextMessage =
messagingStorage.getNextMessage(MessageSurfaceId.NOTIFICATION, messages)
messagingStorage.getNextMessage(FenixMessageSurfaceId.NOTIFICATION, messages)
?: return Result.success()
val currentBootUniqueIdentifier = BootUtils.getBootIdentifier(context)
@ -65,7 +65,7 @@ class MessageNotificationWorker(
return Result.success()
}
val nimbusMessagingController = NimbusMessagingController(messagingStorage)
val nimbusMessagingController = FenixNimbusMessagingController(messagingStorage)
// Update message as displayed.
val updatedMessage =
@ -147,7 +147,7 @@ class MessageNotificationWorker(
* Initialize the [Worker] to begin polling Nimbus.
*/
fun setMessageNotificationWorker(context: Context) {
val messaging = FxNimbus.features.messaging
val messaging = FxNimbusMessaging.features.messaging
val featureConfig = messaging.value()
val notificationConfig = featureConfig.notificationConfig
val pollingInterval = notificationConfig.refreshInterval.toLong()
@ -189,10 +189,10 @@ class NotificationDismissedService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
val nimbusMessagingController =
NimbusMessagingController(applicationContext.components.analytics.messagingStorage)
FenixNimbusMessagingController(applicationContext.components.analytics.messagingStorage)
// Get the relevant message.
val message = intent.getStringExtra(DISMISSED_MESSAGE_ID) ?.let { messageId ->
val message = intent.getStringExtra(DISMISSED_MESSAGE_ID)?.let { messageId ->
runBlockingIncrement { nimbusMessagingController.getMessage(messageId) }
}
@ -218,10 +218,10 @@ class NotificationClickedReceiverActivity : Activity() {
super.onCreate(savedInstanceState)
val nimbusMessagingController =
NimbusMessagingController(components.analytics.messagingStorage)
FenixNimbusMessagingController(components.analytics.messagingStorage)
// Get the relevant message.
val message = intent.getStringExtra(CLICKED_MESSAGE_ID) ?.let { messageId ->
val message = intent.getStringExtra(CLICKED_MESSAGE_ID)?.let { messageId ->
runBlockingIncrement { nimbusMessagingController.getMessage(messageId) }
}

@ -2,12 +2,11 @@
* 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
package org.mozilla.fenix.messaging
import mozilla.components.support.base.feature.LifecycleAwareFeature
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
import org.mozilla.fenix.nimbus.MessageSurfaceId
/**
* A message observer that updates the provided.
@ -15,7 +14,7 @@ import org.mozilla.fenix.nimbus.MessageSurfaceId
class MessagingFeature(val appStore: AppStore) : LifecycleAwareFeature {
override fun start() {
appStore.dispatch(MessagingAction.Evaluate(MessageSurfaceId.HOMESCREEN))
appStore.dispatch(MessagingAction.Evaluate(FenixMessageSurfaceId.HOMESCREEN))
}
override fun stop() = Unit

@ -2,9 +2,10 @@
* 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
package org.mozilla.fenix.messaging
import org.mozilla.fenix.nimbus.MessageSurfaceId
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.MessageSurfaceId
/**
* Represent all the state related to the Messaging framework.

@ -2,13 +2,16 @@
* 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
package org.mozilla.fenix.messaging.state
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.MiddlewareContext
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.NimbusMessagingController
import mozilla.components.service.nimbus.messaging.NimbusMessagingStorage
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
@ -18,15 +21,13 @@ 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.NimbusMessagingController
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
import org.mozilla.fenix.messaging.FenixNimbusMessagingController
typealias AppStoreMiddlewareContext = MiddlewareContext<AppState, AppAction>
class MessagingMiddleware(
private val messagingStorage: NimbusMessagingStorage,
private val controller: NimbusMessagingController = NimbusMessagingController(messagingStorage),
private val controller: NimbusMessagingController = FenixNimbusMessagingController(messagingStorage),
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO),
) : Middleware<AppState, AppAction> {

@ -2,14 +2,14 @@
* 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
package org.mozilla.fenix.messaging.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
import org.mozilla.fenix.messaging.MessagingState
/**
* Reducer for [MessagingState].

@ -18,9 +18,9 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.google.android.material.button.MaterialButton
import mozilla.components.service.nimbus.messaging.MESSAGING_FEATURE_ID
import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
import org.mozilla.fenix.R
import org.mozilla.fenix.gleanplumb.MESSAGING_FEATURE_ID
import org.mozilla.fenix.settings.studies.CustomViewHolder.SectionViewHolder
import org.mozilla.fenix.settings.studies.CustomViewHolder.StudyViewHolder

@ -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"

@ -14,6 +14,8 @@ import mozilla.components.concept.sync.DeviceType
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.MessageData
import mozilla.components.service.pocket.PocketStory
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
@ -32,7 +34,6 @@ import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.filterOut
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.home.CurrentMode
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
@ -44,8 +45,7 @@ import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.nimbus.MessageData
import org.mozilla.fenix.nimbus.MessageSurfaceId
import org.mozilla.fenix.messaging.FenixMessageSurfaceId
import org.mozilla.fenix.onboarding.FenixOnboarding
class AppStoreTest {
@ -118,7 +118,7 @@ class AppStoreTest {
val message = Message(
"message",
MessageData(surface = MessageSurfaceId.HOMESCREEN),
MessageData(surface = FenixMessageSurfaceId.HOMESCREEN),
"action",
mockk(),
emptyList(),

@ -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])
}
}

@ -32,6 +32,7 @@ import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
@ -61,12 +62,11 @@ import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessageController
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.messaging.MessageController
import org.mozilla.fenix.onboarding.WallpaperOnboardingDialogFragment.Companion.THUMBNAILS_SELECTION_COUNT
import org.mozilla.fenix.search.toolbar.SearchSelectorMenu
import org.mozilla.fenix.settings.SupportUtils

@ -8,13 +8,13 @@ import io.mockk.every
import io.mockk.mockk
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.pocket.PocketStory
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup

@ -2,12 +2,15 @@
* 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
package org.mozilla.fenix.messaging
import androidx.core.net.toUri
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.MessageData
import mozilla.components.service.nimbus.messaging.NimbusMessagingController
import mozilla.components.support.test.robolectric.testContext
import mozilla.telemetry.glean.testing.GleanTestRule
import org.junit.Before
@ -19,7 +22,6 @@ 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.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.nimbus.MessageData
@RunWith(FenixRobolectricTestRunner::class)
class DefaultMessageControllerTest {

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.messaging
import io.mockk.spyk
import io.mockk.verify
@ -12,7 +12,6 @@ import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction
import org.mozilla.fenix.nimbus.MessageSurfaceId
class MessagingFeatureTest {
@OptIn(ExperimentalCoroutinesApi::class)
@ -26,6 +25,6 @@ class MessagingFeatureTest {
binding.start()
verify { appStore.dispatch(MessagingAction.Evaluate(MessageSurfaceId.HOMESCREEN)) }
verify { appStore.dispatch(MessagingAction.Evaluate(FenixMessageSurfaceId.HOMESCREEN)) }
}
}

@ -2,13 +2,18 @@
* 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
package org.mozilla.fenix.messaging.state
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.advanceUntilIdle
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.MessageData
import mozilla.components.service.nimbus.messaging.NimbusMessagingController
import mozilla.components.service.nimbus.messaging.NimbusMessagingStorage
import mozilla.components.service.nimbus.messaging.StyleData
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.rule.MainCoroutineRule
@ -24,13 +29,9 @@ import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageCl
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.MessageDismissed
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.Restore
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessagingState
import org.mozilla.fenix.gleanplumb.NimbusMessagingController
import org.mozilla.fenix.gleanplumb.NimbusMessagingStorage
import org.mozilla.fenix.nimbus.MessageData
import org.mozilla.fenix.nimbus.MessageSurfaceId
import org.mozilla.fenix.nimbus.StyleData
import org.mozilla.fenix.messaging.FenixMessageSurfaceId
import org.mozilla.fenix.messaging.FenixNimbusMessagingController
import org.mozilla.fenix.messaging.MessagingState
class MessagingMiddlewareTest {
@get:Rule
@ -42,7 +43,7 @@ class MessagingMiddlewareTest {
@Before
fun setUp() {
messagingStorage = mockk(relaxed = true)
controller = NimbusMessagingController(messagingStorage) { 0 }
controller = FenixNimbusMessagingController(messagingStorage) { 0 }
}
@Test
@ -86,14 +87,14 @@ class MessagingMiddlewareTest {
every {
messagingStorage.getNextMessage(
MessageSurfaceId.HOMESCREEN,
FenixMessageSurfaceId.HOMESCREEN,
any(),
)
} returns message
assertEquals(0, store.state.messaging.messageToShow.size)
store.dispatch(Evaluate(MessageSurfaceId.HOMESCREEN)).joinBlocking()
store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
store.waitUntilIdle()
// UpdateMessageToShow to causes messageToShow to append
@ -212,12 +213,12 @@ class MessagingMiddlewareTest {
every {
messagingStorage.getNextMessage(
MessageSurfaceId.HOMESCREEN,
FenixMessageSurfaceId.HOMESCREEN,
any(),
)
} returns message
store.dispatch(Evaluate(MessageSurfaceId.HOMESCREEN))
store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN))
store.waitUntilIdle()
assertEquals(1, store.state.messaging.messages.first().metadata.displayCount)
@ -245,12 +246,12 @@ class MessagingMiddlewareTest {
every {
messagingStorage.getNextMessage(
MessageSurfaceId.HOMESCREEN,
FenixMessageSurfaceId.HOMESCREEN,
any(),
)
} returns message1
store.dispatch(Evaluate(MessageSurfaceId.HOMESCREEN)).joinBlocking()
store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
store.waitUntilIdle()
assertEquals(messageDisplayed1, store.state.messaging.messages[0])
@ -277,12 +278,12 @@ class MessagingMiddlewareTest {
every {
messagingStorage.getNextMessage(
MessageSurfaceId.HOMESCREEN,
FenixMessageSurfaceId.HOMESCREEN,
any(),
)
} returns message
store.dispatch(Evaluate(MessageSurfaceId.HOMESCREEN)).joinBlocking()
store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
store.waitUntilIdle()
assertEquals(messageDisplayed.metadata.displayCount, store.state.messaging.messages[0].metadata.displayCount)
@ -307,12 +308,12 @@ class MessagingMiddlewareTest {
every {
messagingStorage.getNextMessage(
MessageSurfaceId.HOMESCREEN,
FenixMessageSurfaceId.HOMESCREEN,
any(),
)
} returns message
store.dispatch(Evaluate(MessageSurfaceId.HOMESCREEN)).joinBlocking()
store.dispatch(Evaluate(FenixMessageSurfaceId.HOMESCREEN)).joinBlocking()
store.waitUntilIdle()
assertEquals(0, store.state.messaging.messages.size)

@ -2,9 +2,13 @@
* 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
package org.mozilla.fenix.messaging.state
import io.mockk.mockk
import mozilla.components.service.nimbus.messaging.Message
import mozilla.components.service.nimbus.messaging.MessageData
import mozilla.components.service.nimbus.messaging.MessageSurfaceId
import mozilla.components.service.nimbus.messaging.StyleData
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@ -15,11 +19,8 @@ import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMes
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.Message
import org.mozilla.fenix.gleanplumb.MessagingState
import org.mozilla.fenix.nimbus.MessageData
import org.mozilla.fenix.nimbus.MessageSurfaceId
import org.mozilla.fenix.nimbus.StyleData
import org.mozilla.fenix.messaging.FenixMessageSurfaceId
import org.mozilla.fenix.messaging.MessagingState
class MessagingReducerTest {
@ -45,7 +46,7 @@ class MessagingReducerTest {
assertNull(updatedState.messaging.messageToShow[m.surface])
}
private fun createMessage(id: String, action: String = "action-1", surface: MessageSurfaceId = MessageSurfaceId.HOMESCREEN): Message =
private fun createMessage(id: String, action: String = "action-1", surface: MessageSurfaceId = FenixMessageSurfaceId.HOMESCREEN): Message =
Message(
id = id,
data = MessageData(surface = surface),

@ -10,6 +10,7 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import mozilla.components.service.nimbus.messaging.NimbusSystem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue

@ -19,13 +19,13 @@ import io.mockk.verify
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import mozilla.components.service.nimbus.messaging.MESSAGING_FEATURE_ID
import mozilla.components.support.test.robolectric.testContext
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.experiments.nimbus.internal.EnrolledExperiment
import org.mozilla.fenix.R
import org.mozilla.fenix.gleanplumb.MESSAGING_FEATURE_ID
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.studies.CustomViewHolder.SectionViewHolder
import org.mozilla.fenix.settings.studies.CustomViewHolder.StudyViewHolder

@ -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))
}
}

@ -59,6 +59,8 @@ buildscript {
}
dependencies {
classpath "org.mozilla.appservices:tooling-nimbus-gradle:${Versions.mozilla_appservices}"
classpath FenixDependencies.tools_androidgradle
classpath FenixDependencies.tools_kotlingradle
classpath FenixDependencies.tools_benchmarkgradle

Loading…
Cancel
Save