Skip to main content

Introduction

The mobile messaging system is a feature of Firefox on iOS and Android, designed to send in-app messages directly to users without going through a release cycle.

It allows staff— most likely experiment owners, product owners, user research and marketing teams—

  • to send messages to the user audiences, with rules about when to show them.
  • to experiment with those messages (the appearance or copy of the message)
  • to allow the user to respond to the message (either dismissing, or opening a specified URL)

About this document

This document is a guide for staff who wish to send message users through the experimenter interface.

It is also a living document:

  • the messaging system is under active development, and learning from its MVP.
  • attributes useful in triggers, and deeplink actions will accrue in each of the embedding apps.
  • surfaces are being added to embedding apps.

You can view a demo of sending a survey on mobile here Access Passcode: 9Zx9Lg&M

Edit history

  • Changed is_default_browser to is_default_browser_string.
  • Renamed Glean messages to match the implementation.
  • Added notification surface and associated trigger expressions.
  • v117 - added experiment property to message, removed message-under-experiment.

Scene setting

Nimbus Mobile Messaging is built on top of Nimbus, Mozilla's experimentation platform. Nimbus allows you to send bits of configuration to application features from Experimenter, the web-application staff use to launch and manage experiments and rollouts.

Using Experimenter in the general case is documented elsewhere, so this document is specifically concerned with configuring the messaging feature, via the Branches screen.

my-first-message in the branch configuration screen

The messages that are sent are specified as JSON in the "Value" text area on this screen, so much of this document will consider this the user-interface for sending messages.

{
"messages": {
"my-first-message": {
"surface": "homescreen",
"trigger": [
"USER_EN_SPEAKER",
"USER_RECENTLY_UPDATED"
],

"title": "Enhance your privacy",
"text": "We've improved our tracking protection, tap here to switch it on",
"button-label": "Make it so!",

"action": "OPEN_SETTINGS_TRACKING_PROTECTION"
}
}
}

We will start with this simple example to introduce the concepts, then add more as we go.

Anatomy of a message

Messages have a number of components:

  • the message key. In this example above, this is my-first-message. For most cases, these should be
    • unique so as not to collide with other messages.
    • human readable. By convention, these are kebab-case.
  • the trigger. This is the conditions that must be true for the message to be eligible to be displayed.
  • the content of a message. This is the title of the message, the text and the button-label.
  • the surface. This is app specific.
  • the action that is performed if and when the user taps on the button.

If any of these components are missing, then the message is considered malformed.

Triggers

The app provides the messaging system with attributes. We use JEXL expressions to query these attributes, or trigger messages based on the user's behavior in the app.

To reduce errors and to allow re-use, the JEXL expressions are named. By convention, these names are in SCREAMING_SNAKE_CASE.

The trigger attribute of the message is an array of these named trigger expressions.

If all of these trigger expressions evaluate to true, then the message is said to be eligible.

For example:

{
"messages": {
"my-first-message": {

"trigger": [
"USER_EN_SPEAKER",
"USER_RECENTLY_UPDATED"
],

}
}
}

In the above example, only users who have updated their apps in the last 7 days and who are English speakers.

Message content

The message content is provided as strings, and is what the user sees.

Only the text property is mandatory. If this is missing, then the message is said to be malformed.

{
"messages": {
"my-first-message": {


"title": "Enhance your privacy",
"text": "We've improved our tracking protection, tap here to switch it on",
"button-label": "Make it so!",


}
}
}

If title is missing, then the title is not displayed.

If button-label is missing, then the button is not displayed, but the whole message is tappable.

Surfaces

The surface of the message is the message surface on which the message will be displayed.

These are app specific: the application provides the surface to draw the message on.

SurfaceDescriptionVersions
homescreenOn the new tab/homescreen
notificationA system notification. Permission is required for Android SDK > 13v111
surveyA screen that is shown at startup

Actions

Actions are performed when the user taps on the call to action (i.e. the button).

The actions are implemented as URLs, which may be deeplinks in to the app, web links, or deeplinks in to other apps.

To reduce errors and to allow re-use, action URLs can be named. By convention, these names are in SCREAMING_SNAKE_CASE.

