feat: idempotent writeWorkoutData + accurate authorization helpers#485
Open
magic-fit wants to merge 7 commits intocarp-dk:mainfrom
Open
feat: idempotent writeWorkoutData + accurate authorization helpers#485magic-fit wants to merge 7 commits intocarp-dk:mainfrom
magic-fit wants to merge 7 commits intocarp-dk:mainfrom
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —
HKMetadataKeySyncIdentifieron iOS andMetadata.clientRecordIdon Health Connect — buthealthcurrentlydoesn't surface either, forcing consumers to either tolerate duplicate
records or ship their own native code on top of the plugin.
This PR extends
writeWorkoutDatato pass these platform-native upsertkeys 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
writeWorkoutDataparameters (all optional)syncIdentifier+syncVersionHKMetadataKeySyncIdentifier+HKMetadataKeySyncVersion— HealthKit's documented upsert mechanismclientRecordId+clientRecordVersionMetadata.clientRecordId/clientRecordVersion— HC's documented upsert mechanismstartZoneOffset/endZoneOffsetExerciseSessionRecordand linked records; preserves local-day aggregation across timezone changesdeviceTypeDevice(type = ...)for auto/activeMetadatafactoriesiosExtraMetadataHKWorkoutmetadata dictuseActiveEnergyActiveCaloriesBurnedRecordinstead ofTotalCaloriesBurnedRecord(excludes BMR, which can over-report the workout contribution)New methods
accurateAuthorizationStatus({required types})— returns athree-valued
HealthPermissionStatus(notDetermined/denied/granted). iOS readsHKHealthStore.authorizationStatus(for:)pertype and aggregates worst-case. Android delegates to
getGrantedPermissions().containsAll(...). Fills the gap left byhasPermissions, which deliberately returnsnullon iOS READintent.
isHealthConnectPackageInstalled()— returns whether thecom.google.android.apps.healthdatapackage is installed. Splitsthe ambiguity of
getHealthConnectSdkStatus'ssdkUnavailableProviderUpdateRequiredstate so callers can routethe user to an install vs update CTA.
Design notes
iOS metadata merge order
iosExtraMetadatais merged before the sync-identifier keys, so acaller cannot accidentally overwrite
HKMetadataKeySyncIdentifier/HKMetadataKeySyncVersionthrough the extras map. When no new paramsare supplied, the
HKWorkoutmetadata dict staysnil.Android linked-record IDs
ExerciseSessionRecord, linkedDistanceRecord, and linked calorierecord each get a distinct
clientRecordId:<clientRecordId><clientRecordId>:distance<clientRecordId>:caloriesThis prevents HC from rejecting the insert as a duplicate-upsert batch.
Zone offset safety
ZoneOffset.ofTotalSecondsthrowsDateTimeExceptionfor valuesoutside UTC±18h; a new
safeZoneOffset()helper catches and logs awarning 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 programmererror before the method channel call.
device_info_plusconstraintAlso includes a one-line pubspec change relaxing the
device_info_plusdependency from
^12.1.0to>=12.1.0 <14.0.0. The plugin only readsandroidInfo.idandiosInfo.identifierForVendor— both fields areunchanged 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
clientRecordVersionthat omitstotalDistanceortotalEnergyBurnedleaves the old linked record inplace (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
deleteLinkedRecordsOnRewriteopt-in in a follow-up ifyou'd prefer to land the fix here.
Tests
HealthTestContextharness:test/unit/health_plugin_writes_test.dart— every newwriteWorkoutDataparam, validation rules, backward-compattest/unit/health_plugin_permissions_test.dart— wirestring parsing, empty-types guard, null-response fail-closed
test/support/stub_device_info.dart:adds
isiOSAppOnVision(required bydevice_info_plus12.1+) anddrops the dead
serialNumberkey (removed in v13). Without theisiOSAppOnVisionaddition the existing suite was already red onevery
setUp()that callsHealth().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
1regardless of tapcount. 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:
Commits
76faee9— test: add isiOSAppOnVision to StubDeviceInfoPlugincd2ab57— feat(writeWorkoutData): idempotent-write metadata, zone offsets, active-energy opt-inddbdafd— feat: accurateAuthorizationStatus + isHealthConnectPackageInstalledbd5ca78— docs(example): idempotency demo button76718f5— chore: allow device_info_plus 13.x