The NotificationTrigger is used to conditionally display the interaction bubble (floating widget) based on study phase and previous EMA answers. To display any questionnaire in the interaction bubble on unlock or through push notifications, the NotificationTriggers are planned on enrolment, depending on the study phase.
A questionnaire trigger, the EMAFloatingWidgetNotificationTrigger, is used to create NotificationTriggers for the respective questionnaire.
- defines if the questionnaire should only be shown after unlocking the phone, or also to push a notification proactively
- defines the phase in which the NotificationTrigger should be created
- defines the time intervals in which any NotificationTrigger should be created
- it also defines if the NotificationTrigger is planned or only created conditionally, e.g. after answering a previous questionnaire with a rule prompting to create a new NotificationTrigger
To decide if and which NotificationTrigger is shown on unlock (or through a push notification), the widget (or push BroadcastReceiver) checks if either
- the latest NotificationTrigger of the current time interval (called Time Bucket) is unanswered
- the last time interval contains an unanswered wave-breaking NotificationTrigger, which makes the time interval "virtually" expand to the current one In both cases, the floating widget will show the questionnaire defined in the questionnaire trigger of the NotificationTrigger. If it is configured to be pushed, an additional push notification is created.
Questionnaire rules enable conditional logic within questionnaires, allowing dynamic responses based on participant answers. When a questionnaire is completed, the rules are evaluated against the provided answers, and matching rules trigger specific actions.
Each questionnaire can have an optional rules field containing a list of QuestionnaireRule objects. Each rule consists of:
- name: A unique identifier for the rule
- conditions: A
ConditionGroupthat defines when the rule should trigger - actions: A list of actions to execute when conditions are met
Conditions are evaluated against questionnaire element values using:
- fieldName: The name of the questionnaire element to check (must match the element's
nameproperty) - comparator: Either
equalsornot_equals - expectedValue: The value to compare against (supports any JSON type)
Multiple conditions can be combined using logical operators:
- and: All conditions must be true
- or: At least one condition must be true
Two types of actions are supported:
-
put_notification_trigger: Creates a new notification trigger
triggerId: The ID of the trigger to create
-
open_questionnaire: Immediately opens another questionnaire
eventQuestionnaireTriggerId: The ID of the questionnaire trigger to open
{
"name": "Follow-up Rule",
"conditions": {
"operator": "and",
"conditions": [
{
"fieldName": "mood_rating",
"comparator": "equals",
"expectedValue": "low"
},
{
"fieldName": "needs_support",
"comparator": "not_equals",
"expectedValue": "no"
}
]
},
"actions": [
{
"type": "put_notification_trigger",
"triggerId": 123
},
{
"type": "open_questionnaire",
"eventQuestionnaireTriggerId": 456
}
]
}This rule would trigger if the participant rated their mood as "low" AND didn't answer "no" to needing support, then both create a notification trigger and open another questionnaire.
Rules are evaluated in QuestionnaireRuleEvaluator.kt:20 after questionnaire completion. The evaluator supports various element types including radio groups, checkboxes, sliders, text entries, button groups, and social network entries. If any condition references a non-existent field or the field has no value, that condition evaluates to false.
The text view components support string interpolation to include dynamic content in the questionnaire. Currently, the dynamic content is limited to the timestamps of NotificationTriggers.
It can be used in any text_view ESM element and the questionnaire shown needs to have a NotificationTrigger associated with its PendingNotification.
The format for the string interpolation for NotificationTrigger timestamps is
| Format | Description | Example |
|---|---|---|
{{triggerName_pushed}} |
Time when NotificationTrigger was pushed | 11:25 |
{{triggerName_answered}} |
Time when NotificationTrigger was answered | 11:30 |
where triggerName is the name of the NotificationTrigger, so for example the text of a TextViewElement could be What did you do on {{Trigger1_pushed}}?, which will replace to What did you do on 11:25? when the questionnaire is shown.
The PendingQuestionnaire is the central data model for managing questionnaire lifecycle, from creation to completion and upload. It serves as a persistent storage container that tracks questionnaire state, participant answers, and metadata throughout the entire ESM process.
PendingQuestionnaire acts as a bridge between questionnaire definitions and participant responses, providing:
- State Management: Tracks questionnaire status from notification through completion
- Answer Storage: Persists participant responses as JSON during and after completion
- Upload Coordination: Manages data synchronization with the backend
The PendingQuestionnaire entity in PendingQuestionnaire.kt:38 contains:
| Field | Type | Description |
|---|---|---|
uid |
UUID | Unique identifier for the questionnaire instance |
addedAt |
Long | Timestamp when questionnaire was created |
validUntil |
Long | Expiration timestamp (-1 for no expiration) |
questionnaireJson |
String | Serialized questionnaire definition and elements |
triggerJson |
String | Serialized trigger configuration that created this instance |
elementValuesJson |
String? | JSON-encoded participant answers |
updatedAt |
Long | Last modification timestamp |
openedPage |
Int? | Current/last viewed page (for multi-page questionnaires) |
status |
PendingQuestionnaireStatus | Current state (NOTIFIED, PENDING, COMPLETED) |
finishedAt |
Long? | Completion timestamp |
notificationTriggerUid |
UUID? | Reference to associated NotificationTrigger (if any) |
displayType |
PendingQuestionnaireDisplayType | How questionnaire is presented (INBOX, NOTIFICATION_TRIGGER) |
PendingQuestionnaire progresses through three states:
- NOTIFIED: Initial state when questionnaire is created and ready for display
- PENDING: Participant has opened questionnaire and is actively answering
- COMPLETED: All responses submitted and questionnaire finished
Questionnaires can be created through two pathways:
- INBOX: Manual questionnaires added directly to participant's inbox
- NOTIFICATION_TRIGGER: Automatic questionnaires triggered by NotificationTrigger events
When created via NotificationTrigger, the notificationTriggerUid field links to the triggering event, enabling string interpolation and contextual data access.
Participant responses are stored in elementValuesJson as a serialized map of element IDs to ElementValue objects.
LogService is responsible for starting the Sensors.
Currently, sampling is manually started. It is managed by SamplingManager. We have different modes of sampling, depending on what a study wants to investigate:
- Continuous sampling, repeatedly for a specific duration
- The LogService is started once and is always active as a foreground service. The SamplingManager can initialize LogService for different sampling 'strategies'.
- The default strategy is OnUnlockAndPeriodicSamplingStrategy, which covers the following
- Sampling on unlock
- Periodic sampling (every 5 minutes for 1 minute)
- Event-based (continuous) sampling, e.g. for notifications
- This "wakes up" each enabled sensor through their
startmethod - Sensors are only stopped through
stopwhen the entire sampling process is stopped (through the UI)
- The default strategy is OnUnlockAndPeriodicSamplingStrategy, which covers the following
- The LogService is started once and is always active as a foreground service. The SamplingManager can initialize LogService for different sampling 'strategies'.
- AudioSampleService: Will record audio until it is stopped again
- SingletonSensorList: For long-running tasks, we want to reuse the existing sensor instances, and clean them up manually
The UITreeSensor captures privacy-preserving structural representations of UI screens and user interactions for behavioral analysis. It works in conjunction with the accessibility service to log detailed UI hierarchies without capturing any personal data.
- Accessibility Service captures UI trees and user interactions via UITreeConsumer
- SnapshotBatchManager batches the data (50 snapshots or 60 seconds)
- UITreeSensor receives and logs the batch to database (
ui_tree.json)
Each logged line contains a batch with two types of snapshots:
1. Skeleton Snapshots (Full UI Structure)
{
"timestamp": 1699876543210,
"appPackage": "com.instagram.android",
"framework": "NATIVE",
"skeleton": {
"signature": "a3f5b8c...",
"nodes": [
{
"id": 0,
"type": "CONTAINER",
"region": "CENTER",
"sizeClass": "FULLSCREEN",
"relativeX": 0.0,
"relativeY": 0.0,
"clickable": false,
"hasText": false,
"textCategory": "SHORT_PHRASE"
}
]
}
}2. Interaction Snapshots (User Actions)
{
"timestamp": 1699876545123,
"skeleton": {
"signature": "a3f5b8c...",
"nodes": []
},
"interaction": {
"type": "TAP",
"targetNodeId": 2,
"tapX": 0.9,
"tapY": 0.89
}
}- Text content categorized by length only (SINGLE_WORD, SHORT_PHRASE, SENTENCE, etc.)
- No actual text, usernames, messages, or content
- Only structural properties (type, position, size)
The sensor tracks five types of user interactions:
- TAP: Single tap, double-tap, button clicks
- LONG_PRESS: Long press events
- SCROLL: Scrolling through content
- TEXT_INPUT: Text entry in input fields
- SWIPE: Swipe gestures
Works across UI frameworks via accessibility service:
- Native Android (View, Jetpack Compose)
- React Native
- Flutter
- Xamarin
- WebView (Cordova, Ionic, Capacitor)
- Unity
- AppSensor does not sample the foreground activity (function was deprecated with Android Lollipop)
- Current Solution We're using AccessibilitySensor as it yields useful information about app use, which can be post-processed to infer opened apps and interactions
- Alternative: We could also use the UsageStats if
getLastTimeUsed()is reliable enough
- Starting with Android 15, we can no longer run a foreground service that'll record from the microphone
- Needs Clarification: Instead, we could maybe ask the participants to open the app so the microphone becomes active again?