Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Sampling package class

Declare the measure types, create the probes, register the JSON classes and use the package in an app.

Copy MarkdownOpen inOpen in GitHubView as MarkdownOpen in Scira AIOpen in ChatGPTOpen in ClaudeOpen in Cursor

The sampling package class is what an app registers with CARP Mobile Sensing (CAMS). It declares the package's measure types with their metadata and default sampling configurations. It also creates a probe for each measure and registers the classes that CAMS decodes from JSON.

Write the package class

Replace the contents of lib/barometer_package.dart with the complete BarometerSamplingPackage class:

lib/barometer_package.dart
part of 'barometer_sampling_package.dart';

/// The sampling package for air pressure from the phone's barometer
///
/// Both measure types use the phone itself, so the package extends
/// [SmartphoneSamplingPackage] and needs no device manager of its own.
/// On registration, it registers its data and configuration classes for
/// JSON deserialization and adds [roundAirPressure] and
/// [roundAirPressureSummary] to the default [PrivacySchema]
class BarometerSamplingPackage extends SmartphoneSamplingPackage { 
  /// The namespace of the measure types in this package
  ///
  /// A reverse domain name that you control, not the CARP namespace
  /// `dk.cachet.carp`
  static const String NAMESPACE = 'com.example';

  /// Measure type for air pressure readings from the phone's barometer
  ///  * Event-based measure, one measurement per reading
  ///  * Uses the [Smartphone] primary device
  ///  * Uses an [IntervalSamplingConfiguration], whose interval is the
  ///    requested time between readings; default 1 second
  ///  * Collected as [AirPressure] data
  static const String AIR_PRESSURE = '$NAMESPACE.airpressure';

  /// Measure type for a summary of the air pressure in a sampling window
  ///  * Event-based (periodic) measure, one measurement per window
  ///  * Uses the [Smartphone] primary device
  ///  * Uses an [AirPressureSummarySamplingConfiguration] or a
  ///    [PeriodicSamplingConfiguration]; default a 10-second window every
  ///    5 minutes, with 1 second between readings
  ///  * Collected as [AirPressureSummary] data
  static const String AIR_PRESSURE_SUMMARY = '$NAMESPACE.airpressuresummary';

  /// The default time between barometer readings
  static const Duration DEFAULT_SAMPLING_PERIOD = Duration(seconds: 1);

  @override
  DataTypeSamplingSchemeMap get samplingSchemes =>
      DataTypeSamplingSchemeMap.from([
        DataTypeSamplingScheme(
          CamsDataTypeMetaData(
            type: AIR_PRESSURE,
            displayName: 'Air Pressure',
            timeType: DataTimeType.POINT,
          ),
          IntervalSamplingConfiguration(interval: DEFAULT_SAMPLING_PERIOD),
        ),
        DataTypeSamplingScheme(
          CamsDataTypeMetaData(
            type: AIR_PRESSURE_SUMMARY,
            displayName: 'Air Pressure Summary',
            timeType: DataTimeType.TIME_SPAN,
          ),
          AirPressureSummarySamplingConfiguration(
            interval: const Duration(minutes: 5),
            duration: const Duration(seconds: 10),
          ),
        ),
      ]);

  @override
  Probe? create(String type) => switch (type) { 
    AIR_PRESSURE => AirPressureProbe(),
    AIR_PRESSURE_SUMMARY => AirPressureSummaryProbe(),
    _ => null,
  };

  @override
  void onRegister() { 
    // Register the classes that CAMS deserializes from JSON
    FromJsonFactory().registerAll([
      AirPressure(pressure: 0),
      AirPressureSummary(count: 0, mean: 0, min: 0, max: 0),
      AirPressureSummarySamplingConfiguration(
        interval: Duration.zero,
        duration: Duration.zero,
      ),
    ]);

    // Add the privacy functions to the default privacy schema
    DataTransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT)!
      ..add(AIR_PRESSURE, roundAirPressure)
      ..add(AIR_PRESSURE_SUMMARY, roundAirPressureSummary);
  }
}

