Skip to content
CARP Docs
Ask AISystemLightDark
On this page

Privacy and data formats

Add privacy functions that coarsen sensitive data, and transformers that convert data to a format such as Open mHealth.

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

CARP Mobile Sensing (CAMS) runs each measurement's data through data transformers, functions that take a Data object and return one. It does so before it stores the measurement or passes it to the app.

A sampling package supplies transformers for its own data types: privacy functions protect sensitive values, and format transformers convert data to another data format. The example package adds two privacy functions that round air pressure to whole hectopascals (hPa).

CAMS groups transformers in schemas, one for each namespace, keyed by data type. It applies two schemas to every measurement, in this order:

SchemaSelected byDefault
Privacy schemaprivacySchemaName of the protocolNone: data is kept as collected
Data formatdataFormat of the protocol's data endpointNameSpace.CARP: data is kept as collected

Each schema looks up the transformer for the type of the data and leaves data without one unchanged. For how a study protocol selects the schemas, see Data transformation and privacy.

Write a privacy function

A privacy function is a DataTransformer, that is, a Data Function(Data). Replace the contents of lib/barometer_privacy.dart with the two privacy functions of the example package, which round air pressure to whole hectopascals:

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

// Air pressure drops by about 0.12 hPa per meter of altitude near sea level,
// so exact readings can show which floor of a building the phone is on.
// Rounding to whole hectopascals coarsens altitude to about 8 meters, and
// rounding twice gives the same value, so applying it again is safe

/// Rounds the pressure of [AirPressure] data to whole hectopascals
///
/// Registered in the default [PrivacySchema] for the
/// [BarometerSamplingPackage.AIR_PRESSURE] measure
Data roundAirPressure(Data data) { 
  if (data is AirPressure) data.pressure = data.pressure.roundToDouble();
  return data;
}

/// Rounds the mean, minimum and maximum of [AirPressureSummary] data to
/// whole hectopascals
///
/// Registered in the default [PrivacySchema] for the
/// [BarometerSamplingPackage.AIR_PRESSURE_SUMMARY] measure
Data roundAirPressureSummary(Data data) { 
  if (data is AirPressureSummary) { 
    data
      ..mean = data.mean.roundToDouble()
      ..min = data.min.roundToDouble()
      ..max = data.max.roundToDouble();
  }
  return data;
}

One floor of a building is about 3 m high, or 0.36 hPa, so raw readings can tell floors apart. Weather changes of several hectopascals a day stay visible after rounding.

  • Test the class with is, and return any other data unchanged. carp_connectivity_package asserts the class and then casts. In a release build, where asserts are off, the cast throws a TypeError if data of another class ever has the same type.
  • Change the object in place or return a new one: CAMS replaces the measurement's data with the result.

Register the privacy functions

The example package adds its functions to the default privacy schema in onRegister() of lib/barometer_package.dart, keyed by the measure types:

  @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);
  }
  • DataTransformerSchemaRegistry registers the default PrivacySchema as soon as it is created, so lookup() always finds it. add() replaces a function that was added for the same type before.
  • The key must be the data's type, which is the data class's jsonType. CAMS looks a function up by the type of the data, not of the measure. The data classes of the example package return the measure types from jsonType, so the two are the same.
  • Never register a new PrivacySchema: register() replaces the schema of that name, and the functions that the packages added to it are gone.

Turn on privacy in the protocol

Privacy functions run only in studies whose protocol names the privacy schema:

final protocol = SmartphoneStudyProtocol(
  ownerId: 'owner@example.com',
  name: 'Air pressure',
  dataEndPoint: SQLiteDataEndPoint(),
  privacySchemaName: PrivacySchema.DEFAULT,
);

Without privacySchemaName, CAMS uses the CARP schema, which has no transformers, and stores the data as collected. Say so in your README, and list the fields that each function changes. The READMEs of carp_communication_package and carp_connectivity_package describe their privacy protection but do not say that it needs privacySchemaName.

The privacySchemaName and the dataFormat must name registered schemas. CAMS looks both up with a null check, so an unknown name makes the transformation throw for every measurement.

Make privacy functions safe to repeat

CAMS transforms a measurement once for every listener of the study's measurement stream, each time on the same Measurement object. The data manager is one listener, and an app that listens to SmartPhoneClientManager().measurements adds another. A privacy function therefore often runs twice on the same data, and a package cannot prevent it.

Write functions that give the same result when they run again. Rounding does, and the AirPressure test in test/package_test.dart of the example package applies the default schema twice to check it:

  group('Privacy', () {
    final schema = DataTransformerSchemaRegistry().lookup(
      PrivacySchema.DEFAULT,
    )!;

    test('rounds AirPressure to whole hectopascals', () {
      final data = schema.transform(AirPressure(pressure: 1013.62));
      expect((data as AirPressure).pressure, 1014);
      // Rounding again changes nothing
      expect((schema.transform(data) as AirPressure).pressure, 1014);
    });
    // ...
  });

Hashing does not give the same result: a second run hashes the hash, so the data manager and the app can get different values.

Limits of privacy functions

  • Hashing is not anonymization: an unsalted SHA-1 hash of a phone number, as carp_communication_package stores, can be reversed by hashing every possible number and comparing. Prefer removing or coarsening a value, as the example package does, and do not call hashed data anonymous.
  • Only the measurement's own data: CAMS transforms the data of each measurement by its type, not the data nested in it. A user task's result can be stored twice: as a measurement and inside the CompletedAppTask of its task, as the survey package does. A function keyed by the result's type protects only the first copy.

Convert data to another format

A format transformer converts data to a data class in another namespace. The registry creates a schema for each of these namespaces:

