Skip to content

feat: idempotent writeWorkoutData + accurate authorization helpers#485

Open
magic-fit wants to merge 7 commits intocarp-dk:mainfrom
magic-fit:health/idempotency-keys
Open

feat: idempotent writeWorkoutData + accurate authorization helpers#485
magic-fit wants to merge 7 commits intocarp-dk:mainfrom
magic-fit:health/idempotency-keys

Conversation

@magic-fit
Copy link
Copy Markdown

Motivation

Apps that sync workouts from another device or backend into HealthKit /
Health Connect need reliable dedup across reinstalls, OS migrations, and
repeated network retries. Both platforms expose first-class upsert
mechanisms for exactly this — HKMetadataKeySyncIdentifier on iOS and
Metadata.clientRecordId on Health Connect — but health currently
doesn't surface either, forcing consumers to either tolerate duplicate
records or ship their own native code on top of the plugin.

This PR extends writeWorkoutData to pass these platform-native upsert
keys through, along with a couple of adjacent gaps (zone offsets,
active-vs-total calories, accurate iOS authorization state, and Health
Connect install detection). Every addition is backward-compatible — no-
new-params callers get byte-for-byte identical behaviour.

What's new

writeWorkoutData parameters (all optional)

Param Platform Role
syncIdentifier + syncVersion iOS HKMetadataKeySyncIdentifier + HKMetadataKeySyncVersion — HealthKit's documented upsert mechanism
clientRecordId + clientRecordVersion Android Metadata.clientRecordId / clientRecordVersion — HC's documented upsert mechanism
startZoneOffset / endZoneOffset Android Attached to ExerciseSessionRecord and linked records; preserves local-day aggregation across timezone changes
deviceType Android Device(type = ...) for auto/active Metadata factories
iosExtraMetadata iOS Arbitrary keys merged into the HKWorkout metadata dict
useActiveEnergy Android When true, emits ActiveCaloriesBurnedRecord instead of TotalCaloriesBurnedRecord (excludes BMR, which can over-report the workout contribution)

New methods

  • accurateAuthorizationStatus({required types}) — returns a
    three-valued HealthPermissionStatus (notDetermined / denied /
    granted). iOS reads HKHealthStore.authorizationStatus(for:) per
    type and aggregates worst-case. Android delegates to
    getGrantedPermissions().containsAll(...). Fills the gap left by
    hasPermissions, which deliberately returns null on iOS READ
    intent.

  • isHealthConnectPackageInstalled() — returns whether the
    com.google.android.apps.healthdata package is installed. Splits
    the ambiguity of getHealthConnectSdkStatus's
    sdkUnavailableProviderUpdateRequired state so callers can route
    the user to an install vs update CTA.

Design notes

iOS metadata merge order

iosExtraMetadata is merged before the sync-identifier keys, so a
caller cannot accidentally overwrite HKMetadataKeySyncIdentifier /
HKMetadataKeySyncVersion through the extras map. When no new params
are supplied, the HKWorkout metadata dict stays nil.

Android linked-record IDs

ExerciseSessionRecord, linked DistanceRecord, and linked calorie
record each get a distinct clientRecordId:

  • session: <clientRecordId>
  • distance: <clientRecordId>:distance
  • calories: <clientRecordId>:calories

This prevents HC from rejecting the insert as a duplicate-upsert batch.

Zone offset safety

ZoneOffset.ofTotalSeconds throws DateTimeException for values
outside UTC±18h; a new safeZoneOffset() helper catches and logs a
warning rather than surfacing an uncaught platform error.

Validation

Both id/version pairs are checked both directions at the Dart layer:
providing only one half throws ArgumentError — catches programmer
error before the method channel call.

device_info_plus constraint

Also includes a one-line pubspec change relaxing the device_info_plus
dependency from ^12.1.0 to >=12.1.0 <14.0.0. The plugin only reads
androidInfo.id and iosInfo.identifierForVendor — both fields are
unchanged across v12 and v13. Blocking consumers on v13 had no
source-level reason, and the full test suite is green against both
majors.

Known limitation