Both measures run on the phone, so the class extends SmartphoneSamplingPackage and implements only samplingSchemes, create() and onRegister().

To compile the package, add the two privacy functions that onRegister() uses to lib/barometer_privacy.dart. For their code, see Write a privacy function.

Members of a sampling package

Every sampling package implements the six members of SamplingPackage. SmartphoneSamplingPackage implements four of them for packages that collect data on the phone:

MemberCalled byIn SmartphoneSamplingPackage
samplingSchemesregister() and the registry, each time it creates a probeYou implement it
create()The registry, for each measure when CAMS initializes a deployment or a user task startsYou implement it
onRegister()register(), onceDoes nothing; you override it
dataTypesOnly the phone's device manager, which lists the types of phone packages for informationsamplingSchemes.dataTypes
deviceTyperegister(), which registers deviceManager under this typedk.carp.cams.devices.Smartphone
deviceManagerregister() and the registry, each time it creates a probeOne SmartphoneDeviceManager that all phone packages share

For a wearable or an online service, implement SamplingPackage with all six members. See Devices and services.

Declare the measure types

samplingSchemes returns a DataTypeSamplingSchemeMap with one DataTypeSamplingScheme per measure type: the type's metadata and its default sampling configuration. It is the list of types that the package supports:

  • Only declared types get probes: the registry finds the package for a measure by looking for its type in samplingSchemes. For a type that create() handles but samplingSchemes lacks, CAMS creates no probe; it skips the measure and logs a warning.
  • The default configuration applies when the protocol sets none: give each type the default that its probe expects. A scheme without one gets a NoOptionsSamplingConfiguration, and a probe that casts its configuration, such as an IntervalProbe, never starts. See Set the default configurations.

The metadata of each scheme is a CamsDataTypeMetaData:

ParameterTypeRequiredDefaultDescription
typeStringYes—The measure type.
displayNameStringNo''A name that apps can show, as the CAMS demo app does in its list of probes.
timeTypeDataTimeTypeNoDataTimeType.POINTPOINT for data at one moment, TIME_SPAN for data over an interval, whose measurements set sensorEndTime.
dataEventTypeDataEventTypeNoDataEventType.EVENTEVENT for a measure that collects until it is paused, ONE_TIME for one that collects once each time it is triggered.
permissionsList<Permission>Noconst []The runtime permissions that the probe needs. See Declare permissions.

CAMS 2.5.1 does not read timeType or dataEventType. They tell apps and data analysts what to expect, while the probe alone decides when collection stops and which timestamps a measurement has. Set them to match the probe: the example declares TIME_SPAN for the summary, whose measurements have an end time.

Declare permissions

List the runtime permissions that a measure's probe needs in permissions, as Permission values of the permission_handler plugin. The sensor package of CAMS, for example, declares activity recognition for its step count measure:

DataTypeSamplingScheme(
  CamsDataTypeMetaData(
    type: STEP_COUNT,
    displayName: "Step Count",
    timeType: DataTimeType.POINT,
    permissions: [Permission.activityRecognition],
  ),
),

CAMS 2.5.1 then handles the permission in two places:

  1. CAMS asks when the phone gets a study's deployment: after tryDeployment() and at every app start in configure(). It collects the permissions of all measures in the deployment and asks for the missing ones, one dialog at a time. An app that calls configure() with askForPermissions: false turns this off.
  2. When a probe resumes, the CAMS probe base classes check the permissions. On Android, a missing permission makes onResume() return false: the probe does not start, reports no error and checks again at its next resume. On iOS, the check always passes, because iOS asks for a permission when a plugin first uses it.

