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
tois_default_browser_string
. - Renamed Glean messages to match the implementation.
- Added notification surface and associated trigger expressions.
- v117 - added
experiment
property to message, removedmessage-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.
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 betrue
for the message to be eligible to be displayed. - the content of a message. This is the
title
of the message, thetext
and thebutton-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.
- Firefox for Android
- Firefox for iOS
Surface | Description | Versions |
---|---|---|
homescreen | On the new tab/homescreen | |
notification | A system notification. Permission is required for Android SDK > 13 | v111 |
survey | A screen that is shown at startup |
Surface | Description | Versions |
---|---|---|
new-tab-card | On the new tab/homescreen | |
notification | A system notification. | |
survey | A screen that is shown at startup |
While there is only one surface, the surface attribute can be omitted.
It is possible this table is out of date. The definitive source of truth for this in the code itself.
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 thestyles
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 thetrigger
value of the treatment message. - the
surface
value of the control must match thesurface
value of the treatment message. - the
style
value of the control must match thestyle
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.
- an
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.
- Firefox for Android
- [Firefox for iOS] (link needed)
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:
- This JEXL playground is setup to prototype expressions against existing attributes.
- JEXL documentation
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:
- Firefox for Android:
- Deeplinks in the
AndroidManifest.xml
. - Corresponding
actions
in thenimbus.fml.yaml
. Note that thedeepLinkScheme
is omitted in the FML.
- Deeplinks in the
- Firefox for iOS:
- Deeplinks in the
NavigationRouter.swift
. - Corresponding
actions
in thenimbus.fml.yaml
. Note that thedeepLinkScheme
is omitted in the FML.
- Deeplinks in the
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 name | JEXL expression | Discussion |
---|---|---|
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 | |
USER_EN_SPEAKER | 'en' 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 |
These trigger expressions are application specific:
- Firefox for Android
- Firefox for iOS
Expression name | JEXL expression | Discussion |
---|---|---|
I_AM_DEFAULT_BROWSER | is_default_browser == true | |
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 == '' | |
INACTIVE_1_DAY | 'app_launched'\|eventLastSeen('Hours') >= 24 | User has not launched the app for 24h or more |
INACTIVE_2_DAYS | 'app_launched'\|eventLastSeen('Days', 0) >= 2 | User has not launched the app for 1 d or more |
INACTIVE_3_DAYS | 'app_launched'\|eventLastSeen('Days', 0) >= 3 | User has not launched the app for 2 d or more |
INACTIVE_4_DAYS | 'app_launched'\|eventLastSeen('Days', 0) >= 4 | User has not launched the app for 3 d or more |
INACTIVE_5_DAYS | 'app_launched'\|eventLastSeen('Days', 0) >= 5 | User has not launched the app for 4 d or more |
FXA_SIGNED_IN | 'sync_auth.sign_in'\|eventLastSeen('Years', 0) <= 4 | User has signed in to FxA in the last 4 years |
FXA_NOT_SIGNED_IN | 'sync_auth.sign_in'\|eventLastSeen('Years', 0) > 4 | User has not signed in to FxA in the last 4 years |
USER_INFREQUENT | 'app_launched'\|eventCountNonZero('Days', 28) >= 1 && 'app_launched'\|eventCountNonZero('Days', 28) < 7 | User definition |
USER_CASUAL | 'app_launched'\|eventCountNonZero('Days', 28) >= 7 && 'app_launched'\|eventCountNonZero('Days', 28) < 14 | User definition |
USER_REGULAR | 'app_launched'\|eventCountNonZero('Days', 28) >= 14 && 'app_launched'\|eventCountNonZero('Days', 28) < 21 | User definition |
USER_CORE_ACTIVE | 'app_launched'\|eventCountNonZero('Days', 28) >= 21 | User 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.
Expression name | JEXL expression | Discussion |
---|
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.
- Firefox for Android
- Firefox for iOS
Action | Description | Corresponding Deeplink |
---|---|---|
ENABLE_PRIVATE_BROWSING | firefox://enable_private_browsing | |
INSTALL_SEARCH_WIDGET | firefox://install_search_widget | |
MAKE_DEFAULT_BROWSER | firefox://make_default_browser | |
OPEN_SETTINGS_ACCESSIBILITY | firefox://settings_accessibility | |
OPEN_SETTINGS_ADDON_MANAGER | firefox://settings_addon_manager | |
OPEN_SETTINGS_DELETE_BROWSING_DATA | firefox://settings_delete_browsing_data | |
OPEN_SETTINGS_LOGINS | firefox://settings_logins | |
OPEN_SETTINGS_NOTIFICATIONS | firefox://settings_notifications | |
OPEN_SETTINGS_PRIVACY | firefox://settings_privacy | |
OPEN_SETTINGS_SEARCH_ENGINE | firefox://settings_search_engine | |
OPEN_SETTINGS_TRACKING_PROTECTION | firefox://settings_tracking_protection | |
OPEN_SETTINGS_WALLPAPERS | firefox://settings_wallpapers | |
OPEN_SETTINGS | firefox://settings | |
TURN_ON_SYNC | firefox://turn_on_sync | |
VIEW_BOOKMARKS | firefox://urls_bookmarks | |
VIEW_COLLECTIONS | firefox://home_collections | |
VIEW_HISTORY | firefox://urls_history | |
VIEW_HOMESCREEN | firefox://home |
Action | Description | Corresponding Deeplink |
---|---|---|
ENABLE_PRIVATE_BROWSING | firefox://deep-link?url=homepanel/new-private-tab | |
MAKE_DEFAULT_BROWSER | firefox://deep-link?url=default-browser/system-settings | |
OPEN_SETTINGS_EMAIL | firefox://deep-link?url=settings/mailto | |
OPEN_SETTINGS_FXA | firefox://deep-link?url=settings/fxa | |
OPEN_SETTINGS_HOMESCREEN | firefox://deep-link?url=settings/homepage | |
OPEN_SETTINGS_NEW_TAB | firefox://deep-link?url=settings/newtab | |
OPEN_SETTINGS_PRIVACY | firefox://deep-link?url=settings/clear-private-data | |
OPEN_SETTINGS_SEARCH_ENGINE | firefox://deep-link?url=settings/search | |
OPEN_SETTINGS_THEME | firefox://deep-link?url=settings/theme | |
OPEN_SETTINGS_WALLPAPERS | firefox://deep-link?url=settings/wallpaper | |
OPEN_SETTINGS | firefox://deep-link?url=settings/general | |
VIEW_BOOKMARKS | firefox://deep-link?url=homepanel/bookmarks | |
VIEW_DOWNLOADS | firefox://deep-link?url=homepanel/downloads | |
VIEW_HISTORY | firefox://deep-link?url=homepanel/history | |
VIEW_READING_LIST | firefox://deep-link?url=homepanel/reading-list | |
VIEW_TOP_SITES | firefox://deep-link?url=homepanel/top-sites |
List of attributes
By convention these are in snake_case
.
These attributes are defined by the Nimbus SDK.
Attribute | Type | Description |
---|---|---|
app_name | string | |
app_id | string | |
channel | string | |
app_version | string | |
app_build | string | |
architecture | string | |
device_manufacturer | string | |
device_model | string | |
locale | string | |
os | string | |
os_version | string | |
android_sdk_version | string | |
debug_tag | string | |
installation_date | string | |
home_directory | string |
These attributes are application specific.
Unfortunately, the JEXL evaluator used does not have support for negation, so boolean attributes use equality or inequality.
- Firefox for Android
- Firefox for iOS
Attribute | Type | Description | Versions |
---|---|---|---|
date_string | string | In YYYY-MM-DD format | |
is_default_browser | boolean | JEXL.rs does not implement boolean negation ! | |
number_of_app_launches | int | Indicates how many times the app has been launched. | |
adjust_campaign | string? | The campaign id parameter as derived by Adjust | v111 |
adjust_network | string? | The network parameter as derived by Adjust | v111 |
adjust_ad_group | string? | The Ad Group parameter as derived by Adjust | v111 |
adjust_creative | string? | The Creative parameter as derived by Adjust | v111 |
are_notifications_enabled | boolean | JEXL.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.
Attribute | Type | Description | Versions |
---|---|---|---|
date_string | string | In YYYY-MM-DD format | |
is_default_browser | boolean | JEXL.rs does not implement boolean negation ! |
It is possible this table is out of date. The definitive source of truth for this in the code itself.