Rewriting a workout with a higher clientRecordVersion that omits
totalDistance or totalEnergyBurned leaves the old linked record in
place (the session's upsert succeeds but the linked records are never
revisited). Deliberately out of scope for this PR — all the use cases
we've validated rewrite with all three fields on every call. Happy to
add a separate deleteLinkedRecordsOnRewrite opt-in in a follow-up if
you'd prefer to land the fix here.

Tests

  • 25 new Dart unit tests added via the existing
    HealthTestContext harness:
    • 16 in test/unit/health_plugin_writes_test.dart — every new
      writeWorkoutData param, validation rules, backward-compat
    • 9 in test/unit/health_plugin_permissions_test.dart — wire
      string parsing, empty-types guard, null-response fail-closed
  • 1 incidental test fix in test/support/stub_device_info.dart:
    adds isiOSAppOnVision (required by device_info_plus 12.1+) and
    drops the dead serialNumber key (removed in v13). Without the
    isiOSAppOnVision addition the existing suite was already red on
    every setUp() that calls Health().

All tests pass: 77/77 green on flutter test.

Example app demo

The example app ships a Test Idempotency button that exercises the
full upsert flow end-to-end on a connected device. Tapping it writes
the same workout twice with identical sync/client ids, then queries
back and reports the count — should always be 1 regardless of tap
count. Day-bucketed session id so tomorrow's tap produces a fresh
record.

Verified on a Pixel 7 running Android 16 (Health Connect provider
installed): 2 button presses × 2 writes each = 4 total writes → 1
record
in Health Connect. iOS verification pending a paired-device
test, happy to pair with a reviewer to run it if helpful.

To reproduce:

cd example
flutter run -d <device-id>
# In the app: Authorize → tap "Test Idempotency" 2–3 times
# → read the on-screen result
# → open Apple Health / Health Connect to visually confirm

Commits

  1. 76faee9 — test: add isiOSAppOnVision to StubDeviceInfoPlugin
  2. cd2ab57 — feat(writeWorkoutData): idempotent-write metadata, zone offsets, active-energy opt-in
  3. ddbdafd — feat: accurateAuthorizationStatus + isHealthConnectPackageInstalled
  4. bd5ca78 — docs(example): idempotency demo button
  5. 76718f5 — chore: allow device_info_plus 13.x

IosDeviceInfo.fromMap in device_info_plus 12.1+ requires the
isiOSAppOnVision bool, and the stub was passing null so every test
setUp() that instantiates Health() was crashing with a type error
before the test body could run. Adds the field so the suite is
executable.
…and active-energy opt-in

Extends writeWorkoutData with nine optional parameters, all null-default
so existing callers get byte-for-byte identical behaviour.

iOS (HealthKit upsert via sync identifier):
  - syncIdentifier    -> HKMetadataKeySyncIdentifier
  - syncVersion       -> HKMetadataKeySyncVersion
  - iosExtraMetadata  -> arbitrary keys merged into HKWorkout.metadata

  Merge order: extras first, then explicit sync keys, so a caller can
  never accidentally clobber the idempotency identifiers from the extra
  map. If no new params are supplied the metadata dict stays nil.

Android (Health Connect upsert via clientRecordId):
  - clientRecordId        -> Metadata.clientRecordId
  - clientRecordVersion   -> Metadata.clientRecordVersion (same-or-lower
                             version is a no-op per HC docs)
  - startZoneOffset /     -> ExerciseSessionRecord + linked records get
    endZoneOffset            the provided ZoneOffset; safeZoneOffset()
                             catches DateTimeException for values
                             outside UTC+-18h and logs a warning instead
                             of surfacing an uncaught platform error.
  - deviceType            -> Device(type = ...) for auto/active methods
  - useActiveEnergy       -> when true the linked calorie record is
                             ActiveCaloriesBurnedRecord instead of
                             TotalCaloriesBurnedRecord (excludes BMR)

  Linked DistanceRecord and calorie record get ':distance' / ':calories'
  suffixes derived from the caller's clientRecordId so HC doesn't reject
  the batch as duplicate upserts.

Validation:
  - syncIdentifier <-> syncVersion both required together
  - clientRecordId <-> clientRecordVersion both required together

Known limitation (documented in PR description): rewriting a workout
with a higher clientRecordVersion that omits totalDistance or
totalEnergyBurned leaves the old linked record in place. Out of scope
for this change - all use cases we ship rewrite with all three fields.