A few rules follow from this:

  • Declare the Android permission group: on iOS, CAMS asks for Permission.sensors instead of Permission.activityRecognition and for Permission.bluetooth instead of Permission.bluetoothScan.
  • Leave out the permissions to connect to a device: these permissions, such as Bluetooth access, belong to the device manager. CAMS does not ask for them. See Devices and services.
  • Add permission_handler to the package's dependencies: CAMS does not export Permission. In the README, list the Android manifest entries and iOS usage descriptions that an app must add.
  • Do not add a permissions getter to the package class: SamplingPackage has none in CAMS 2.x. A getter left over from CAMS 1.x only causes the analyzer warning override_on_non_overriding_member, and CAMS never asks for its permissions.

The example declares no permissions. Android needs none to read the barometer. On iOS, sensors_plus reads it through Core Motion, which needs an NSMotionUsageDescription entry in the app's Info.plist; the app setup below adds it.

Declaring Permission.sensors would make CAMS ask for body sensor access on Android, which the barometer does not need. If a participant denied it, neither probe would start there.

Create the probes

create() returns a new probe for a measure type or null for a type that the package does not handle. The registry calls it for each measure of each background task when CAMS initializes a deployment, before any device connects:

  • Return a new probe from every call: each measure has its own probe with its own state. An app task's measures get new probes each time one of its user tasks starts.
  • Return null on an unsupported platform: CAMS then skips the measure with a warning and runs the rest of the protocol. The sensor package of CAMS does this for ambient light on iOS.
  • Keep the probe constructor free of setup: the registry sets the probe's deviceManager after create() returns, so reading it in the constructor throws a LateInitializationError. See Probe lifecycle.
  • Do not choose the probe from device state: create() runs before the device connects. See Read the device in a probe.

Register the JSON classes and privacy functions

When an app calls register(), the SamplingPackageRegistry takes three steps:

  1. It adds the metadata from samplingSchemes to the data types that CAMS knows.
  2. It registers deviceManager with the device controller under deviceType. The first manager registered for a type is kept.
  3. It calls onRegister().

In onRegister(), register every class of the package that CAMS decodes from JSON. A placeholder instance is enough: FromJsonFactory stores only the class's decoding function under its jsonType.

ClassesCAMS decodes them fromWhen one is not registered
Data classes and a CompletedAppTask for each app task typeStored measurements, for example before an upload to CARP Web Services (CAWS)Every upload attempt of the study fails.
Sampling configurations, device configurations, device registrations and AppTask subclassesThe deployment, which CAMS saves on the phone and restores at every app startAt the next app start, the study and every study saved after it fail to load. CAMS logs the warning "Failed to load studies".

For the classes in the deployment, the first run hides a missing registration, because the local deployment service keeps the protocol's objects and decodes nothing.

carp_movesense_package, for example, does not register its device registration class. A study in which a Movesense sensor has connected therefore fails to load at the next app start. For a test that catches this, see Test the JSON round trips.

Register only the classes of your package: CAMS registers its own, such as IntervalSamplingConfiguration, when it initializes.

Register each package once, too. register() does not check for duplicates: a second registration runs onRegister() again. With a second instance, CAMS also logs a duplicate-type warning for every probe of the package's types.

The example's onRegister() also adds two privacy functions to the default privacy schema. They run only in studies whose protocol sets privacySchemaName: PrivacySchema.DEFAULT. See Privacy and data formats.

Device type and device manager

deviceType and deviceManager connect the package's probes to a device. SmartphoneSamplingPackage returns the phone's type and the SmartphoneDeviceManager that all phone packages share, so the example implements neither. To implement both for a wearable or an online service, see Expose the device in the package.

Use the package in an app