If the specified action property contains a :// it is treated as a bare URL, so one-off URLs may be specified.

String substiutions

Before the final URL is opened, a variable subsitution is made, so identifiers within a pair of braces may be made part of the URL.

Any attribute that Nimbus Mobile Messaging knows about can be used. For example:

{
"messages": {
"my-message": {

"action": "https://mozilla.org/fenix/{locale}/whatsnew/{app_version}"

}
}
}

Additionally, the special {uuid} can be used to generate a new UUID. If detected, this will be recorded as an extra called action-uuid on the message's interaction event.

{
"messages": {

"action": "https://surveys.thirdparty.org/fenix-viewpoint/?client_id={uuid}"

}
}

This allows the client's anonymous survey results to match up to their Glean data while remaining anonymous to the third party.

Other message attributes

There are additional message attributes which don't quite fit anywhere, yet. It is best to leave this unused. We're looking for feedback during the MVP phase.

  • style – this is a string key into the styles object. It will code for visual style, and/or priority.

Current values are DEFAULT, PERSISTENT, WARNING, URGENT, NOTIFICATION.

This currently code for:

  • the message priority. Messages will be shown in descending order of priority.
  • the max-display-count. Messages will be shown to the user for this number of sessions before the message expires.

Localization of messages

Localization of strings and messages is not fully developed in Nimbus: the intention is to integrate with the existing tooling for localizing strings.

For message content, you might use identifiers of pre-translated strings (in Firefox for iOS, Firefox for Android). This might be useful for very common phrases like OK, or Cancel.

However this relies on the exact translations either being coincidentally already in use by the app, or the experimental copy known about before the app was released. For several reasons, we do not recommend this approach.

Instead, we can add multiple messages which do the same thing, but in different langauges.

The messages property is a JSON object, so can accept multiple messages, each with different locale triggers.

{
"messages": {
"my-l10n-message-en": {

"trigger": [

"USER_EN_SPEAKER"
],
"button-label": "Go to settings"
},
"my-l10n-message-de": {

"trigger": [

"USER_DE_SPEAKER"
],
"button-label": "Einstellungen öffnen"
},
"my-l10n-message-es": {

"trigger": [

"USER_ES_SPEAKER"
],
"button-label": "Ir a ajustes"
},
"my-l10n-message-fr": {

"trigger": [

"USER_FR_SPEAKER"
],
"button-label": "Ouvrir les paramètres"
}
}
}

For each of the messages,

  • the message key should have a common prefix. This becomes important when experimenting with messages.
  • the triggers should be the same list of named expressions, but appending the trigger-expression to select for language.

In this manner the locale can be only one value at a time, and only one version of the message is eligible for display at any one time.

Experimenting with messages

So far, we have talked about using Experimenter to push out messages as single branch experiments.

When running experiments, we'll need to configure two or more branches. By convention, each branch will have the same message keys— in the example below: my-first-message.

We should also tell the system that the message is under experiment. This is done by annotating each message with the experiment it came from. For convenience, the system replaces the string {experiment} with the experiment slug at enrollment, so annotating the message with the experiment is done like so:

{
"messages": {
"my-first-message": {
"experiment": "{experiment}"

}
},
}

Experimenting with localized messages

We saw when localizing messages that the branch provided different messages based on adding additional trigger expressions.

It is important that:

  • each of the different languages represented in one branch is represented in all branches.
  • for the messages in each branch, no more than one message will be eligible for display at any one time. In the example below, the user's locale can only be set to one language at a time, so only one message is triggered at any one time.
{
"messages": {
"my-l10n-message-en": {
"experiment": "{experiment}",

"surface": "notification",
"trigger": [

"USER_EN_SPEAKER"
]

},
"my-l10n-message-de": {
"experiment": "{experiment}",

"surface": "notification",
"trigger": [

"USER_DE_SPEAKER"
],

},
"my-l10n-message-es": {
"experiment": "{experiment}",

"surface": "notification",
"trigger": [

"USER_ES_SPEAKER"
],

},
"my-l10n-message-fr": {
"experiment": "{experiment}",

"surface": "notification",
"trigger": [

"USER_FR_SPEAKER"
],

}
}
}