Covered by 16 new unit tests in test/unit/health_plugin_writes_test.dart.
…lled

accurateAuthorizationStatus({required types}) returns a three-valued
HealthPermissionStatus (notDetermined / denied / granted) via worst-case
aggregation across the requested data types. Fills the gap left by
hasPermissions - which deliberately returns null on iOS READ intent
and collapses per-type state on Android into a single boolean.

  iOS:     HKHealthStore.authorizationStatus(for:) per type, expanding
           the NUTRITION composite and covering characteristic types.
           Any .sharingDenied -> denied; any .notDetermined -> nd;
           otherwise granted.
  Android: getGrantedPermissions().containsAll(writePermsForTypes).
           HC exposes no notDetermined state so Android returns only
           granted/denied; callers treat the nd branch as iOS-specific.

isHealthConnectPackageInstalled() distinguishes 'not installed' from
'needs update' - a distinction getHealthConnectSdkStatus collapses
into sdkUnavailableProviderUpdateRequired. Lets consumers route the
user to an install vs update CTA.

  iOS:     returns true unconditionally (vacuous).
  Android: PackageManager.getPackageInfo('com.google.android.apps.
           healthdata', 0) wrapped in NameNotFoundException catch.

Covered by 9 new unit tests in
test/unit/health_plugin_permissions_test.dart.
Adds a 'Test Idempotency' button to the example app that writes the
same workout twice with identical syncIdentifier / clientRecordId,
then queries back and reports the matching count. Serves as a
hand-operated smoke test on real devices and as PR evidence - repeated
taps collapse to exactly one record in Apple Health / Health Connect.

Uses a day-bucketed session id so every tap within a day dedups and
the next day's tap produces a fresh record for repeat testing.

Result is surfaced via setState + the existing _content switch (a
SnackBar would silently fail because HealthAppState's context sits
above MaterialApp, so ScaffoldMessenger.of would not be resolvable).
The plugin only reads androidInfo.id and iosInfo.identifierForVendor —
both fields are unchanged across device_info_plus 12 and 13. The
previous ^12.1.0 constraint blocks consumers already pinned to 13.x
without any compatibility reason in the source.

Relaxes to '>=12.1.0 <14.0.0'. pubspec.lock now resolves
device_info_plus 13.0.0; full unit test suite still green on both
majors.

Also drops the dead 'serialNumber' key from the iOS/Android stub maps
in test/support/stub_device_info.dart — the field was removed in
device_info_plus 13. It was silently ignored by AndroidDeviceInfo.fromMap
(Dart maps don't fail on extra keys), but leaving it in the stub hides
the real shape of the map.
Both iOS and Android previously swallowed write failures as
`result.success(false)`, hiding the native error cause from the Dart
caller. Callers that want to classify a failure as permissionDenied
vs platformUnavailable vs transient had no signal to work with.

- Android `writeWorkoutData` now emits `result.error(code, ...)` on
  exception with codes SECURITY_EXCEPTION / IO_EXCEPTION /
  REMOTE_EXCEPTION / WRITE_ERROR (fallback). Unsupported workout type
  still returns false (programmer-error early return).
- iOS `writeWorkoutData` now inspects the HKHealthStore.save
  completion's NSError and emits a FlutterError with codes
  HK_AUTHORIZATION_DENIED / HK_AUTHORIZATION_NOT_DETERMINED /
  HK_DATA_UNAVAILABLE / HK_DATA_RESTRICTED / HK_INVALID_ARGUMENT /
  HK_DATABASE_INACCESSIBLE / HK_USER_CANCELED / HK_ERROR_<raw>
  (unmapped HKError.Code) / WRITE_UNKNOWN (save returned false with
  no error) / WRITE_ERROR (non-HKErrorDomain). details carries
  domain + nativeCode.

Dart-level `writeWorkoutData` contract:
- returns true  → success
- returns false → unsupported workout type only
- throws PlatformException with the typed code → any runtime failure

Added test coverage for SECURITY_EXCEPTION and HK_AUTHORIZATION_DENIED
PlatformException propagation through the Dart wrapper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant