React NativeでHealthKitとHealth Connectと連携する

## はじめに
iOSには[HealthKit](https://developer.apple.com/jp/health-fitness/)、Androidには[Health Connect](https://health.google/intl/ja/health-connect-android/)と呼ばれるユーザーの睡眠時間や歩数等のヘルスデータを管理する仕組みが組み込まれています（それぞれiOS 8、 Android 14からで、Android 13以前のAndroidユーザーは手動でHealth Connectをインストールすることで利用可能）。また、iOSには[ヘルスケア](https://www.apple.com/jp/ios/health/)、Androidには[Google Fit](https://www.google.com/intl/ja_jp/fit/)というデフォルトでインストールされているアプリがあり、これらを使うことでユーザーはHealthKit、Health Connectに蓄積された自分のヘルスデータの確認が可能となっています。

https://developer.apple.com/jp/health-fitness/

https://health.google/intl/ja/health-connect-android/

今回はReact Nativeを使ったサードパーティ製アプリでiOS、Androidそれぞれのヘルスデータを取得するために必要な設定や簡単なサンプルコードをご紹介します。iOSとAndroidでそれぞれ違ったデータ構造や内部的なデータベースへのクエリを扱うため、利用するライブラリはそれぞれ異なります。iOSには[react-native-healthkit](https://github.com/kingstinct/react-native-healthkit)、androidには[react-native-health-connect](https://github.com/matinzd/react-native-health-connect)というライブラリがあります。

iOS向けに[react-native-health](https://github.com/agencyenterprise/react-native-health)というライブラリもあり、こちらの方が執筆時にはスター数が多かったのですが、もう一年ほど開発が活発にされていないように見受けられました。READMEには以下のようにSwiftで実装したバージョンに変わるという表記があるのですが、今のところこちらのリリースに関する情報はなさそうです。

> We're thrilled to share that we're in the midst of creating a significant update for this library. The upcoming version will be crafted using Swift (bye-bye, Objective-C! 👋) and will showcase a fresh new interface.

https://github.com/kingstinct/react-native-healthkit

https://github.com/matinzd/react-native-health-connect

Expoを使用されている場合は、以前[こちらの記事](https://qiita.com/taisuke-j/items/aaf0440246095fd97946)で触れた通り、`Expo prebuild`等を利用すればExpoをejectすることなく、問題なく動作確認しながら進めることができます。

`react-native-health-connect` に関しては[こちら](https://matinzd.github.io/react-native-health-connect/docs/intro/)の公式のドキュメントサイトがあります。内部的なネイティブ実装をより理解するには以下のそれぞれのドキュメントが参考になりました。

https://developer.apple.com/documentation/healthkit

https://developer.android.com/reference/androidx/health/connect/client/HealthConnectClient


## HealthKit (iOS)

基本は`react-native-healthkit`の[githubレポジトリ](https://github.com/kingstinct/react-native-healthkit)のREADMEを見て進めましたが、私の場合、Xcode上で設定可能な**Frameworks, Libraries, and Embedded Content**の設定が不足していました（エラーは`failed to update bundle ID capabilities: unknown entitlement key: com.apple.developer.healthkit.access (exit code: 1)`といった内容）。また後半のステップの `bridging header` を追加するところも少しつまづきました。

Discordのチャンネルもあるようなのでもし困ったらそこで聞いてみるのも良いかもしれません。

### セットアップ

#### 1. react-native-healthkitをインストール
```bash
npm i @kingstinct/react-native-healthkit
```

#### 2. info.plistに設定を追加
`info.plist`　に以下を追加。もしくは`ios`ディレクトリをXcodeで開き左のサイドバーからプロジェクトを選択し、`Info`タブにある`Custom iOS Target Properties` から追加することも可能（ユーザーの臨床記録にアクセスする場合は、`Privacy - Health Records Usage Description`を選択）。
```xml
<!-- info.plist -->

<key>NSHealthShareUsageDescription</key>
<string>Read Health Data</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Write Health Data</string>
```

#### 3. HealthKit Capabilityの追加
`ios`ディレクトリをXcodeで開き左のサイドバーからプロジェクトを選択し、`Signing & Capabilities`タブを選択、左上の`+ Capability`をクリックする。選択メニューが表示されるのでスクロールして`HealthKit`をダブルクリック（ユーザーの臨床記録にアクセスする場合は、その後に画面下部の`Clinical Health Records`にチェックを入れる）。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674308/0f773af9-d9ec-8fc5-5b00-f00253164c13.png)

HealthKit Capabilityの入った`entitlements` ファイルが作成される
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.developer.healthkit</key>
	<true/>
	<key>com.apple.developer.healthkit.access</key>
	<array/>
</dict>
</plist>
```

#### 4. bridging headerの設定

Xcodeの`Build Settings`にbridging headerの設定が見当たらなかったが、READMEに記載された[こちらのリンク](https://reactnative.dev/docs/native-modules-ios#exporting-swift)の通り、ファイルをXcode上で新規作成することでbridging headerが無事設定された。その後プロジェクトで不要と思われるファイルのみ削除。

#### 5. Expo向けの設定

`app.json`

```json
{
  "expo": {
    "plugins": ["@kingstinct/react-native-healthkit"]
  }
}
```

#### 6. その他不足していたと思われる設定

Xcodeの`General`タブを選択し、`Frameworks, Libraries, and Embedded Content`のセクションから`HealthKit.framework`と`HealthKitUI.framework`を選択。`project.pbxproj`に設定が反映される。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674308/4f182519-35fc-b897-27bd-753d97b65ffd.png)

これでHealthKit向けの初期設定は完了です。

### サンプル
歩数をHealthKitから取得し表示するサンプルです。

```tsx
import { useEffect, useState } from "react";
import { View, Text, Button, Alert } from "react-native";
import HealthKit, {
  queryStatisticsForQuantity,
  HKQuantityTypeIdentifier,
  HKStatisticsOptions,
} from "@kingstinct/react-native-healthkit";

const App = () => {
  const [steps, setSteps] = useState<number | null>(null);

  useEffect(() => {
    const getPermission = async () => {
      try {
        // HealthKitが利用可能か確認
        const healthKitAvailable = await HealthKit.isHealthDataAvailable();

        if (!healthKitAvailable) {
          throw new Error("HealthKit is not avaliable");
        }

        // パーミッションの取得
        await HealthKit.requestAuthorization([
          HKQuantityTypeIdentifier.stepCount,
        ]);
      } catch (error) {
        console.error(error);
        Alert.alert("エラーが発生しました。");
      }
    };

    getPermission();
  }, []);

  const fetchSteps = async () => {
    try {
      const now = new Date();
      const startOfDay = new Date(
        now.getFullYear(),
        now.getMonth(),
        now.getDate()
      );

      // 歩数の取得
      const response = await queryStatisticsForQuantity(
        HKQuantityTypeIdentifier.stepCount,
        [HKStatisticsOptions.cumulativeSum],
        startOfDay,
        now
      );

      const totalSteps = response.sumQuantity?.quantity || 0;
      setSteps(totalSteps);
    } catch (error) {
      console.error(error);
      Alert.alert("エラーが発生しました。");
    }
  };

  return (
    <View>
      <Text>今日の歩数: {steps !== null ? steps : "歩数を取得中..."}</Text>
      <Button title="Refresh" onPress={fetchSteps} />
    </View>
  );
};

export default App;
```

## Health Connect (Android)

`react-native-health-connect`の初期設定は[公式のドキュメントサイト](https://matinzd.github.io/react-native-health-connect/docs/intro)があるのでそちらに沿って進めれば基本大丈夫だと思いますが（以下セットアップの1〜6）、私の場合いくつか追加の設定が必要でした（セットアップの7）。

https://matinzd.github.io/react-native-health-connect/docs/intro

### セットアップ

#### 1. react-native-health-connectをインストール

```bash
npm i react-native-health-connect
```

#### 2. MainActivity.ktに設定を追加
以下ドキュメントの通りそのまま。
```kotlin
// 下記importを追加
+ import android.os.Bundle
+ import dev.matinzd.healthconnect.permissions.HealthConnectPermissionDelegate

// MainActivityにonCreateを追加
+ override fun onCreate(savedInstanceState: Bundle?) {
+   super.onCreate(savedInstanceState)
+   HealthConnectPermissionDelegate.setPermissionDelegate(this)
+ }
```

#### 3. AndroidManifest.xmlにパーミッション設定を追加
`android/src/main/AndroidManifest.xml`
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
+   <uses-permission android:name="android.permission.health.READ_STEPS"/>
+   <uses-permission android:name="android.permission.health.WRITE_STEPS"/>
</manifest>
```

#### 4. PermissionRationaleActivity.ktファイルを追加
`android/app/src/main/java/com/healthconnectexample/PermissionRationaleActivity.kt`

```kotlin
package com.healthconnectexample

import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity

class PermissionsRationaleActivity: AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val webView = WebView(this)
    webView.webViewClient = object : WebViewClient() {
      override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        return false
      }
    }

    webView.loadUrl("https://developer.android.com/health-and-fitness/guides/health-connect/develop/get-started")

    setContentView(webView)
  }
}
```

#### 5. AndroidManifest.xmlに以下のパーミッション設定を追加

`android/src/main/AndroidManifest.xml`

```xml
    <!-- For supported versions through Android 13, create an activity to show the rationale of Health Connect permissions once users click the privacy policy link. -->
    <activity
      android:name=".PermissionsRationaleActivity"
      android:exported="true">
      <intent-filter>
        <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
      </intent-filter>
    </activity>

    <!-- For versions starting Android 14, create an activity alias to show the rationale of Health Connect permissions once users click the privacy policy link. -->
    <activity-alias
      android:name="ViewPermissionUsageActivity"
      android:exported="true"
      android:targetActivity=".PermissionsRationaleActivity"
      android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
      <intent-filter>
        <action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
        <category android:name="android.intent.category.HEALTH_PERMISSIONS" />
      </intent-filter>
    </activity-alias>
```

#### 6. Expo向けの設定

`app.json`
```json
{
  "expo": {
    ...
    "plugins": [
      "react-native-health-connect",
      [
        "expo-build-properties",
        {
          "android": {
            "compileSdkVersion": 34,
            "targetSdkVersion": 34,
            "minSdkVersion": 26
          }
        }
      ]
    ],
    "android": {
      ...
      "permissions": [
        "android.permission.health.READ_STEPS",
        "android.permission.health.WRITE_STEPS",
        "android.permission.health.READ_ACTIVE_CALORIES_BURNED"
      ]
    }
  }
}
```

#### 7. その他不足していたと思われる設定

私の場合は、ビルドが通らず以下の設定も追加しました。

`android/app/build.gradle`
```groovy
+ implementation "androidx.health.connect:connect-client:1.0.0-alpha11"
...
+ implementation project(':react-native-health-connect')
```

`android/app/src/main/AndroidManifest.xml`
```xml
+ <queries>
+   <package android:name="com.google.android.apps.healthdata" />
+ </queries>
```

`android/build.gradle`

```groovy
buildscript {
    ext {
        buildToolsVersion = "34.0.0"
        minSdkVersion = 26
        compileSdkVersion = 34
        targetSdkVersion = 34
        ...
```

`packages/client/android/settings.gradle`
```groovy
include ':react-native-health-connect'
project(':react-native-health-connect').projectDir = new File(rootProject.projectDir, '../../../node_modules/react-native-health-connect/android')
```

なんとかビルドが通りました。

### サンプル

歩数をHealth Connectから取得し表示するサンプルです。
```tsx
import { useEffect, useState } from "react";
import { View, Text, Button, Alert } from "react-native";
import {
  initialize,
  requestPermission,
  readRecords,
  getSdkStatus,
  SdkAvailabilityStatus,
} from "react-native-health-connect";

const delay = async (ms: number) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

const App = () => {
  const [steps, setSteps] = useState<number | null>(null);

  useEffect(() => {
    const getPermission = async() => {
      try {
        // Health Connectがインストールされているかの確認
        const status = await getSdkStatus();
        if (status === SdkAvailabilityStatus.SDK_UNAVAILABLE) {
          Alert.alert("Health Connectインストールされていません。Google Play Storeからダウンロードしてください。");
          return;
        }
    
        if (status === SdkAvailabilityStatus.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) {
          Alert.alert("Health Connectプロバイダーの更新が必要です。");
          return;
        }
  
        // Health Connect Clientの初期化
        await initialize();
  
        // 私の場合、少しdelayが必要でした
        await delay(1000);
  
        // パーミッションの取得
        const granted = await requestPermission([
          { accessType: "read", recordType: "Steps" },
        ]);

        // パーミッションを取得できたので歩数を取得
        if (granted) {
          fetchSteps();
        }
      } catch (error) {
        console.error(error);
        Alert.alert("エラーが発生しました。");
      }
    };

    getPermission();
  }, []);

  const fetchSteps = async () => {
    try {
      const now = new Date();
      const currentTime = now.toISOString();
      const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();

      // 歩数の取得
      const stepsData = await readRecords("Steps", {
        timeRangeFilter: {
          operator: "between",
          startTime: startOfDay,
          endTime: currentTime,
        },
      });

      const totalSteps = stepsData.reduce((sum, entry) => sum + entry.count, 0);
      setSteps(totalSteps);
    } catch (error) {
      console.error(error);
      Alert.alert("エラーが発生しました。");
    }
  };

  return (
    <View>
      <Text>今日の歩数: {steps !== null ? steps : "歩数を取得中..."}</Text>
      <Button title="Refresh" onPress={fetchSteps} />
    </View>
  );
};

export default App;

```

細かい話ですが、`react-native-health-connect`では内部的な型がルートでexportされておらず、以下で対応可能でした。
```tsx
import { Permission } from 'react-native-health-connect/src/types';
```

## グラフ表示用のクエリ

グラフの表示等で、例えば30日間における1日ごとの歩数の合計を配列で取得したい場合HealthKitには`HKStatisticsCollectionQuery`と呼ばれる強力なクエリがあるのですが、執筆時にはライブラリ側ではまだ実装がされていないようでした（恐らく時間の問題）。

https://developer.apple.com/documentation/healthkit/hkstatisticscollectionquery

https://github.com/kingstinct/react-native-healthkit/issues/82

そしてHealth Connectも同様に`AggregateGroupByDurationRequest`というリクエストオブジェクトがネイティブ実装側にあり、まだライブラリ側には実装されていないという、とても似た状況となっています。そのため現状だと例えば2ヶ月間の日別データを取得する際はヘルスデータの種類ごとに `aggregateRecord` を日別に計60回ずつ実行するような実装になってしまいます（HealthKitも同様）。明確な上限値は[ドキュメント](https://developer.android.com/health-and-fitness/guides/health-connect/plan/rate-limiting)には記載されていないですが、Rate Limitが存在しているため、膨大なリクエスト数になりそうな場合はリクエストのバッチをchunkに分割するなどの工夫が必要かもしれません。

（追記）こちらの件、[メソッド追加のPR](https://github.com/matinzd/react-native-health-connect/pull/157)を上げて無事取り込んでもらえました。HealthKitの方はレビュー待ち。

https://developer.android.com/health-and-fitness/guides/health-connect/develop/aggregate-data#buckets

## 別端末とのヘルスデータ共有

また、まだまだ理解が浅いところなのですが、iOSはHealthKitデータをiCloudに保存できるので恐らく別端末から容易に復元ができ、Android側では現状そういったことが難しいのでは？と思っています。

https://support.apple.com/ja-jp/guide/security/sec88be9900f/web

Googleの公式動画でも自前のサーバー経由でヘルスデータを連携することを言及しているのでやはり一手間かかりそうです。ただしウェアラブル端末とは動画で説明されている `Wearable Data Layer API` というAPIで連携できるかもしれません。

https://www.youtube.com/watch?v=OuCY5Kpu5xA

また、AndroidではHealth Connectからデータを取得する際に`dataOriginFilter`というフィルターを使って特定のアプリから取得したヘルスデータのみ取得、などのフィルタリングが可能そうです。

## さいごに
基本的なコンセプトは同じなのですが、それぞれのヘルスデータにおいてHealthKitとHealthConnectで扱うデータ構造や取得するクエリが変わってくるため、最初慣れるまでに少し時間を要しました。

引き続きHealthKitとHealth Connectに関して新しい知見があれば追記していきます。