Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Common pitfalls

The rules that keep a sampling package from losing data, with the CARP packages that broke them and the fixes.

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

A sampling package loses data in a few known ways. Most rules below come from a defect in a CARP sampling package or in CARP Mobile Sensing (CAMS) 2.5.1 itself.

The rules are ordered by how much data breaking them loses. The first ones stop the app or drop whole studies, and the last ones cost accuracy or privacy. Check your package against them before you publish it.

RuleWhat breaking it loses
Initialize CAMS before you register packagesEverything: the app throws at startup.
Register every class that CAMS storesThe study at the next start, and every study saved after it.
Report the device status that CAMS acts onEvery measure on the device.
Create every probe, and check the device at resumeThe measure, for good.
Keep one device manager per device typeEvery measure on the device.
Give each probe the configuration it expectsThe measure, without an error.
Register a user task factory for each app task typeEvery app task of that type.
Make each data type equal its measure typeThe upload of the data, and its triggers and privacy functions.
Declare the permissions that each measure needsThe measure on Android, or the fields that need the permission.
Report errors and keep the rest of the dataThe records after a failure, or the whole measure.
Get units and timestamps rightTimestamps and values that analysis can use.
Leave the deployment's configurations unchangedThe measure until a restart, or data that is stored again.
Make resume safe to repeat, and release what a probe startsData after a reconnect, and battery while the probe is paused.
Emit data as soon as it existsA recording when the app is killed, and the first events.
Know when transformers runNo data, but data can leave the phone unprotected.

Initialize CAMS before you register packages

  • What breaks: the first call to SamplingPackageRegistry() registers the built-in CAMS packages, and one of them needs CAMS to be initialized. Registering a package before CarpMobileSensing.ensureInitialized() throws "Null check operator used on a null value", and the app shows a blank screen. Registering after SmartPhoneClientManager().configure() is too late: configure() restores the saved studies and app tasks, which need the package's classes and factories.
  • Seen in: the README of every CARP sampling package, and the doc comments of the registry and of the eSense package, leave out the initialization.
  • Fix: initialize CAMS, register the packages and then configure the client, in your app, README, example and tests. Creating SmartPhoneClientManager() also initializes CAMS. The example app's main() keeps this order:
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  CarpMobileSensing.ensureInitialized();

  // 1. Register the sampling package
  SamplingPackageRegistry().register(BarometerSamplingPackage());
  // ...
  final client = SmartPhoneClientManager();
  await client.configure();
  // ...
}

For the reasons behind this order, see Startup order.

Register every class that CAMS stores

  • What breaks: CAMS saves protocols, deployments and measurements as JSON and reads them back through FromJsonFactory. At the next start, the first study with an unknown class throws a SerializationException, and CAMS drops it with every study saved after it. With CARP Web Services (CAWS), one unregistered data class makes every upload attempt of its study fail.
  • Seen in: carp_movesense_package never registers its device registration class, so a study fails to load at the first start after the sensor has connected. carp_polar_package registers its class, but one field holds a plugin enum that the plugin writes in one form and the package reads in another. Both packages' tests missed it: one decodes without FromJsonFactory, the other leaves that field empty.
  • Fix: in onRegister(), register every class that a protocol, a deployment or a measurement can hold. These are device configurations and registrations, sampling configurations, app tasks and their CompletedAppTask types, and data classes.

Test each class through FromJsonFactory, with every field set. The example package's test/json_test.dart does this for each class:

  test('AirPressure round trip', () {
    final json = roundTrip(AirPressure(pressure: 1013.25));
    expect(json[Serializable.CLASS_IDENTIFIER], 'com.example.airpressure');

    final data = FromJsonFactory().fromJson<Data>(json);
    expect(data, isA<AirPressure>());
    expect((data as AirPressure).pressure, 1013.25);
  });

For the other round trips, see Test the JSON round trips.

