Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/typed-healthkit-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@kingstinct/react-native-healthkit': major
---

Make typed `metadata` the canonical metadata API and generate more of the HealthKit type surface from Apple’s SDK.

This release introduces generated identifier/value/unit metadata, typed metadata payloads on returned models, generic quantity sample typing, and SDK-backed schema verification to keep the surfaced API aligned with the pinned Xcode HealthKit SDK.

Breaking changes:

- Remove the legacy flattened `metadataX` fields from returned models.
- Make `metadata` the single canonical metadata surface.

Migration examples:

- `sample.metadataExternalUUID` -> `sample.metadata.HKExternalUUID`
- `sample.metadataWeatherCondition` -> `sample.metadata.HKWeatherCondition`
- `workout.metadataAverageMETs` -> `workout.metadata.HKAverageMETs`
- `categorySample.metadataMenstrualCycleStart` -> `categorySample.metadata.HKMenstrualCycleStart`

This is intended to make the library easier to extend over time: Apple SDK metadata flows into the generated schema and typed `metadata` surface with much less hand-maintained code.
6 changes: 3 additions & 3 deletions .github/workflows/package-preview.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
name: Package Preview

on:
pull_request_target:
pull_request:

permissions: {}
Comment thread
robertherber marked this conversation as resolved.

jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: refs/pull/${{ github.event.number }}/merge

- uses: oven-sh/setup-bun@v2
with:
Expand Down
87 changes: 82 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,47 @@ on:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
changes:
runs-on: ubuntu-latest
outputs:
healthkit_contracts: ${{ steps.filter.outputs.healthkit_contracts }}
swift_native: ${{ steps.filter.outputs.swift_native }}
permissions:
pull-requests: read

steps:
- uses: actions/checkout@v4

- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
healthkit_contracts:
- '.bun-version'
- 'bun.lock'
- 'package.json'
- 'packages/react-native-healthkit/src/**'
- 'packages/react-native-healthkit/ios/**'
- 'packages/react-native-healthkit/cpp/**'
- 'packages/react-native-healthkit/scripts/**'
- 'packages/react-native-healthkit/nitrogen/**'
- 'packages/react-native-healthkit/package.json'
- 'packages/react-native-healthkit/tsconfig*.json'
- 'apps/example/contracts/**'
- 'apps/example/scripts/run-healthkit-contracts.sh'
- 'apps/example/app.json'
- 'apps/example/app/contracts.tsx'
- 'apps/example/app/_layout.tsx'
- 'apps/example/app/auth.tsx'
- 'apps/example/constants/AllUsedIdentifiersInApp.ts'
- 'apps/example/ios/**'
- 'apps/example/package.json'
- '.github/workflows/test.yml'
swift_native:
- 'packages/react-native-healthkit/ios/**'
- 'packages/react-native-healthkit/cpp/**'
- 'apps/example/ios/**'

test:
# The type of runner that the job will run on
runs-on: ${{ inputs.os || 'ubuntu-latest' }}
Expand Down Expand Up @@ -95,8 +136,10 @@ jobs:
run: bun run lint

swiftlint:
needs: changes
if: needs.changes.outputs.swift_native == 'true'
# The type of runner that the job will run on
runs-on: ${{ inputs.os || 'macos-latest' }}
runs-on: macos-15
timeout-minutes: 10

# Steps represent a sequence of tasks that will be executed as part of the job
Expand Down Expand Up @@ -139,9 +182,11 @@ jobs:
# xcodebuild -downloadPlatform iOS
# # optional but useful:
build-ios:
needs: changes
if: needs.changes.outputs.healthkit_contracts == 'true'
# Only run on macOS since we need Xcode
runs-on: macos-15
timeout-minutes: 50
timeout-minutes: 25

steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
Expand All @@ -157,13 +202,45 @@ jobs:
## Xcode 26.2 (Swift 6.2) - latest stable
- run: sudo xcode-select -s /Applications/Xcode_26.2.app

- name: Install simulator tooling
run: |
brew tap wix/brew
brew install applesimutils

- name: Verify generated HealthKit schema and bindings
working-directory: packages/react-native-healthkit
run: bun run check:generated

- run: bun run codegen
working-directory: packages/react-native-healthkit

- name: Expo Prebuild
working-directory: apps/example
run: bunx expo prebuild --platform ios
run: bunx expo prebuild --platform ios --non-interactive

- name: Build iOS project
working-directory: apps/example
run: bun run build-sim
working-directory: apps/example/ios
run: |
SIMULATOR_ID="$(
xcrun simctl list devices available |
sed -n 's/^[[:space:]]*iPhone[^()]* (\([0-9A-F-][0-9A-F-]*\)) (.*/\1/p' |
head -n 1
)"
if [ -z "$SIMULATOR_ID" ]; then
echo "Unable to find an available iPhone simulator." >&2
exit 1
fi
xcodebuild \
-quiet \
-workspace RNHealthKit.xcworkspace \
-scheme RNHealthKit \
-configuration Debug \
-sdk iphonesimulator \
-destination "id=$SIMULATOR_ID" \
ONLY_ACTIVE_ARCH=YES \
COMPILER_INDEX_STORE_ENABLE=NO \
DEBUG_INFORMATION_FORMAT=dwarf \
build