An app uses your package like any other sampling package: it registers the package, adds the measures to a protocol and deploys it. The example app in example/ does this, and setting it up takes three steps:

  1. Create the example app

    flutter create --project-name barometer_sampling_package_example --platforms android,ios example
    cd example
    flutter pub add carp_core carp_mobile_sensing 'barometer_sampling_package:{"path":"../"}'

    Delete example/test/: its widget test uses the template's MyApp class, which the new main.dart replaces. The path dependency makes the app use the package in the folder above, which applies your changes at each run:

    example/pubspec.yaml
    dependencies:
      flutter:
        sdk: flutter
    
      carp_core: ^2.2.2
      carp_mobile_sensing: ^2.5.1
      barometer_sampling_package:
        path: ../
  2. Set up Android and iOS for CAMS

    To configure the example app for CAMS on Android and iOS, see Installation. The iOS setup there includes the entry that sensors_plus needs to read the barometer:

    example/ios/Runner/Info.plist
    <key>NSMotionUsageDescription</key>
    <string>This app reads the barometer to measure air pressure.</string>

    Without this entry, the app crashes on iOS when it reads the barometer.

  3. Register the package and deploy a protocol

    Replace the contents of example/lib/main.dart with:

    example/lib/main.dart
    import 'dart:convert';
    
    import 'package:barometer_sampling_package/barometer_sampling_package.dart';
    import 'package:carp_core/carp_core.dart' hide Smartphone;
    import 'package:carp_mobile_sensing/carp_mobile_sensing.dart';
    import 'package:flutter/material.dart';
    
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      CarpMobileSensing.ensureInitialized();
    
      // 1. Register the sampling package
      SamplingPackageRegistry().register(BarometerSamplingPackage());
    
      // 2. Create a protocol with the phone as the primary device
      final protocol = SmartphoneStudyProtocol(
        ownerId: 'owner@example.com',
        name: 'Air pressure',
        dataEndPoint: SQLiteDataEndPoint(),
      );
      final phone = Smartphone();
      protocol.addPrimaryDevice(phone);
    
      // 3. Collect every reading, and a summary of a 10-second window every
      // 30 seconds
      protocol.addTaskControl(
        ImmediateTrigger(),
        BackgroundTask(
          measures: [
            Measure(type: BarometerSamplingPackage.AIR_PRESSURE),
            Measure(
              type: BarometerSamplingPackage.AIR_PRESSURE_SUMMARY,
              samplingConfiguration: AirPressureSummarySamplingConfiguration( 
                interval: const Duration(seconds: 30),
                duration: const Duration(seconds: 10),
              ),
            ),
          ],
        ),
        phone,
      );
    
      // 4. Deploy the protocol on the first run and start sampling
      final client = SmartPhoneClientManager();
      await client.configure();
      if (client.studies.isEmpty) {
        final study = await client.addStudyFromProtocol(protocol);
        await client.tryDeployment(study.studyDeploymentId, study.deviceRoleName);
      }
      client.resume();
    
      // 5. Print each measurement as JSON, and the errors that probes report
      client.measurements.listen(
        (measurement) => print(jsonEncode(measurement)),
        onError: (Object error) => print('Error: $error'),
      );
    
      runApp(
        const MaterialApp(
          home: Scaffold(body: Center(child: Text('Collecting air pressure'))),
        ),
      );
    }

Startup order

StageWhat it does
InitializeCarpMobileSensing.ensureInitialized() sets up the data types and JSON classes of CAMS. It must run before the first SamplingPackageRegistry() call.
1. RegisterMakes the two measure types, their data classes and the summary configuration known to CAMS. It must run before configure().
2. ProtocolCreates a protocol with the phone as the primary device. SQLiteDataEndPoint() stores the measurements in a SQLite database on the phone.
3. MeasuresAdds both measures to one task. They are event-based, so an ImmediateTrigger starts them once and they run until the study stops. The override shortens the summary interval from 5 minutes to 30 seconds. The first window starts one interval after the probe starts, so the first summary appears within a minute.
4. DeployConfigures the client and, on the first run only, adds and deploys the study. On later runs, configure() restores the saved study.
5. PrintPrints each measurement as JSON. onError prints the errors that probes report, which would otherwise be uncaught errors.

Initialize CAMS first