Since these triggers are mutually exclusive, the user will only ever be exposed to one message under experiment.

Control messages

For most messages in experiments, we'll need to specify a control message.

The control is specified in a similar way to others:

{
"messages": {
"my-first-message": {
"trigger": [],
"surface": "notification",
"experiment": "{experiment}",
"is-control": true,
}
}
}

Important: The control message must be eligible whenever the treatment message would have been. This means that:

  • the trigger value of the control must match the trigger value of the treatment message.
  • the surface value of the control must match the surface value of the treatment message.
  • the style value of the control must match the style value of the treatment message.

For localized messages, we will need to provide a set of contol messages that matches the treatment messages, so that the control message is triggered for all and only the same circumstances as the treatment.

{
"messages": {
"my-l10n-message-en": {
"experiment": "{experiment}",
"is-control": true,
"trigger": [

"USER_EN_SPEAKER"
]
},
"my-l10n-message-de": {
"experiment": "{experiment}",
"is-control": true,
"trigger": [

"USER_DE_SPEAKER"
]
},
"my-l10n-message-es": {
"experiment": "{experiment}",
"is-control": true,
"trigger": [

"USER_ES_SPEAKER"
],

},
"my-l10n-message-fr": {
"experiment": "{experiment}",
"is-control": true,
"trigger": [

"USER_FR_SPEAKER"
]
}
}
}

Control messages (i.e. messages with is-control set to true) that do not have an experiment set to {experiment} will be reported by the client as malformed, since we can't ascertain which experiment they came from.

Displaying the control message

The control message is the placebo, so doesn't make any sense to display to the user, so when a control message is selected for display, what should a message surface actually display?

{
"messages": {
"my-first-message": {
"trigger": [],
"experiment": "{experiment}",
"is-control": true,
}
},
"on-control": "show-next-message"
}

The on-control property controls what happens in this case:

  • show-next-message causes the next eligible message to be displayed.
    • other messages may come from the app itself or from rollouts that other staff are doing.
  • show-none causes the message surface not to display anything at all.

By default, on-control is set to show-next-message.

Advanced uses of the {experiment} subsitution

The literal string {experiment} can appear anywhere in the feature configuration, including the message key allows for some deduplication. For example, a regularly repeating message could be set up with message keys derived from the experiment slug.

{
"messages": {
"{experiment}-en": {
"triggers": [
"USER_EN_SPEAKER"
],

},
"{experiment}-fr": {
"triggers": [
"USER_FR_SPEAKER"
],

},
"{experiment}-es": {
"triggers": [
"USER_ES_SPEAKER"
],

},
"{experiment}-de": {
"triggers": [
"USER_DE_SPEAKER"
],

}
}
}

Events emitted

Nimbus Events

Nimbus emits events via Glean for all experiments that the user is enrolled in.

Enrollment is a decision taken at start-up about whether the user is eligible for the experiment (i.e. fits the experiment targeting criteria), and is chosen to part of the experiment cohort, i.e. is chosen to be have one of the branches).

Exposure evnts happen when the user is enrolled in the experiment, and the subsequently exposed to the treatment (or the control).

In the case of messaging, exposure events are emitted when a message from one of the branches of an enrolled experiment is shown. For other messages that aren't from an experiment, no exposure events are emitted.

Message Events

Each of the following events is emitted— via Glean— at certain points while the message exists on the user's device.

  • message_shown: the message is shown to the user.
  • message_clicked: the user has tapped on the button, or the message itself if a button doesn't exist.
    • an action-uuid is given as an extra.
  • malformed: the message sender has somehow made an error specifying a particular message. e.g.
  • message_dismissed: the message was shown to the user, and tapped the dismiss action. This is message surface dependent.
  • message_expired: The message has been shown to the user for a number of sessions, and not been interacted with. We therefore expire the message.

Each message has a message-key extra.

Extending the system

Much of the system relies on Nimbus merging together JSON objects. We have seen this in the messages object which can contain messages from the default configuration, rollouts, and experiments.

We can also add to the actions, triggers and styles object in the same way. This is covered below, and do not need an engineer.

Adding attributes

Attributes require an application engineer to add values to the JSON object that is passed to the messaging subsystem.