SchemaNamespaceHolds
CARPTransformerSchemadk.cachet.carp (NameSpace.CARP)No transformers: the default data format
OMHTransformerSchemaorg.openmhealth (NameSpace.OMH)Open mHealth (OMH) transformers that packages add
FHIRTransformerSchemaorg.hl7.fhir (NameSpace.FHIR)Fast Healthcare Interoperability Resources (FHIR) transformers that packages add
PrivacySchemadefault-privacy-schema (PrivacySchema.DEFAULT)Privacy functions that packages add

The example below converts the readings of a blood pressure package to OMH data points of the openmhealth_schemas package. It is built like the location and activity transformers of the context package. The Movisens package converts heart rate and step counts the same way, and adds a FHIR transformer for heart rate.

BloodPressureReading is the package's data class, with systolic and diastolic in millimeters of mercury (mmHg), for the measure type com.example.bloodpressure.

Adding a format takes four steps:

  1. Add the format's package

    flutter pub add openmhealth_schemas

    Import it in the library file. It also defines Acceleration, HeartRate, Measure and StepCount, which carp_core defines too, so hide them:

    import 'package:openmhealth_schemas/openmhealth_schemas.dart'
        hide Acceleration, HeartRate, Measure, StepCount;
  2. Write the data class of the format

    /// An Open mHealth (OMH) blood pressure data point, made from a
    /// [BloodPressureReading]
    @JsonSerializable(includeIfNull: false, explicitToJson: true)
    class OMHBloodPressureDataPoint extends Data {
      /// The OMH data point: a header and a blood pressure body
      DataPoint datapoint;
    
      OMHBloodPressureDataPoint(this.datapoint);
    
      /// Converts [reading] to an OMH blood pressure data point
      factory OMHBloodPressureDataPoint.fromReading(BloodPressureReading reading) =>
          OMHBloodPressureDataPoint(
            DataPoint(
              body: BloodPressure(
                systolicBloodPressure: SystolicBloodPressure(
                  unit: BloodPressureUnit.MM_OF_MERCURY,
                  value: reading.systolic,
                ),
                diastolicBloodPressure: DiastolicBloodPressure(
                  unit: BloodPressureUnit.MM_OF_MERCURY,
                  value: reading.diastolic,
                ),
              ),
              provenance: DataPointAcquisitionProvenance(
                sourceName: 'Example blood pressure monitor',
                modality: DataPointModality.SENSED,
              ),
            ),
          );
    
      /// The transformer that the package adds to the OMH schema
      static DataTransformer get transformer =>
          (data) => data is BloodPressureReading
          ? OMHBloodPressureDataPoint.fromReading(data)
          : data;
    
      // Every OMH data point has the data type org.openmhealth.data-point
      @override
      String get jsonType => '${NameSpace.OMH}.${SchemaSupport.DATA_POINT}';
    
      @override
      Function get fromJsonFunction => _$OMHBloodPressureDataPointFromJson;
      factory OMHBloodPressureDataPoint.fromJson(Map<String, dynamic> json) =>
          FromJsonFactory().fromJson<OMHBloodPressureDataPoint>(json);
      @override
      Map<String, dynamic> toJson() => _$OMHBloodPressureDataPointToJson(this);
    }

    Its jsonType puts the data in the OMH namespace, and its transformer returns any other data unchanged, like a privacy function.

  3. Add the transformer in onRegister()

    @override
    void onRegister() {
      // ...
      DataTransformerSchemaRegistry() 
          .lookup(NameSpace.OMH)!
          .add(BLOOD_PRESSURE, OMHBloodPressureDataPoint.transformer);
    }

    Add to the schema that the registry created. Never register OMHTransformerSchema again: register() replaces the schema with an empty one, and the transformers that other packages added are gone.

  4. Select the format in the protocol

    final protocol = SmartphoneStudyProtocol(
      ownerId: 'owner@example.com',
      name: 'Blood pressure in Open mHealth format',
      dataEndPoint: SQLiteDataEndPoint(dataFormat: NameSpace.OMH),
    );
Example data

A blood pressure measurement after the OMH transformer, as the data manager stores it:

{
  "sensorStartTime": 1793086326042377,
  "data": {
    "__type": "org.openmhealth.data-point",
    "datapoint": {
      "header": {
        "id": "fbbf37b0-d1d8-11f1-92fc-bb572c71a5c2",
        "creation_date_time": "2026-10-27T08:35:29.195733",
        "schema_id": {
          "namespace": "omh",
          "name": "blood-pressure",
          "version": "1.0"
        },
        "acquisition_provenance": {
          "source_name": "Example blood pressure monitor",
          "modality": "sensed"
        }
      },
      "body": {
        "systolic_blood_pressure": {
          "value": 118.0,
          "unit": "mmHg"
        },
        "diastolic_blood_pressure": {
          "value": 76.0,
          "unit": "mmHg"
        }
      }
    }
  }
}

openmhealth_schemas writes the header's creation_date_time in the phone's local time, without an offset.

Effects of a data format

  • Data types without a transformer in the schema stay in the CARP format.
  • Transformed data has the type org.openmhealth.data-point, and the data manager stores it under that type.
  • A SamplingEventTrigger and measurementsByType() see the transformed data, so they no longer match it to the original measure type.
  • CarpDataManager uploads data to CARP Web Services (CAWS) only when its type is a measure type of the deployment, which transformed data does not have. Use a data format only with a data manager that keeps the data on the phone, such as the SQLite or file data manager.
  • Every OMH data point has the same type, so FromJsonFactory can hold only one class for it. Neither CAMS nor the context package registers one, so Measurement.fromJson() cannot read stored OMH data back.
  • The privacy schema runs first, so a format transformer gets data that the privacy functions have already changed.

Next step

Testing and publishing

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

Quick links

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