In CAMS 2.5.1, the first SamplingPackageRegistry() call registers the built-in packages, which need the data types that CAMS adds when it initializes. Without CarpMobileSensing.ensureInitialized() first, that call throws "Null check operator used on a null value" before runApp(), and the app shows a blank screen. Creating a SmartPhoneClientManager also initializes CAMS, but creating a protocol does not.

Show the same order in your package's README, example app and doc comments. In the package's tests, initialize CAMS in the setup, as the example's tests do.

Register the package before configure(), because configure() restores the studies saved in earlier runs. It decodes their deployments, including the summary measure's AirPressureSummarySamplingConfiguration, so a package registered later comes too late and the saved study fails to load. Register user task factories and other extensions at the same point.

Run the app

Run the app on a phone from the example/ folder:

flutter run

For each reading, the console shows a line like the first one below. From about 40 seconds after the start, it also shows a line like the second one for each window:

{"sensorStartTime":1793083703208532,"data":{"__type":"com.example.airpressure","pressure":1013.2471923828125}}
{"sensorStartTime":1793083733211070,"sensorEndTime":1793083743212514,"data":{"__type":"com.example.airpressuresummary","count":10,"mean":1013.243,"min":1013.21,"max":1013.27}}

The first measurement of a study is the dk.cachet.carp.triggeredtask data that CAMS adds when the task starts. CAMS also prints [CAMS INFO] and [CAMS WARNING] lines, because its default log level shows both.

One of the warnings says that CAMS ignores the resume() call: in CAMS 2.5.1, the study's executor cannot resume yet at that point. The probes start anyway, because CAMS resumes the tasks on the phone when the phone's device manager connects.

The iOS Simulator has no barometer. There, each probe logs a warning like this one after 5 seconds and collects nothing:

[CAMS WARNING] AirPressureProbe - No barometer reading yet. The phone may have no barometer, or the user has not allowed motion access yet. Listening anyway.

Add app task types

An app task is a task that the participant performs in the app, such as a survey. A package that adds an app task type registers four things in onRegister():

RegisterWhy
The AppTask subclass, if the type has oneCAMS saves app tasks in the deployment and in the task queue.
A CompletedAppTask with the typeEvery app task adds a measure of type dk.cachet.carp.completedapptask.<type>. CAMS registers that JSON type only for its built-in task types.
Each data class that a task returns as its resultThe completed-task measurement holds the result.
A UserTaskFactory for the typeWithout a factory, CAMS does not queue a task of the type when it is triggered. Register it with AppTaskController().registerUserTaskFactory().

For a package class DiarySamplingPackage with a task type constant DIARY_TYPE and a result class DiaryEntry, onRegister() looks like this:

@override
void onRegister() {
  FromJsonFactory().registerAll([
    CompletedAppTask(taskName: '', taskType: DIARY_TYPE),
    DiaryEntry(text: ''),
  ]);
  AppTaskController().registerUserTaskFactory(DiaryUserTaskFactory());
}

The factory serves the types in its types list. Each time CAMS triggers an app task of one of them, the factory creates a user task, the runtime form of the app task:

class DiaryUserTaskFactory implements UserTaskFactory {
  @override
  List<String> types = [DiarySamplingPackage.DIARY_TYPE];

  @override
  UserTask create(AppTaskExecutor executor) => DiaryUserTask(executor);
}

CAMS picks the factory by the type string, and a later factory for the same type replaces an earlier one. Use a type string of your own, not a built-in one such as survey, which the factory of the survey package serves.

For the user task itself, see Extending the app task model. In its onStart(), call super.onStart() before you resume backgroundTaskExecutor, which super.onStart() initializes.

Next steps

Devices and services

Add a device manager for a wearable or an online service.

Privacy and data formats

Protect sensitive values and convert data to other formats.

Testing and publishing

Test the package, run it in an app and publish it.

Quick links

Page · DocumentationCARP DocumentationPage · DocumentationIntroductionPage · DocumentationQuickstartPage · DocumentationTerminologyPage · DocumentationArchitecture