public class HealthStore
- Object
- HealthStore
Reads, writes and watches the platform health store – HealthKit on iOS, Health Connect on Android, a local store elsewhere.
Obtain one from Health.getStore(); it is never null. On a port with
no health support this base class is returned as-is and every operation
fails fast with HealthError.NOT_SUPPORTED.
Division of labour
The public methods here are final. They validate requests, normalize
units, page through results, compute bucket boundaries, own the
subscription registry and persist cursors – all of it once, in shared
code. Ports implement only the do* methods, which receive
already-validated input and hand back raw platform data.
That split is deliberate: it is why a malformed query fails the same way on every platform, why unit conversion cannot drift between ports, and why no port can forget to check that the health provider is actually available.
Threading
Every method may be called from the EDT and returns immediately.
Change deliveries – HealthChangeListener and
HealthBackgroundListener – always arrive on the EDT.
Results of the operations you start arrive on the EDT on iOS and Android: both ports marshal every platform answer onto it, and the post-processing below hops back to it when it is done.
The local-backed ports do not carry that guarantee. On desktop,
JavaScript and the simulator a result is delivered on the thread that
asked for it, so a read started from a worker calls you back on that
worker and touching the UI from there needs your own callSerially.
Health and the developer guide say the same. This section has been
wrong in both directions – it claimed an unconditional EDT guarantee
the local store never offered, and then denied the one the mobile
ports do make.
Post-processing of large result sets – unit conversion across a hundred thousand samples – happens on a shared background thread, so nothing here blocks the UI.
Fields
protected static final int DEFAULT_OPERATION_TIMEOUT = 60000 | Default per-operation safety timeout for reads and writes. |
protected static final int DEFAULT_AUTHORIZATION_TIMEOUT = 300000 | Default safety timeout for authorization flows, which involve a user tapping through a sheet. |
Constructors
protected HealthStore() | Ports construct subclasses. |
Methods
Inherited methods
Field details
DEFAULT_OPERATION_TIMEOUT
protected static final int DEFAULT_OPERATION_TIMEOUT = 60000DEFAULT_AUTHORIZATION_TIMEOUT
protected static final int DEFAULT_AUTHORIZATION_TIMEOUT = 300000Constructor details
HealthStore
protected HealthStore()Health.getStore().Method details
isSupported
public boolean isSupported()true when this store can do anything at all. false on the
fallback base class.isTypeSupported
public boolean isTypeSupported(HealthDataType type)type. Even a fully supported
platform covers a subset of HealthDataType.values().getSupportedTypes
public List<HealthDataType> getSupportedTypes()isWritable
public boolean isWritable(HealthDataType type)type. Some types are read-only on
some platforms – derived metrics the OS computes itself.isSourceDeduplicationSupported
public boolean isSourceDeduplicationSupported()Whether samples of type can be deleted. Both platforms restrict
deletion to data this app wrote.
Whether this store can de-duplicate overlapping sources when it
aggregates.
False everywhere by default, and the honest answer for most stores: the shared rollup computes every metric from raw samples, so a phone and a watch that both recorded one walk contribute it twice.
HealthKit’s statistics engine does de-duplicate, which is a real
capability this API refused to expose while it levelled the two
platforms down to the behaviour of the weaker one. A store that
overrides this to true honours
AggregateQuery.setDeduplicateSources(boolean); one that does not
rejects the request rather than quietly ignoring it, so an app can
tell the difference between “de-duplicated” and “counted twice”
instead of comparing totals and guessing.
isDeletable
public boolean isDeletable(HealthDataType type)getSupportedMetrics
public List<AggregateMetric> getSupportedMetrics(HealthDataType type)type. Metrics
outside this set are computed in shared code from raw samples,
which is slower but portable.isBackgroundDeliverySupported
public boolean isBackgroundDeliverySupported()isPushDelivery
public boolean isPushDelivery()HealthSubscription.isPushDelivery().getMaxWriteBatchSize
public int getMaxWriteBatchSize()getPreferredWriteUnit
public HealthUnit getPreferredWriteUnit(HealthDataType type)type in. The base class
converts before calling doWrite(List,AsyncResource), so ports
never do unit arithmetic.requestAuthorization
public final AsyncResource<Boolean> requestAuthorization(HealthAccess... access)Presents the platform authorization UI for the requested access.
Resolving true means the flow completed, not that access was
granted. On iOS the sheet completes identically whether the user
enabled every switch or none of them, and HealthKit will not say
which. Treat true as “the user has now been asked”.
Only Android distinguishes an explicit dismissal, which fails with
HealthError.USER_CANCELED.
getWriteAuthorizationStatus
public HealthAuthorizationStatus getWriteAuthorizationStatus(HealthDataType type)type. Truthful on both
platforms.getReadAuthorizationStatus
public HealthAuthorizationStatus getReadAuthorizationStatus(HealthDataType type)This app’s read authorization for type.
Returns HealthAuthorizationStatus.UNKNOWN on iOS in every
case. HealthKit deliberately refuses to disclose read
authorization, because an app that could tell the difference
between “denied” and “no data” could infer that a user is hiding a
pregnancy or a prescription. Android answers truthfully, because
its read permissions are ordinary runtime grants.
The Android port does not paper over the difference by pretending
iOS behaves the same way, and neither should your UI: never tell a
user “you denied access” on the strength of this. Say “no data
available” and offer Health.openHealthSettings().
See hasAnyData(HealthDataType,HealthTimeRange) for the only
honest probe.
getAuthorizationRequestStatus
public final AsyncResource<HealthRequestStatus> getAuthorizationRequestStatus(HealthAccess... access)Whether presenting the authorization sheet would show the user
anything – see HealthRequestStatus. Useful for deciding whether
to show your own explainer screen first.
Answers HealthRequestStatus.UNKNOWN on both phones in this
release. Neither port implements the query yet – HealthKit’s
getRequestStatusForAuthorization and a Health Connect grant
comparison are what would answer it – so the documented
SHOULD_REQUEST and UNNECESSARY answers never arrive there and
an explainer gated on them would never show. Show the explainer on
your own terms until this reports something else, and treat
UNKNOWN as “ask anyway”: requesting authorization that is
already granted is harmless on both platforms.
hasAnyData
public final AsyncResource<Boolean> hasAnyData(HealthDataType type, HealthTimeRange range)Runs a bounded query and reports whether any sample came back.
This measures data presence, not permission, and the name says
so on purpose. On iOS a false means “denied, or genuinely no
data” and the two are indistinguishable – that is a platform
privacy guarantee, not a gap in this API.
Note also that on iOS your own writes remain readable to you even
when read access was denied, so true does not prove you can see
other apps’ data.
readSamples
public final AsyncResource<List<HealthSample>> readSamples(SampleQuery query)Reads samples, following paging until the query’s limit is reached or the data runs out.
Results are normalized: values arrive in the query’s unit or the
type’s canonical unit, and series are flattened unless
SampleQuery.setFlattenSeries(boolean) says otherwise.
readSamplePage
public final AsyncResource<SamplePage> readSamplePage(SampleQuery caller)readSamples(SampleQuery) for high-frequency types, so peak
memory stays bounded regardless of how much history exists.armTimeout
protected final void armTimeout(AsyncResource resource)Fails resource with HealthError.TIMEOUT if the platform never
answers.
getOperationTimeout() was settable and documented and nothing
ever armed a timer, so a native call that lost its callback left
the operation pending forever – which is precisely the case the
setting exists for.
armTimeout
protected final void armTimeout(AsyncResource resource, int millis)aggregate
public final AsyncResource<List<AggregateResult>> aggregate(AggregateQuery query)Summarizes data into time buckets.
Returns one AggregateResult per bucket, in chronological order,
including buckets that held no data – those come back empty rather
than being omitted, so a chart can render a gap where a gap
belongs. Read the double-counting warning on AggregateQuery
before trusting a cross-platform total.
aggregateSamples
protected final List<AggregateResult> aggregateSamples(AggregateQuery query, long[] boundaries, List<HealthSample> samples)Aggregates raw samples into the query’s buckets.
This is the one implementation of the arithmetic. Ports that have a
native aggregation API can override doAggregate and use it; ports
that do not get this for free, which is what stops a total computed
on iOS from disagreeing with the same total computed on Android
because two implementations rounded differently.
bucketBoundaries
protected final long[] bucketBoundaries(AggregateQuery query)Computes the bucket boundaries for a query, as n+1 timestamps
bounding n buckets.
Exposed to ports because both platforms want the boundaries up front, and because getting daylight-saving right exactly once is better than getting it wrong twice.
write
public final AsyncResource<HealthWriteResult> write(HealthSample sample)write
public final AsyncResource<HealthWriteResult> write(List<HealthSample> samples)Writes several samples, chunking to getMaxWriteBatchSize().
Rejects before touching the platform: types this app cannot write,
instantaneous samples of interval-only types such as
HealthDataType.STEPS, and quantities whose unit measures the
wrong dimension. Catching these here turns what would be an opaque
platform exception into a message that names the offending sample.
delete
public final AsyncResource<Integer> delete(HealthDeleteRequest request)HealthDeleteRequest.subscribe
public final HealthSubscription subscribe(SubscriptionRequest request, Class backgroundListenerClass)Subscribes with a listener that survives the app being killed.
backgroundListenerClass must be a public top-level class with a
public no-argument constructor implementing
HealthBackgroundListener – see that interface for the full
contract.
Throws
IllegalArgumentException- if the class is null or does not
implement
HealthBackgroundListener. This is checked eagerly, because the alternative is discovering the mistake weeks later when a background relaunch silently does nothing.
subscribe
public final HealthSubscription subscribe(SubscriptionRequest request, HealthChangeListener listener)Class overload if you need delivery after the app
has been killed.unsubscribe
public final void unsubscribe(String subscriptionId)getSubscriptions
public final List<HealthSubscription> getSubscriptions()drainChanges
public final AsyncResource<Integer> drainChanges()Drains pending changes for every active subscription, resolving with the number of batches delivered.
Your app decides when this happens. Nothing here hooks the
application lifecycle, so call it when you come to the foreground,
and from your background-fetch handler – Health Connect never
wakes the app on its own, and this release registers no
HKObserverQuery either, so nothing else will notice new data
while your app is closed. HealthSubscription.isPushDelivery()
answers false everywhere for the same reason.
Overlapping calls are coalesced: a drain started while one is already running does not read the same change window a second time, it resolves alongside the one in flight.
fireChanges
protected final int fireChanges(HealthChangeBatch batch)Delivers a batch to whichever listener the subscription registered, on the EDT.
The cursor is persisted only after the listener returns. That ordering means a crash inside a listener costs one redelivered batch rather than losing the data permanently – the opposite ordering would advance past data the app never actually processed.
Returns
0 when nothing was, and more
than one when the subscription’s per-batch cap split the page.
Ports add this to the count they resolve drainChanges with, which
is documented as a number of batches: returning a yes/no counted a
page of 250 additions capped at 100 as one batch while the listener
was called three times.setBackgroundListenerFactory
public static void setBackgroundListenerFactory(HealthBackgroundListenerFactory factory)Installs the build-generated factory that constructs background listeners after a process relaunch.
Called by code the build server injects into app startup, in the
same way the Android port’s native bridges are registered. There is
deliberately no reflective fallback – see
HealthBackgroundListenerFactory for why resolving a class by name
is the wrong mechanism on these targets.
loadAnchor
protected final HealthAnchor loadAnchor(String subscriptionId)seedAnchor
protected final boolean seedAnchor(HealthSubscription subscription, HealthAnchor anchor)Seeds a subscription’s starting cursor in memory and on disk, for a port that establishes one at registration – the Health Connect baseline token, say.
Both halves are needed. Persisting alone looked correct and did nothing: the next drain still found a null anchor on the handle, took a fresh cursor, and skipped exactly the window the seed existed to cover.
The seed is dropped, not applied, in two cases, because the cursor it carries comes from a platform call that started earlier and may land arbitrarily late:
- the handle is no longer the registered one. It was stopped, or
replaced by a new subscription reusing the id. Applying it would
restore a cursor
unsubscribe()promised to discard, or hand a fresh subscription a cursor issued for a different set of types. - the handle already has a cursor. Something has advanced past this seed – a drain that ran while it was in flight, or a restored cursor from a previous launch – and rewinding to it would re-deliver changes the app has already been told about.
Returns whether the seed was applied, so a port can tell a dropped seed from an accepted one.
storeAnchor
protected final void storeAnchor(String subscriptionId, HealthAnchor anchor)fireChanges(HealthChangeBatch)
after successful delivery; ports rarely need it directly.clearAnchor
protected final void clearAnchor(String subscriptionId)Drops a subscription’s cursor, in memory and on disk.
For a cursor the platform has rejected outright – a Health Connect change token that has aged out, say. Clearing only the persisted copy is not enough: the drains read the live handle, so the next one would resend the very token that just failed and keep failing identically forever.
restoreSubscriptions
protected final void restoreSubscriptions()Re-registers every subscription persisted by a previous launch.
This is what makes a subscription survive the process being killed: without it, an app relaunched into the background has no idea it was ever watching anything.
It runs on its own, the first time anything touches subscriptions –
see ensureSubscriptionsRestored. It used to be documented as
something ports had to call from their constructor, which no port
did, so persisted subscriptions were silently never restored on any
platform. A constructor could not have called it safely anyway: it
dispatches to doSubscribe, which a subclass has not finished
initialising at that point.
setOperationTimeout
protected final void setOperationTimeout(int millis)HealthError.TIMEOUT instead of an operation that hangs forever.getOperationTimeout
protected final int getOperationTimeout()setAuthorizationTimeout
protected final void setAuthorizationTimeout(int millis)Sets the authorization safety timeout in milliseconds.
Separate from the operation timeout because this one is waiting on a person rather than on a platform call, so it is far longer by default. It also bounds how long a queued authorization can wait: the screen is released when the active flow settles, and this is what guarantees that happens.
doReadSamples
protected void doReadSamples(SampleQuery query, AsyncResource<SamplePage> out)doAggregate
protected void doAggregate(AggregateQuery query, long[] boundaries, AsyncResource<List<AggregateResult>> out)Computes bucketed aggregates. boundaries holds n+1 timestamps
bounding n buckets, already daylight-saving correct.
Unlike the rest of the port SPI this does not default to
NOT_SUPPORTED. Neither HealthKit nor Health Connect gives us an
aggregation we can use directly for every metric this API exposes,
so the default reads the raw samples and runs the shared arithmetic
in aggregateSamples. A port only overrides this when its native
aggregation is genuinely better – and if it does, it owes the same
answers.
aggregateByReadingSamples
protected final void aggregateByReadingSamples(AggregateQuery query, long[] boundaries, AsyncResource<List<AggregateResult>> out)The fallback aggregation: read every requested type over the query’s range, then bucket the samples locally.
Reads one type at a time because HealthKit accepts only one type per query, and it is the narrower contract of the two.
doWrite
protected void doWrite(List<HealthSample> samples, AsyncResource<HealthWriteResult> out)getMaxWriteBatchSize(), already
validated and converted into getPreferredWriteUnit(HealthDataType).doDelete
protected void doDelete(HealthDeleteRequest request, AsyncResource<Integer> out)doRequestAuthorization
protected void doRequestAuthorization(List<HealthAccess> access, AsyncResource<Boolean> out)doGetAuthorizationRequestStatus
protected void doGetAuthorizationRequestStatus(List<HealthAccess> access, AsyncResource<HealthRequestStatus> out)Reports whether the authorization sheet would show anything.
No mobile port overrides this yet, so both phones answer
UNKNOWN; the public method says so. HealthKit answers it through
getRequestStatusForAuthorization, and Health Connect through
comparing the granted permission set against the requested one –
each of which is a real piece of work rather than a mapping.
doSubscribe
protected void doSubscribe(SubscriptionRequest request, HealthSubscription subscription)Registers a platform observer for subscription, resuming from
HealthSubscription.getAnchor() when a cursor was persisted.
Called on registration and again on restoreSubscriptions().
The live handle is passed rather than the bare anchor because a
port that establishes a starting cursor asynchronously has to be
able to tell, when its answer arrives, whether the registration it
answers for is still the current one – see
seedAnchor(HealthSubscription,HealthAnchor).
doUnsubscribe
protected void doUnsubscribe(HealthSubscription subscription)Tears down the platform observer belonging to subscription.
The handle, not the id: another registration under the same id can land between the removal and this call, and a teardown that works by id alone then dismantles the replacement’s platform state. A port must key its own bookkeeping on this instance so it tears down the generation that was actually cancelled.
doDrainChanges
protected void doDrainChanges(List<HealthSubscription> subscriptions, AsyncResource<Integer> out)fireChanges(HealthChangeBatch), resolving with the number of
batches delivered.