Skip to content

Commit

Permalink
Merge pull request #58 from oldratip/feat/exposing-heartbeat-series-q…
Browse files Browse the repository at this point in the history
…uery

feat: exposing HKHeartbeatSeriesQuery for Heartbeat series data
  • Loading branch information
robertherber authored Apr 25, 2023
2 parents 6906649 + 3dd8c66 commit 6ed0cf6
Show file tree
Hide file tree
Showing 10 changed files with 261 additions and 0 deletions.
18 changes: 18 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import useSources from '@kingstinct/react-native-healthkit/hooks/useSources'
import useStatisticsForQuantity from '@kingstinct/react-native-healthkit/hooks/useStatisticsForQuantity'
import deleteQuantitySample from '@kingstinct/react-native-healthkit/utils/deleteQuantitySample'
import deleteSamples from '@kingstinct/react-native-healthkit/utils/deleteSamples'
import queryHeartbeatSeriesSamples from '@kingstinct/react-native-healthkit/utils/queryHeartbeatSeriesSamples'
import queryQuantitySamples from '@kingstinct/react-native-healthkit/utils/queryQuantitySamples'
import saveQuantitySample from '@kingstinct/react-native-healthkit/utils/saveQuantitySample'
import saveWorkoutSample from '@kingstinct/react-native-healthkit/utils/saveWorkoutSample'
Expand Down Expand Up @@ -569,6 +570,8 @@ const readPermissions: readonly HealthkitReadAuthorization[] = [
HKQuantityTypeIdentifier.distanceWalkingRunning,
HKQuantityTypeIdentifier.oxygenSaturation,
HKQuantityTypeIdentifier.heartRate,
HKQuantityTypeIdentifier.heartRateVariabilitySDNN,
'HKDataTypeIdentifierHeartbeatSeries',
HKQuantityTypeIdentifier.swimmingStrokeCount,
HKQuantityTypeIdentifier.bodyFatPercentage,
HKQuantityTypeIdentifier.bodyMass,
Expand Down Expand Up @@ -596,6 +599,7 @@ const App = () => {
}, [])

const anchor = useRef<string>()
const heartbeatsAnchor = useRef<string>()

return status !== HKAuthorizationRequestStatus.unnecessary ? (
<View style={styles.buttonWrapper}>
Expand Down Expand Up @@ -629,6 +633,20 @@ const App = () => {
>
Next 2 stepCount
</Button>
<Button onPress={async () => {
const res = await queryHeartbeatSeriesSamples({
limit: 2,
anchor: heartbeatsAnchor.current,
ascending: true,
})

heartbeatsAnchor.current = res.newAnchor

alert(JSON.stringify(res))
}}
>
Next 2 HeartbeatSeries samples
</Button>
<LatestWorkout icon='run' title='Latest workout' />
<List.AccordionGroup>
<List.Accordion title='Latest values' id='1'>
Expand Down
9 changes: 9 additions & 0 deletions ios/ReactNativeHealthkit.m
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ @interface RCT_EXTERN_MODULE(ReactNativeHealthkit, RCTEventEmitter)
reject:(RCTPromiseRejectBlock)reject
)

RCT_EXTERN_METHOD(queryHeartbeatSeriesSamples:(NSDate)from
to:(NSDate)to
limit:(NSInteger)limit
ascending:(BOOL)ascending
anchor:(NSString)anchor
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
)

RCT_EXTERN_METHOD(queryCategorySamples:(NSString)typeIdentifier
from:(NSDate)from
to:(NSDate)to
Expand Down
137 changes: 137 additions & 0 deletions ios/ReactNativeHealthkit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1422,4 +1422,141 @@ class ReactNativeHealthkit: RCTEventEmitter {
}
}
}

typealias HKAnchoredObjectQueryResult = (samples: [HKSample], deletedSamples: [HKDeletedObject]?, newAnchor: HKQueryAnchor?);