Once in this JSON, this becomes available to JEXL expressions and string subsitution.

Adding custom trigger expressions

Trigger expressions can be added on a per-message basis, by adding to the triggers object.

{
"triggers": {
"DATE_IS_CHRISTMAS": "'-12-25' in date_string"
},

"messages": {
"happy-christmas-en": {
"trigger": [
"DATE_IS_CHRISTMAS",
"USER_EN_SPEAKER"
]

"title": "Happy Christmas",

},
"happy-christmas-fr": {
"trigger": [
"DATE_IS_CHRISTMAS",
"USER_FR_SPEAKER"
]

"title": "Joyeux Noel",

}
}
}

The DATE_IS_CHRISTMAS is now available as a trigger expression in all the messages in the branch.

It can be used in all messages only when it is added back to the application's list of trigger expressions.

Once a branch has been rolled out, then DATE_IS_CHRISTMAS becomes available to all messages.

Finally, it can be rolled back in to the product by adding it to the nimbus.fml.yaml file, i.e. in Firefox for iOS and Firefox for Android.

Care should be taken to test this new trigger expression before deploying it.

Suitable tools to prototype these expressions:

Adding custom actions

All actions are implemented as URLs. Ad-hoc URLs can be used for one-off messages, but must contain the scheme and separator: e.g. https://.

URLs that start with :// are taken as deeplinks into the app.

You can add URLs as named actions for use by multiple messages.

{
"actions": {
"INSTALL_VPN": "market://details?id=org.mozilla.firefox.vpn"
}

"messages": {
"upsell-vpn-en": {

"button-label": "VPN maybe?",
"action": "INSTALL_VPN"

"trigger": ["USER_EN_SPEAKER"]
},
"upsell-vpn-fr": {

"button-label": "VPN peut-être?",
"action": "INSTALL_VPN"

"trigger": ["USER_FR_SPEAKER"]
},
}
}

Deeplinks are implemented by the application developers in the app.

There are two parts to making new actions for messages:

  • implementing them so that the app can respond to deeplinks.
  • making those links accessible to messages, by adding them to the nimbus.fml.yaml file.

For example:

If the implementation stage has been done, but the FML part hasn't, you can add the action as part of the branch configuration.

{
"actions": {
"FXA_SIGN_IN": "://fxa-signin?signin"
},

"messages": {
"upsell-fxa": {

"action": "FXA_SIGN_IN"
}
}
}

Custom actions can be used in all messages only when they are added back to the application's list of actions.

Lifecycle of a message

Like all Nimbus enabled features the messaging system configuration (messages, triggers, actions, styles) is likely to undergo a number of phases:

  • experimentation
  • rollout the successful messages to the rest of the audience
  • fold-back into the code: copy the successful JSON branches into the nimbus.fml.yaml files where they will become part of the next release.

Appendices

List of trigger expressions

These trigger expressions are based upon the default set of attrbutes available to Nimbus.