Report the device status that CAMS acts on

  • What breaks: CAMS resumes a device's tasks when its status becomes connected, pauses them on disconnecting and resumes them on reconnected. Any other sequence leaves the device's measures stopped. connect() also sets the status that onConnect() returns, and does nothing while the status is connecting.
  • Seen in: carp_movisens_package returns connecting even on success. When it has to scan for the sensor, nothing sets connected later, so its tasks never start. carp_polar_package reports connected when the first data type arrives, so its other probes start before their data. carp_esense_package and carp_movesense_package report a lost link as disconnected, which pauses nothing.
  • Fix: set connected when the device is ready, disconnecting when the link is lost and reconnected when it is back. Return status from onConnect(), not a constant, and put a timeout on the SDK's connect call. The thermometer example maps its link events like this:
void _onLinkChanged(bool isUp) {
  if (isUp) {
    status = _hasConnected ? DeviceStatus.reconnected : DeviceStatus.connected;
    _hasConnected = true;
  } else {
    status = DeviceStatus.disconnecting;
  }
}

For the complete device manager, see Devices and services.

Create every probe, and check the device at resume

  • What breaks: CAMS creates and initializes the probes before it connects the devices. A measure whose create() returns null never collects, and a probe whose onInitialize() returns false ignores every later resume.
  • Seen in: carp_movesense_package creates its temperature probe only when it knows the sensor model, which it learns after connecting. The activity and heart rate probes of carp_movisens_package return false from onInitialize() until the sensor is connected.
  • Fix: return a probe for every measure type, and do not use deviceManager in the probe's constructor: the registry sets it after create() returns. Check the device in onResume() or in the stream getter, which CAMS reads when the probe resumes. The thermometer example's probe checks it in the getter:
@override
Stream<Measurement>? get stream => deviceManager.isConnected
    ? deviceManager.temperatures.map(
        (celsius) => Measurement.fromData(SkinTemperature(celsius: celsius)),
      )
    : null;

For when CAMS creates the probes, see Read the device in a probe.

Keep one device manager per device type

  • What breaks: CAMS finds a device's manager by the type of the device configuration, and gives each probe the value of the package's deviceManager getter. A deviceType that differs from the configuration's type leaves the device unconfigured. A getter that builds a new manager gives the probes one that CAMS never configures or connects.
  • Seen in: no CARP package; the rule follows from how CAMS matches devices.
  • Fix: keep the manager in a final field, and return the configuration's DEVICE_TYPE, which must equal dk.carp.cams.devices. followed by the configuration's class name:
final ThermoDeviceManager _deviceManager = ThermoDeviceManager();

@override
String get deviceType => ThermoDevice.DEVICE_TYPE;

@override
ThermoDeviceManager get deviceManager => _deviceManager;

See Expose the device in the package.

Give each probe the configuration it expects

  • What breaks: IntervalProbe and the periodic and buffering probes of CAMS cast their sampling configuration with as. A measure type without a default configuration gets a NoOptionsSamplingConfiguration, so the cast throws inside onResume(). CAMS does not catch it, and the probe never starts, without an error measurement.
  • Seen in: the geofence probe of carp_context_package throws when its measure has no configuration. The beacon probe of carp_connectivity_package throws for any other configuration class.
  • Fix: give every measure type whose probe needs a configuration a default of that class. Read the configuration with a type test and a fallback, and check its values in onResume(). AirPressureProbe in the example package falls back to its default period:
  /// The requested time between readings
  Duration get samplingPeriod => switch (samplingConfiguration) {
    IntervalSamplingConfiguration configuration => configuration.interval,
    _ => BarometerSamplingPackage.DEFAULT_SAMPLING_PERIOD,
  };

For defaults and value checks, see Sampling configurations.

Register a user task factory for each app task type

  • What breaks: CAMS queues an app task only if a user task factory is registered for its type. Without one, it logs a warning and drops the task, also when it restores the task queue at start. Each app task also adds a dk.cachet.carp.completedapptask.<type> measure, which CAMS can decode only for its built-in task types.
  • Seen in: carp_audio_package defines audio, video and image measures but no factory for their app task types, so those app tasks never reach the participant.
  • Fix: register the factory and the JSON classes in onRegister(), so that they are in place before configure():