@available(iOS 13.0.0, *)
func _queryHeartbeatSeriesSamples(
store: HKHealthStore,
predicate: NSPredicate?,
limit: Int,
anchor: HKQueryAnchor?
) async throws -> HKAnchoredObjectQueryResult {
let queryResult = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<HKAnchoredObjectQueryResult, Error>) in
let query = HKAnchoredObjectQuery(
type: HKSeriesType.heartbeat(),
predicate: predicate,
anchor: anchor,
limit: limit
) { (
query: HKAnchoredObjectQuery,
s: [HKSample]?,
deletedSamples: [HKDeletedObject]?,
newAnchor: HKQueryAnchor?,
error: Error?
) in
if let err = error {
continuation.resume(throwing: err);
}

guard let samples = s else {
fatalError("Should not fail");
}

continuation.resume(returning: HKAnchoredObjectQueryResult(samples: samples, deletedSamples: deletedSamples, newAnchor: newAnchor));
}

store.execute(query);
}

return queryResult;
}

@available(iOS 13.0.0, *)
func getHeartbeatSeriesHeartbeats(store: HKHealthStore, sample: HKHeartbeatSeriesSample) async throws -> [Dictionary<String, Any>] {
let beatTimes = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<[Dictionary<String, Any>], Error>) in
var allBeats: [Dictionary<String, Any>] = [];

let query = HKHeartbeatSeriesQuery(heartbeatSeries: sample) { (
query: HKHeartbeatSeriesQuery,
timeSinceSeriesStart: TimeInterval,
precededByGap: Bool,
done: Bool,
error: Error?
) in
if let err = error {
continuation.resume(throwing: err);
}

let timeDict: Dictionary<String, Any> = [
"timeSinceSeriesStart": timeSinceSeriesStart,
"precededByGap": precededByGap
];

allBeats.append(timeDict);

if done {
continuation.resume(returning: allBeats);
}
}

store.execute(query);
}

return beatTimes;
}

@available(iOS 13.0.0, *)
func getSerializedHeartbeatSeriesSample(store: HKHealthStore, sample: HKHeartbeatSeriesSample) async throws -> Dictionary<String, Any> {
let sampleMetadata = self.serializeMetadata(metadata: sample.metadata) as! Dictionary<String, Any>;
let sampleHeartbeats = try await getHeartbeatSeriesHeartbeats(store: store, sample: sample);

return [
"uuid": sample.uuid.uuidString,
"device": self.serializeDevice(_device: sample.device) as Any,
"startDate": self._dateFormatter.string(from: sample.startDate),
"endDate": self._dateFormatter.string(from: sample.endDate),
"heartbeats": sampleHeartbeats as Any,
"metadata": self.serializeMetadata(metadata: sample.metadata),
"sourceRevision": self.serializeSourceRevision(_sourceRevision: sample.sourceRevision) as Any
];
}