Expression nameJEXL expressionDiscussion
USER_RECENTLY_INSTALLEDdays_since_install < 7
USER_RECENTLY_UPDATEDdays_since_update < 7 && days_since_install != days_since_update
USER_TIER_ONE_COUNTRY`('US' in locale
USER_EN_SPEAKER'en' in locale
USER_DE_SPEAKER'de' in locale
USER_FR_SPEAKER'fr' in locale
DEVICE_ANDROIDos == 'Android'
DEVICE_IOSos == 'iOS'
ALWAYStrue
NEVERfalse

These trigger expressions are application specific:

Expression nameJEXL expressionDiscussion
I_AM_DEFAULT_BROWSERis_default_browser == true
I_AM_NOT_DEFAULT_BROWSERis_default_browser == false
USER_ESTABLISHED_INSTALLnumber_of_app_launches >= 4
FUNNEL_PAIDadjust_campaign != ''
FUNNEL_ORGANICadjust_campaign == ''
INACTIVE_1_DAY'app_launched'\|eventLastSeen('Hours') >= 24User has not launched the app for 24h or more
INACTIVE_2_DAYS'app_launched'\|eventLastSeen('Days', 0) >= 2User has not launched the app for 1 d or more
INACTIVE_3_DAYS'app_launched'\|eventLastSeen('Days', 0) >= 3User has not launched the app for 2 d or more
INACTIVE_4_DAYS'app_launched'\|eventLastSeen('Days', 0) >= 4User has not launched the app for 3 d or more
INACTIVE_5_DAYS'app_launched'\|eventLastSeen('Days', 0) >= 5User has not launched the app for 4 d or more
FXA_SIGNED_IN'sync_auth.sign_in'\|eventLastSeen('Years', 0) <= 4User has signed in to FxA in the last 4 years
FXA_NOT_SIGNED_IN'sync_auth.sign_in'\|eventLastSeen('Years', 0) > 4User has not signed in to FxA in the last 4 years
USER_INFREQUENT'app_launched'\|eventCountNonZero('Days', 28) >= 1 && 'app_launched'\|eventCountNonZero('Days', 28) < 7User definition
USER_CASUAL'app_launched'\|eventCountNonZero('Days', 28) >= 7 && 'app_launched'\|eventCountNonZero('Days', 28) < 14User definition
USER_REGULAR'app_launched'\|eventCountNonZero('Days', 28) >= 14 && 'app_launched'\|eventCountNonZero('Days', 28) < 21User definition
USER_CORE_ACTIVE'app_launched'\|eventCountNonZero('Days', 28) >= 21User definition
LAUNCHED_ONCE_THIS_WEEK'app_launched'\|eventSum('Days', 7) == 1

It is possible this table is out of date. The definitive source of truth for this in the code itself.

List of actions

These all correspond to the existing deeplinks in each app, so are entirely app specific.

ActionDescriptionCorresponding Deeplink
ENABLE_PRIVATE_BROWSINGfirefox://enable_private_browsing
INSTALL_SEARCH_WIDGETfirefox://install_search_widget
MAKE_DEFAULT_BROWSERfirefox://make_default_browser
OPEN_SETTINGS_ACCESSIBILITYfirefox://settings_accessibility
OPEN_SETTINGS_ADDON_MANAGERfirefox://settings_addon_manager
OPEN_SETTINGS_DELETE_BROWSING_DATAfirefox://settings_delete_browsing_data
OPEN_SETTINGS_LOGINSfirefox://settings_logins
OPEN_SETTINGS_NOTIFICATIONSfirefox://settings_notifications
OPEN_SETTINGS_PRIVACYfirefox://settings_privacy
OPEN_SETTINGS_SEARCH_ENGINEfirefox://settings_search_engine
OPEN_SETTINGS_TRACKING_PROTECTIONfirefox://settings_tracking_protection
OPEN_SETTINGS_WALLPAPERSfirefox://settings_wallpapers
OPEN_SETTINGSfirefox://settings
TURN_ON_SYNCfirefox://turn_on_sync
VIEW_BOOKMARKSfirefox://urls_bookmarks
VIEW_COLLECTIONSfirefox://home_collections
VIEW_HISTORYfirefox://urls_history
VIEW_HOMESCREENfirefox://home

List of attributes

By convention these are in snake_case.

These attributes are defined by the Nimbus SDK.

AttributeTypeDescription
app_namestring
app_idstring
channelstring
app_versionstring
app_buildstring
architecturestring
device_manufacturerstring
device_modelstring
localestring
osstring
os_versionstring
android_sdk_versionstring
debug_tagstring
installation_datestring
home_directorystring

These attributes are application specific.

Unfortunately, the JEXL evaluator used does not have support for negation, so boolean attributes use equality or inequality.

AttributeTypeDescriptionVersions
date_stringstringIn YYYY-MM-DD format
is_default_browserbooleanJEXL.rs does not implement boolean negation !
number_of_app_launchesintIndicates how many times the app has been launched.
adjust_campaignstring?The campaign id parameter as derived by Adjustv111
adjust_networkstring?The network parameter as derived by Adjustv111
adjust_ad_groupstring?The Ad Group parameter as derived by Adjustv111
adjust_creativestring?The Creative parameter as derived by Adjustv111
are_notifications_enabledbooleanJEXL.rs does not implement boolean negation !v111

It is possible this table is out of date. The definitive source of truth for this in the code itself.