@override
void onRegister() {
  FromJsonFactory().registerAll([
    CompletedAppTask(taskName: '', taskType: DIARY_TYPE),
    DiaryEntry(text: ''),
  ]);
  AppTaskController().registerUserTaskFactory(DiaryUserTaskFactory());
}

In the user task, call backgroundTaskExecutor.resume() after super.onStart(), which initializes the executor; executors have no start(). See Add app task types.

Make each data type equal its measure type

  • What breaks: a data object's type is its jsonType, by default dk.cachet.carp. followed by the class name in lowercase. CAMS stores the data under that type and matches triggers and privacy functions by it. The CAWS data manager uploads only the types that are measure types of the deployment.
  • Seen in: the activity and heart rate probes of carp_movisens_package emit subtypes such as dk.cachet.carp.movisens.activity.steps, which the CAWS data manager never uploads. Two of its classes use the data type of a third, so their data decodes as the wrong class. In CAMS itself, the data of NON_GRAVITATIONAL_ACCELERATION is stored as dk.cachet.carp.acceleration.
  • Fix: return the measure type constant from jsonType, in a namespace that you control, and give each class its own type. Choose a last segment that does not occur earlier in the type. DataType.fromString() cuts the namespace at the first occurrence of the name, so com.example.example parses as the namespace com.

The example package's AirPressure returns its measure type:

  // The data type must be the measure type, see BarometerSamplingPackage
  @override
  String get jsonType => BarometerSamplingPackage.AIR_PRESSURE;

See Return the measure type from jsonType.

Declare the permissions that each measure needs

  • What breaks: when CAMS deploys or restores a study, it asks only for the permissions in each measure's CamsDataTypeMetaData. On Android, the base probes do not start without them, so a missing permission stops the measure or leaves fields empty. An extra permission asks for access that the measure does not need, and stops the measure on Android when the participant denies it.
  • Seen in: the Wi-Fi measure of carp_connectivity_package declares no location permission, which Android needs for the network name. The text message measure of carp_communication_package declares the phone permission instead of the SMS permission.
  • Fix: declare exactly the Android permission groups that each probe needs, and leave out the permissions to reach a device. List the manifest and Info.plist entries in your README. The measures of the example package declare none, because Android needs no permission for the barometer.

For how CAMS asks for permissions, see Declare permissions.

Report errors and keep the rest of the data

  • What breaks: an exception while converting one record can lose that record, the rest of a batch or the whole measure. A MeasurementProbe whose getMeasurement() throws never pauses itself, so its task ignores the next trigger. Errors that a probe keeps to itself never reach the app or the data manager.
  • Seen in: carp_communication_package force-unwraps a date that incoming text messages never have, so it loses all of them. The health probe of carp_health_package reads all its data types in one call, so one failing type empties the measure. It also reports its errors on a stream that nothing listens to.
  • Fix: convert each record in its own try, and report failures with addError(), which the data manager stores as an error measurement. In getMeasurement(), return an Error measurement or null instead of throwing. PluginRecord stands for a record of the plugin:
/// Adds one measurement for each record, and reports the records that it
/// cannot convert
void addRecords(List<PluginRecord> records) {
  for (final record in records) {
    try {
      addMeasurement(Measurement.fromData(Reading.fromRecord(record)));
    } catch (error) {
      addError('$runtimeType - Cannot convert $record: $error');
    }
  }
}

For error handling in probes, see Probes.

Get units and timestamps right

  • What breaks: CAMS measurement times are microseconds since the epoch, and the durations in its JSON are microseconds. A DateTime is written with a Z only when it is in UTC. A value in the wrong unit or time zone dates every record wrong.
  • Seen in: carp_communication_package reads Android's millisecond dates as microseconds, so every call and text message is dated January 1970. The activity probe of carp_context_package stamps the time the event reaches the app, not the time of the activity. The Bluetooth scan of carp_connectivity_package declares a time span but sets no end time.
  • Fix: convert each plugin value with its documented unit, and use the sensor's time when it has a reliable one. Convert DateTime values to UTC, and set sensorEndTime on data that covers a period. AirPressureSummaryProbe in the example package sets both times of its window:
  @override
  Future<Measurement?> getMeasurement() async => _readings.isEmpty
      ? null
      : Measurement(
          sensorStartTime: _windowStart,
          sensorEndTime: _windowEnd,
          data: AirPressureSummary.fromReadings(_readings),
        );