- name: Run HealthKit contracts
run: bun run test:contracts
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ DerivedData
*.ipa
*.xcuserstate
project.xcworkspace
**/.build
**/.swiftpm

# Android/IJ
#
Expand Down Expand Up @@ -79,4 +81,4 @@ lib/
packages/react-native-healthkit/nitrogen/generated
apps/example/android

tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
10 changes: 10 additions & 0 deletions apps/example/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
FitzpatrickSkinType,
WheelchairUse,
} from '@kingstinct/react-native-healthkit/types/Characteristics'
import { router } from 'expo-router'
import { useEffect, useMemo, useState } from 'react'
import { ListItem, type ListItemProps } from '@/components/SwiftListItem'
import { enumKeyLookup } from '@/utils/enumKeyLookup'
Expand Down Expand Up @@ -175,6 +176,15 @@ const CoreTab = () => {
/>
))}
</Section>
<Section title="Contracts">
<ListItem
title="Open Contract Harness"
subtitle="Hidden E2E/contract screen"
onPress={() => {
router.push('/contracts')
}}
/>
</Section>
</List>
</Host>
)
Expand Down
37 changes: 36 additions & 1 deletion apps/example/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
ThemeProvider,
} from '@react-navigation/native'
import { useFonts } from 'expo-font'
import { Stack } from 'expo-router'
import { Stack, usePathname, useRouter } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { useEffect } from 'react'
import 'react-native-reanimated'

import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { readLaunchCommand } from '@/contracts/launchCommand'
import { useColorScheme } from '@/hooks/useColorScheme'

export const unstable_settings = {
Expand All @@ -17,10 +19,37 @@ export const unstable_settings = {

export default function RootLayout() {
const colorScheme = useColorScheme()
const pathname = usePathname()
const router = useRouter()
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
})

useEffect(() => {
if (!loaded) {
return
}

const launchCommand = readLaunchCommand()
if (!launchCommand) {
return
}

if (
launchCommand.route === 'contracts' &&
pathname !== '/contracts' &&
pathname !== '/auth'
) {
router.replace({
pathname: '/contracts',
params: {
autorun: launchCommand.autorun,
scenario: launchCommand.scenario,
},
})
}
}, [loaded, pathname, router])
Comment thread
robertherber marked this conversation as resolved.

if (!loaded) {
// Async font loading only occurs in development.
return null
Expand All @@ -32,6 +61,12 @@ export default function RootLayout() {
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
<Stack.Screen
name="contracts"
options={{
title: 'HealthKit Contracts',
}}
/>
<Stack.Screen
name="auth"
options={{
Expand Down
43 changes: 42 additions & 1 deletion apps/example/app/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@ import {
AllObjectTypesInApp,
AllSampleTypesInApp,
} from '@/constants/AllUsedIdentifiersInApp'
import {
clearLaunchCommand,
readLaunchCommand,
} from '@/contracts/launchCommand'
import { initSubscriptions } from '@/state/subscriptionEvents'
import { enumKeyLookup } from '@/utils/enumKeyLookup'

const authEnumLookup = enumKeyLookup(AuthorizationRequestStatus)

export default function AuthScreen() {
const launchCommand = readLaunchCommand()

const requestAuth = useCallback(async () => {
try {
const res = await requestAuthorization({
Expand All @@ -32,11 +38,23 @@ export default function AuthScreen() {
initSubscriptions()
alert(`response: ${res}`)

if (launchCommand?.route === 'contracts') {
clearLaunchCommand()
router.replace({
pathname: '/contracts',
params: {
autorun: launchCommand.autorun,
scenario: launchCommand.scenario,
},
})
return
}

router.replace('/')
} catch (error) {
console.error('Error requesting authorization:', error)
}
}, [])
}, [launchCommand])

const [status, setStatus] = useState<AuthorizationRequestStatus | null>(null)

Expand All @@ -63,6 +81,29 @@ export default function AuthScreen() {
updateStatus()
}, [])

useEffect(() => {
if (status === AuthorizationRequestStatus.unnecessary) {
if (launchCommand?.route === 'contracts') {
clearLaunchCommand()
router.replace({
pathname: '/contracts',
params: {
autorun: launchCommand.autorun,
scenario: launchCommand.scenario,
},
})
}
return
}

if (
status === AuthorizationRequestStatus.shouldRequest &&
launchCommand?.route === 'contracts'
) {
void requestAuth()
}
}, [launchCommand, requestAuth, status])

return (
<Host style={{ paddingTop: 40 }}>
<VStack>
Expand Down
Loading
Loading