Skip to content
Open
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
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Health Connect ships in the platform on Android 14+. On Android 8–13, users in

1. **Minimum SDK**: `26` (Android 8.0). The module's `build.gradle` defaults match.

2. **Declare permissions** in your host app's `AndroidManifest.xml` (or via `app.config.ts` if you use Expo prebuild). The library's own manifest declares the same set so it merges naturally.
2. **Declare permissions** in your host app's `AndroidManifest.xml` (or via `app.config.ts` if you use Expo prebuild). The library's own manifest declares its default set for backward compatibility, so remove any defaults your app does not use from the final merged manifest.
```xml
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.WRITE_STEPS" />
Expand Down Expand Up @@ -256,17 +256,42 @@ export default function HealthScreen() {

## 📚 API Reference

### `requestAuthorization(): Promise<boolean>`
### `requestAuthorization(readTypes?: string[]): Promise<boolean>`

Requests authorization to access HealthKit data.
Requests authorization to read health data. When `readTypes` is omitted, the module requests its default set for backward compatibility. Pass only the types your app reads to keep the system prompt scoped to the features you use.

**Returns:** `Promise<boolean>` - `true` if authorized, `false` otherwise

**Example:**
```typescript
const authorized = await HealthKitModule.requestAuthorization();
import {
HealthKitCategoryType,
HealthKitModule,
HealthKitQuantityType,
HealthKitWorkoutType,
} from 'react-native-nitro-healthkit';

const authorized = await HealthKitModule.requestAuthorization([
HealthKitQuantityType.STEPS,
HealthKitQuantityType.HEART_RATE,
HealthKitCategoryType.SLEEP_ANALYSIS,
HealthKitWorkoutType.WORKOUT,
]);
```

On Android, declare the matching `READ_*` permissions in the host app manifest. The optional scope controls which permissions the module checks. Because the library manifest supplies its default set for backward compatibility, use Android manifest merger removal rules for every unneeded permission:

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.health.WRITE_STEPS"
tools:node="remove" />
</manifest>
```

On iOS, HealthKit does not reveal whether a user granted or denied read access to an individual type. `checkAuthorizationStatus()` therefore reports `sharingAuthorized` when the system no longer needs to show an authorization request for that type, and `notDetermined` when a request is still needed. This includes workout data via `HealthKitWorkoutType.WORKOUT`.

---

### `getSteps(startDate: Date, endDate: Date): Promise<number>`
Expand Down
18 changes: 17 additions & 1 deletion packages/__tests__/HealthKitModule.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { HealthKitModule, HealthKitQuantityType, HealthKitCategoryType } from '../src/index';
import {
HealthKitCategoryType,
HealthKitModule,
HealthKitQuantityType,
HealthKitWorkoutType,
} from '../src/index';
import { describe, it, expect, beforeAll } from '@jest/globals';

describe('HealthKitModule', () => {
Expand All @@ -20,6 +25,17 @@ describe('HealthKitModule', () => {
const authorized = await HealthKitModule.requestAuthorization();
expect(typeof authorized).toBe('boolean');
});

it('should request authorization for a scoped set of types', async () => {
const authorized = await HealthKitModule.requestAuthorization([
HealthKitQuantityType.STEPS,
HealthKitCategoryType.SLEEP_ANALYSIS,
HealthKitWorkoutType.WORKOUT,
]);

expect(typeof authorized).toBe('boolean');
expect(HealthKitWorkoutType.WORKOUT).toBe('HKWorkoutTypeIdentifier');
});
});