@available(iOS 13.0.0, *)
@objc(queryHeartbeatSeriesSamples:to:limit:ascending:anchor:resolve:reject:)
func queryHeartbeatSeriesSamples(
from: Date,
to: Date,
limit: Int,
ascending: Bool,
anchor: String,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
guard let store = _store else {
return reject(INIT_ERROR, INIT_ERROR_MESSAGE, nil);
}

Task {
do {
let from = from.timeIntervalSince1970 > 0 ? from : nil;
let to = to.timeIntervalSince1970 > 0 ? to : nil;

let predicate = from != nil || to != nil ? HKQuery.predicateForSamples(withStart: from, end: to, options: [HKQueryOptions.strictEndDate, HKQueryOptions.strictStartDate]) : nil;

let limit = limit == 0 ? HKObjectQueryNoLimit : limit;

let actualAnchor = anchor.isEmpty ? nil : base64StringToHKQueryAnchor(base64String: anchor);

let queryResult = try await _queryHeartbeatSeriesSamples(store: store, predicate: predicate, limit: limit, anchor: actualAnchor);

var allHeartbeatSamples: [Dictionary<String, Any>] = [];
for sample in queryResult.samples as! [HKHeartbeatSeriesSample] {
allHeartbeatSamples.append(try await getSerializedHeartbeatSeriesSample(store: store, sample: sample));
}

resolve([
"samples": allHeartbeatSamples as Any,
"deletedSamples": queryResult.deletedSamples?.map({ sample in
return serializeDeletedSample(sample: sample)
}) as Any,
"newAnchor": serializeAnchor(anchor: queryResult.newAnchor) as Any
]);
} catch {
reject(GENERIC_ERROR, error.localizedDescription, error);
}
}
}
}
2 changes: 2 additions & 0 deletions src/index.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import getPreferredUnits from './utils/getPreferredUnits'
import getRequestStatusForAuthorization from './utils/getRequestStatusForAuthorization'
import queryCategorySamples from './utils/queryCategorySamples'
import queryCorrelationSamples from './utils/queryCorrelationSamples'
import queryHeartbeatSeriesSamples from './utils/queryHeartbeatSeriesSamples'
import queryQuantitySamples from './utils/queryQuantitySamples'
import querySources from './utils/querySources'
import queryStatisticsForQuantity from './utils/queryStatisticsForQuantity'
Expand Down Expand Up @@ -59,6 +60,7 @@ const Healthkit = {
// query methods
queryCategorySamples,
queryCorrelationSamples,
queryHeartbeatSeriesSamples,
queryQuantitySamples,
queryStatisticsForQuantity,
queryWorkouts,
Expand Down
5 changes: 5 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const Healthkit: typeof ReactNativeHealthkit = {
newAnchor: '',
})),
queryCorrelationSamples: UnavailableFn(Promise.resolve([])),
queryHeartbeatSeriesSamples: UnavailableFn(Promise.resolve({
samples: [],
deletedSamples: [],
newAnchor: '',
})),
queryQuantitySamples: UnavailableFn(Promise.resolve({
samples: [],
deletedSamples: [],
Expand Down
1 change: 1 addition & 0 deletions src/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const mockModule: (NativeModule & typeof Native) = {
getWorkoutRoutes: jest.fn(),
queryCategorySamples: jest.fn(),
queryCorrelationSamples: jest.fn(),
queryHeartbeatSeriesSamples: jest.fn(),
queryQuantitySamples: jest.fn(),
querySources: jest.fn(),
queryStatisticsForQuantity: jest.fn(),
Expand Down
37 changes: 37 additions & 0 deletions src/native-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,10 @@ contraceptive = 'HKCategoryTypeIdentifierContraceptive',
appleWalkingSteadinessEvent = 'HKCategoryTypeIdentifierAppleWalkingSteadinessEvent',
handwashingEvent = 'HKCategoryTypeIdentifierHandwashingEvent', // HKCategoryValue */

export type HKHeartbeatSeriesSampleMetadata = HKGenericMetadata & {
readonly HKMetadataKeyAlgorithmVersion: string;
}

export type MetadataMapperForCategoryIdentifier<
T extends HKCategoryTypeIdentifier
> = T extends HKCategoryTypeIdentifier.sexualActivity
Expand Down Expand Up @@ -1024,6 +1028,21 @@ export type HKQuantitySampleRaw<
readonly sourceRevision?: HKSourceRevision;
};

export type HKHeartbeatRaw = {
readonly timeSinceSeriesStart: number,
readonly precededByGap: boolean
}

export type HKHeartbeatSeriesSampleRaw = {
readonly uuid: string;
readonly device?: HKDevice;
readonly startDate: string;
readonly endDate: string;
readonly heartbeats: readonly HKHeartbeatRaw[];
readonly metadata?: HKHeartbeatSeriesSampleMetadata;
readonly sourceRevision?: HKSourceRevision;
}

export type HKQuantitySampleRawForSaving<
TQuantityIdentifier extends HKQuantityTypeIdentifier = HKQuantityTypeIdentifier,
TUnit extends UnitForIdentifier<TQuantityIdentifier> = UnitForIdentifier<TQuantityIdentifier>
Expand Down Expand Up @@ -1105,6 +1124,11 @@ export type DeletedCategorySampleRaw<T extends HKCategoryTypeIdentifier> = {
readonly metadata: MetadataMapperForCategoryIdentifier<T>
}

export type DeletedHeartbeatSeriesSampleRaw = {
readonly uuid: string;
readonly metadata: HKHeartbeatSeriesSampleMetadata;
}

export type DeletedQuantitySampleRaw<T extends HKQuantityTypeIdentifier> = {
readonly uuid: string;
readonly metadata: MetadataMapperForQuantityIdentifier<T>
Expand All @@ -1116,6 +1140,12 @@ export type QueryCategorySamplesResponseRaw<T extends HKCategoryTypeIdentifier>
readonly newAnchor: string
}

export type QueryHeartbeatSeriesSamplesResponseRaw = {
readonly samples: readonly HKHeartbeatSeriesSampleRaw[],
readonly deletedSamples: readonly DeletedHeartbeatSeriesSampleRaw[],
readonly newAnchor: string
}

export type QueryQuantitySamplesResponseRaw<T extends HKQuantityTypeIdentifier> = {
readonly samples: readonly HKQuantitySampleRaw<T>[],
readonly deletedSamples: readonly DeletedQuantitySampleRaw<T>[],
Expand Down Expand Up @@ -1258,6 +1288,13 @@ type ReactNativeHealthkitTypeNative = {
ascending: boolean,
anchor: string
) => Promise<QueryCategorySamplesResponseRaw<T>>;
readonly queryHeartbeatSeriesSamples: (
from: string,
to: string,
limit: number,
ascending: boolean,
anchor: string
) => Promise<QueryHeartbeatSeriesSamplesResponseRaw>;
readonly queryQuantitySamples: <
TIdentifier extends HKQuantityTypeIdentifier,
TUnit extends UnitForIdentifier<TIdentifier>
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
HKCorrelationRaw,
HKCorrelationTypeIdentifier,
HKDevice,
HKHeartbeatSeriesSampleRaw,
HKQuantityTypeIdentifier,
HKSourceRevision,
HKUnit,
Expand Down Expand Up @@ -48,6 +49,11 @@ export interface HKWorkout<
readonly endDate: Date;
}

export interface HKHeartbeatSeriesSample extends Omit<HKHeartbeatSeriesSampleRaw, 'endDate' | 'startDate'> {
readonly startDate: Date;
readonly endDate: Date;
}

export interface HKQuantitySample<
TIdentifier extends HKQuantityTypeIdentifier = HKQuantityTypeIdentifier,
TUnit extends UnitForIdentifier<TIdentifier> = UnitForIdentifier<TIdentifier>
Expand Down
12 changes: 12 additions & 0 deletions src/utils/deserializeHeartbeatSeriesSample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { HKHeartbeatSeriesSampleRaw } from '../native-types'
import type { HKHeartbeatSeriesSample } from '../types'

function deserializeHeartbeatSeriesSample(sample: HKHeartbeatSeriesSampleRaw): HKHeartbeatSeriesSample {
return {
...sample,
startDate: new Date(sample.startDate),
endDate: new Date(sample.endDate),
}
}

export default deserializeHeartbeatSeriesSample
34 changes: 34 additions & 0 deletions src/utils/queryHeartbeatSeriesSamples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Native from '../native-types'
import deserializeHeartbeatSeriesSample from './deserializeHeartbeatSeriesSample'
import prepareOptions from './prepareOptions'

import type { DeletedHeartbeatSeriesSampleRaw } from '../native-types'
import type { GenericQueryOptions, HKHeartbeatSeriesSample } from '../types'

export type QueryHeartbeatSeriesSamplesResponse = {
readonly samples: readonly HKHeartbeatSeriesSample[],
readonly deletedSamples: readonly DeletedHeartbeatSeriesSampleRaw[],
readonly newAnchor: string
}

export type QueryHeartbeatSeriesSamplesFn = (options: GenericQueryOptions) => Promise<QueryHeartbeatSeriesSamplesResponse>;

const queryHeartbeatSeriesSamples: QueryHeartbeatSeriesSamplesFn = async (options) => {
const opts = prepareOptions(options)

const result = await Native.queryHeartbeatSeriesSamples(
opts.from,
opts.to,
opts.limit,
opts.ascending,
opts.anchor,
)

return {
deletedSamples: result.deletedSamples,
newAnchor: result.newAnchor,
samples: result.samples.map(deserializeHeartbeatSeriesSample),
}
}

export default queryHeartbeatSeriesSamples

0 comments on commit 6ed0cf6

Please sign in to comment.