Test each conversion against recorded plugin data, field by field. The test of carp_movesense_package checks only the type of its motion data, which holds accelerometer values where the gyroscope and magnetometer values belong.

For timestamps and units, see Data classes.

Leave the deployment's configurations unchanged

  • What breaks: a sampling configuration belongs to the deployment, or is a package default that every probe of the type shares. A probe that changes one changes it for every probe and study that uses it, and fails on a const list. A history measure keeps its lastTime over a restart only when its configuration belongs to the deployment.
  • Seen in: the health probe of carp_health_package removes unsupported types from the configured list in place. On a const list this throws, and the measure stops until the app restarts. APP_USAGE, PHONE_LOG, CALENDAR and HEALTH measures without their own configuration read their whole default window again after every restart, and store overlapping data.
  • Fix: copy a configuration's lists before you filter them, and give protocol authors a method that returns a history measure with its own configuration. Say in your README that a task holds one measure per type: CAMS collapses two measures of one type in one task into one. See Read history since the last collection.

Make resume safe to repeat, and release what a probe starts

  • What breaks: CAMS calls onResume() again on a resumed probe, so whatever onResume() starts without stopping the old one runs twice. A probe that keeps its stream gets nothing after a reconnect, and a broadcast wrapper keeps the sensor running while the probe is paused. A stream that completes closes the probe for good.
  • Seen in: the geofence probe of carp_context_package adds a location listener at every resume. Its activity probe keeps activity recognition running while it is paused. The sensor probe of carp_esense_package keeps its stream, so its data stops after a reconnect, and carp_movesense_package never cancels its battery timer.
  • Fix: build a fresh stream in the stream getter, and keep one timer. Cancel a subscription before you listen again and in onPause(), and stop native work when the probe pauses. AirPressureSummaryProbe in the example package ignores a second resume:
  @override
  Future<bool> onResume() async {
    // CAMS also resumes a probe that is already resumed, and a second
    // resume would start a second window timer
    if (state == ExecutorState.Resumed) return true;
    if (!await checkBarometer(samplingPeriod)) return false;
    return super.onResume();
  }

For the probe base classes that need this guard, see Resume guard.

Emit data as soon as it exists

  • What breaks: CAMS stores only the measurements that a probe has added. Data that a probe holds back until it pauses is lost when the app is killed. Polling and window probes add their first measurement one interval after they resume, so an app that restarts more often than that collects nothing.
  • Seen in: carp_audio_package adds the measurement of a recording only when its probe pauses, so a killed app leaves a file without a measurement. carp_movesense_package skips the first 10 notifications of every subscription, which for its tap measure are the first 10 taps.
  • Fix: add each measurement when its data is complete, and report long recordings in parts. Never skip or replay events, and choose default intervals with the first delay in mind.

For the first delay of interval and window probes, see Set the default configurations.

Know when transformers run

  • What breaks: no data is lost, but data can leave the phone unprotected. Privacy functions run only when the protocol sets privacySchemaName, and they can run twice on the same data. A hash is not anonymization, and registering a built-in schema again drops every transformer that packages added to it.
  • Seen in: the READMEs of carp_communication_package and carp_connectivity_package do not say that their privacy protection needs privacySchemaName, and their anonymizers hash values without a salt.
  • Fix: write privacy functions that give the same result when they run again, and add transformers to the existing schemas. Say in your README which fields the functions change, and that they need privacySchemaName. See Privacy and data formats.

Next steps

Testing and publishing

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

Extending CAMS

Add custom triggers, probes, data managers and transformer schemas.

Best practices

Android and iOS setup recommendations, permissions and troubleshooting.

Quick links

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