describe('Legacy Methods', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.margelo.nitro.healthkit.WorkoutDataPoint
import io.github.n0ku.nitrohealthkit.auth.SecureCredentialsStore
import io.github.n0ku.nitrohealthkit.cache.CacheManager
import io.github.n0ku.nitrohealthkit.mappers.CategoryMapper
import io.github.n0ku.nitrohealthkit.mappers.PermissionMapper
import io.github.n0ku.nitrohealthkit.mappers.QuantityMapper
import io.github.n0ku.nitrohealthkit.mappers.WorkoutMapper
import io.github.n0ku.nitrohealthkit.observers.ChangesObserver
Expand Down Expand Up @@ -84,8 +85,8 @@ class HealthKitModule : HybridHealthKitSpec() {
}

/**
* Reports whether the default Health Connect permissions are
* already granted. When at least one required permission is
* Reports whether the selected (or default) Health Connect permissions
* are already granted. When at least one required permission is
* missing, also launches Health Connect's "Manage permissions"
* screen so the user can grant access without having to navigate
* there manually — this is what callers expect from "request"
Expand All @@ -101,11 +102,11 @@ class HealthKitModule : HybridHealthKitSpec() {
* the fetch on every `AppState` `active` transition, so when the
* user returns from HC the data flows in on its own.
*/
override fun requestAuthorization(): Promise<Boolean> = Promise.async {
override fun requestAuthorization(readTypes: Array<String>?): Promise<Boolean> = Promise.async {
if (HealthConnectClient.getSdkStatus(appContext) != HealthConnectClient.SDK_AVAILABLE) {
return@async false
}
val required = defaultPermissions()
val required = readTypes?.let(PermissionMapper::readPermissionsFor) ?: defaultPermissions()
val granted = client.permissionController.getGrantedPermissions()
if (granted.containsAll(required)) {
return@async true
Expand All @@ -127,9 +128,7 @@ class HealthKitModule : HybridHealthKitSpec() {
}

override fun checkAuthorizationStatus(type: String): Promise<String> = Promise.async {
val quantityPerm = QuantityMapper.readPermissionFor(type)
val categoryPerm = CategoryMapper.readPermissionFor(type)
val permission = quantityPerm ?: categoryPerm ?: return@async NOT_DETERMINED
val permission = PermissionMapper.readPermissionFor(type) ?: return@async NOT_DETERMINED
val granted = client.permissionController.getGrantedPermissions()
if (granted.contains(permission)) AUTHORIZED else DENIED
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package io.github.n0ku.nitrohealthkit.mappers

import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ExerciseSessionRecord

internal object PermissionMapper {

const val WORKOUT_TYPE = "HKWorkoutTypeIdentifier"

fun readPermissionFor(type: String): String? =
QuantityMapper.readPermissionFor(type)
?: CategoryMapper.readPermissionFor(type)
?: if (type == WORKOUT_TYPE) {
HealthPermission.getReadPermission(ExerciseSessionRecord::class)
} else {
null
}

fun readPermissionsFor(types: Array<String>): Set<String> =
types.mapNotNull(::readPermissionFor).toSet()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.github.n0ku.nitrohealthkit.mappers

import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.StepsRecord
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class PermissionMapperTest {

@Test
fun `readPermissionFor resolves quantity category and workout types`() {
assertEquals(
HealthPermission.getReadPermission(StepsRecord::class),
PermissionMapper.readPermissionFor(QuantityMapper.QT_STEPS),
)
assertEquals(
HealthPermission.getReadPermission(SleepSessionRecord::class),
PermissionMapper.readPermissionFor(CategoryMapper.CT_SLEEP_ANALYSIS),
)
assertEquals(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
PermissionMapper.readPermissionFor(PermissionMapper.WORKOUT_TYPE),
)
}

@Test
fun `readPermissionFor returns null for unsupported types`() {
assertNull(PermissionMapper.readPermissionFor("HKQuantityTypeIdentifierAppleExerciseTime"))
}

@Test
fun `readPermissionsFor ignores unsupported types and removes duplicates`() {
val permissions = PermissionMapper.readPermissionsFor(
arrayOf(
QuantityMapper.QT_STEPS,
QuantityMapper.QT_STEPS,
PermissionMapper.WORKOUT_TYPE,
"unsupported",
),
)

assertEquals(
setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
),
permissions,
)
}
}
72 changes: 43 additions & 29 deletions packages/ios/HealthKitModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ public class HealthKitModule: HybridHealthKitSpec, @unchecked Sendable {
// Default cache configuration
private let defaultCacheTTL: TimeInterval = 60 // 1 minute

// Data types to authorize (extended)
private let readTypes: Set<HKObjectType> = {
// Data types requested when callers do not provide an explicit scope.
private let defaultReadTypes: Set<HKObjectType> = {
var types: Set<HKObjectType> = []

// Workout type
Expand Down Expand Up @@ -49,12 +49,30 @@ public class HealthKitModule: HybridHealthKitSpec, @unchecked Sendable {

return types
}()

private static func objectType(for identifier: String) -> HKObjectType? {
if let quantityIdentifier = HKQuantityTypeIdentifier.from(identifier) {
return HKObjectType.quantityType(forIdentifier: quantityIdentifier)
}
if let categoryIdentifier = HKCategoryTypeIdentifier.from(identifier) {
return HKObjectType.categoryType(forIdentifier: categoryIdentifier)
}
if identifier == "HKWorkoutTypeIdentifier" {
return HKObjectType.workoutType()
}
return nil
}

private func resolveReadTypes(_ identifiers: [String]?) -> Set<HKObjectType> {
guard let identifiers else { return defaultReadTypes }
return Set(identifiers.compactMap(Self.objectType(for:)))
}

public override init() {
super.init()
}

public func requestAuthorization() throws -> Promise<Bool> {
public func requestAuthorization(readTypes: [String]?) throws -> Promise<Bool> {
return Promise.async { [weak self] in
guard let self = self else { return false }

Expand All @@ -65,9 +83,11 @@ public class HealthKitModule: HybridHealthKitSpec, @unchecked Sendable {
guard HKHealthStore.isHealthDataAvailable() else {
throw NSError(domain: "HealthKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "HealthKit not available"])
}

let requestedReadTypes = self.resolveReadTypes(readTypes)

return try await withCheckedThrowingContinuation { continuation in
self.healthStore.requestAuthorization(toShare: [], read: self.readTypes) { success, error in
self.healthStore.requestAuthorization(toShare: [], read: requestedReadTypes) { success, error in
if let error = error {
continuation.resume(throwing: error)
} else {
Expand Down Expand Up @@ -657,32 +677,26 @@ public class HealthKitModule: HybridHealthKitSpec, @unchecked Sendable {
return Promise.async { [weak self] in
guard let self = self else { return "notDetermined" }

var objectType: HKObjectType?

// Essayer d'abord comme quantity type
if let identifier = HKQuantityTypeIdentifier.from(type) {
objectType = HKObjectType.quantityType(forIdentifier: identifier)
}
// Sinon essayer comme category type
else if let identifier = HKCategoryTypeIdentifier.from(type) {
objectType = HKObjectType.categoryType(forIdentifier: identifier)
}

guard let type = objectType else {
guard let objectType = Self.objectType(for: type) else {
throw NSError(domain: "HealthKit", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid type: \(type)"])
}

let status = self.healthStore.authorizationStatus(for: type)

switch status {
case .notDetermined:
return "notDetermined"
case .sharingDenied:
return "sharingDenied"
case .sharingAuthorized:
return "sharingAuthorized"
@unknown default:
return "notDetermined"

return try await withCheckedThrowingContinuation { continuation in
self.healthStore.getRequestStatusForAuthorization(toShare: [], read: [objectType]) { status, error in
if let error = error {
continuation.resume(throwing: error)
return
}

switch status {
case .unnecessary:
continuation.resume(returning: "sharingAuthorized")
case .shouldRequest, .unknown:
continuation.resume(returning: "notDetermined")
@unknown default:
continuation.resume(returning: "notDetermined")
}
}
}
}
}
Expand Down Expand Up @@ -818,4 +832,4 @@ public class HealthKitModule: HybridHealthKitSpec, @unchecked Sendable {
}
}

}
}
14 changes: 11 additions & 3 deletions packages/nitrogen/generated/android/c++/JHybridHealthKitSpec.cpp

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading