はじめに

2025年6月30日をもってGoogle Fit APIがサービス終了するため、それまでに移行する必要があります。
Android Developers: Google Fit API のサービス終了の概要

基本的な実装の流れについてはCodeLabの演習を参照するのが確実です。本記事では、実際に移行対応する際に見落としがちな点や注意すべきポイントをピックアップします。
CodeLab: 初めてのヘルスコネクト統合アプリ

バージョン対応

Android 14(API レベル 34)以降、ヘルスコネクトは Android フレームワークの一部になっています。このバージョンのヘルスコネクトはフレームワーク モジュールであるため、セットアップは不要です。

Android 13 以前 Android 13(API レベル 33)以前のバージョンでは、ヘルスコネクトは Android フレームワークの一部ではありません。そのため、Google Play ストアからヘルスコネクト アプリをインストールする必要があります。QR コードをスキャンして、ヘルスコネクトをインストールします。

Android Developers: ヘルスコネクトを Android 13(APK)から Android 14(フレームワーク)に移行するに記載の通り、Android 14以降は標準で含まれるアプリとなるため、Android 13以前とは一部異なる点があります。
特に気にしておいた方が良い点としては、13と14での異なる権限の書き方と、13から14に移行した時のデータ同期です。

権限対応

13以下ではカスタム権限のリソースを定義してあげる必要があります。
14以上では通常の権限同様manifestに定義すれば大丈夫です。 Android 13以前と14以降を含む場合は、これらの両方を記載する必要があります。

Android Developers: 権限の申告

<!-- Android 13以下 以下のようにカスタム権限形式を定義したリソースファイルを用意する-->
<!-- health_permissions.xml -->

  
    androidx.health.permission.SleepSession.READ
    androidx.health.permission.SleepStage.READ
    androidx.health.permission.Weight.READ
    androidx.health.permission.Weight.WRITE
  


<!-- AndroidManifest.xml -->
<!-- 定義したリソースファイルを参照する -->

    android:name=".RationaleActivity"
    android:exported="true">
    
        
    
    


<!-- Android 14以上 -->



Android14移行後のデータ同期

自分の移行したアプリではサーバー側が担ってくれていたので特に関係なかったのですが、データを同期する場合に必要となる情報です。 ガイドに記載の通り移行されない(変更トークンが無効になる)ため手動で対応する必要があります。

Android Developers: 変更履歴の処理

リリース前にフォーム申請が必要

ガイドにも「!」マークがついているので見逃すこともなさそうですが、忘れるとストア版でヘルスコネクト連携ができなくなる重要な項目です。   内容は使用するレコードや使用目的など難しいことはありませんが、確認、処理までに最大10~14営業日かかる可能性があるため、早めに対応する必要があります。   特にプライバシーポリシーについては注意が必要のため、次セクションで書きますがリジェクトされるかもしれない想定で動いた方が良いかもしれません。

また、利用するレコードが変わった場合は再度送信する必要があるため、改修で取得する健康データが増えた等の場合もすぐに再申請をしないとリリース遅れ等の原因になりうるので注意が必要です。

Android Developers: ヘルスコネクトのデータ型へのアクセス権をリクエストする

プライバシーポリシー

申請時の注意

ヘルスコネクトを利用する際はプライバシーポリシーに取得した健康データの取り扱いについて記載する必要があります。

プライバシーポリシーについてはフォーム申請でも触られているため必要なのはわかりますが、 内容として「ヘルスコネクトで取得した健康情報をどのように扱っているか」を記載しないと通らない可能性が高いというのが落とし穴ポイントです。

ヘルスコネクト以前から「健康データの取り扱い」という枠でプライバシーポリシーの記載があるのでそのまま利用したところ、リジェクトされました。

また、2つ目の落とし穴、というよりどうしようもない点として、ヘルスコネクトでの取り扱いを書いていても英文でない場合、落とされる可能性があります……。
「ヘルスコネクトに関するデータ保護の記載見当たらなかったので記載していることがわかるスクリーンショットやリンクを送ってくれ」(要約)
といった旨での2度目のリジェクトを受けました。

実装時の注意

プライバシーポリシーはヘルスコネクトのアプリのアクセス権画面からも呼び出せるようにしなければならないため、実装も必要になります。

ガイドの「アプリのプライバシー ポリシーのダイアログを表示する」に記載の通り、ACTION_SHOW_PERMISSIONS_RATIONALEインテントのプライバシーポリシーアクティビティ(ガイドだとPermissionsRationaleActivity)を用意し、用意したアクティビティでプライバシーポリシーを表示するようにします。

通常はアプリで既に持っていたり、ウェブで公開しているプライバシーポリシーを表示するだけなので特に説明する必要はないと思います。

Android Developers: アプリのプライバシー ポリシーのダイアログを表示する

集計サポートしていないレコードもある

従来のGoogleFitAPIでは以下のように指定すれば体脂肪率の集計を取れたのですが、HealthConnectでは指定のRecordクラスにAggregateMetric型のフィールドを持っている必要があります。 BodyFatRecord(体脂肪率)はフィールドを持っていないため、集計関数を使えないです。

従来は以下のように取れました。

DataReadRequest.Builder()
    .setTimeRange(fromDate, toDate, TimeUnit.MILLISECONDS)
    .aggregate(DataType.TYPE_BODY_FAT_PERCENTAGE)
    .enableServerQueries()
    .bucketByTime(1, TimeUnit.DAYS)
    .build()

集計が使えるレコード、例えばWeightRecord(体重)では、WEIGHT_AVGWEIGHT_MINWEIGHT_MAXのAggregateMetric型のフィールドを持っているため、一定期間ごとの平均、最小、最大体重を標準の集計関数で取得できます。

指定の期間(fromDatetoDateTime

healthConnectClient.aggregateGroupByDuration(
    AggregateGroupByDurationRequest(
        metrics = setOf(WeightRecord.WEIGHT_AVG),
        timeRangeFilter = TimeRangeFilter.between(
            fromDate,
            toDate
        ),
        timeRangeSlicer = Duration.ofHours(24L),
        // GoogleFitから取得したデータのみ欲しい場合は指定
        dataOriginFilter = setOf(DataOrigin(GOOGLE_FIT_PACKAGE)),
    )
)

ちなみに細かい部分ですが、期間だとaggregateGroupByPeriodを使った方が良さそうに見えますが、 こちらだとサマータイムを考慮した期間で集計してしまうため、ケースにより使い分けると良さそうです。

今回移行したアプリでは日ごとの最大値を取得するため、単純にレコードを取得し、日付ごとにまとめた上で、その日の最も遅い時間を取得するように作成しました。 (記載実装はtokenチェックからの再帰や途中処理など一部省略)

val request = ReadRecordsRequest(
    recordType = BodyFatRecord::class,
    timeRangeFilter = TimeRangeFilter.between(
        startDate,
        endDate
    ),
    dataOriginFilter = setOf(DataOrigin(GOOGLE_FIT_PACKAGE)),
    pageToken = pageToken
)
healthConnectClient
    .readRecords(request)
    .records
    .groupBy { bodyFat ->
        // その日の0時ちょうどに変換する関数
        DateTime.getCalenderZeroTime(Date.from(bodyFat.time))
    }
// 後続でまとめた日付ごとの最新の体脂肪率をサーバー送信用にまとめる

ただし、集計関数が使えるのなら使うのが理想とは思うため、あくまで暫定対応的な立ち位置で手動実装したレコードについてはしっかり今後の更新を追った方が良さそうです。

レコードに手動入力有無が入っているとは限らない

GoogleFitAPIでは手動入力であるかを見たい時、DataSource#getStreamNameで取得したstreamNameがuser_inputであれば手動のデータであると判定できます。

一方でHealthConnectではMetaData#getRecordingMethodで取得したrecordingMethodがRECORDING_METHOD_MANUAL_ENTRY(3)であれば手動のデータであると判定できます。

どちらも似たような形で取得できるかと思いきや、取得したStepRecordは全てRECORDING_METHOD_UNKNOWN(0)でした。

GoogleFitからの**ExerciseSessionRecord以外のレコードのrecordingMethodは全てRECORDING_METHOD_UNKNOWNで帰ってくる**とのことで直接手動判定できないようです。

Issue Tracker :Inaccurate recordingMethod for Manually Added Steps in Health Connect

現状できる対応の一つとしてはrecordingMethod:RECORDING_METHOD_MANUAL_ENTRYExerciseSessionRecordを取得し、一致するstartTimeendTimeのレコードは省くといったような流れにしてあげれば弾くことが可能です。

実装例としては以下のようになります。
アクティビティと一緒に作成された歩数データは同一startTimeかつエクササイズの1ms後のendTimeStepRecord1件にまとめられているのを利用して弾きます。
(記載実装はtokenチェックからの再帰や途中処理など一部省略)

// 1. 手動のエクササイズセッションをまとめる
val request = ReadRecordsRequest(
    recordType = ExerciseSessionRecord::class,
    timeRangeFilter = TimeRangeFilter.between(
        startDate,
        endDate
    ),
    dataOriginFilter = setOf(DataOrigin(GOOGLE_FIT_PACKAGE)),
    pageToken = pageToken
)
healthConnectClient
    .readRecords(request)
    .records
.forEach { exerciseSession ->
    // アクティビティの作成/編集したデータは同一startTimeかつエクササイズの1ms後のendTimeのStepRecord1件にまとめられる
    val start = Date.from(exerciseSession.startTime).time
    val end = Date.from(exerciseSession.endTime).time + 1
    invalidRecord[start] = invalidRecord[start]?.let {
        it.add(end)
        it
    } ?: mutableListOf(end)
}

・・・

// 2. 取得したデータから手動のレコードを抜く
val request = ReadRecordsRequest(
    recordType = StepsRecord::class,
    timeRangeFilter = TimeRangeFilter.between(
        startDate,
        endDate
    ),
    dataOriginFilter = setOf(DataOrigin(GOOGLE_FIT_PACKAGE)),
    pageToken = pageToken
)
healthConnectClient
    .readRecords(request)
    .records
.filter { step ->
    // startDate,endDateが無効なExerciseSessionRecordと一致する場合は弾く
    val hasInvalidExerciseSession = invalidRecord[Date.from(step.startTime).time]
        ?.any { endTime -> endTime == Date.from(step.endTime).time }
        ?: false
    !hasInvalidExerciseSession
}.groupBy { step ->
    DateTime.getCalenderZeroTime(Date.from(step.startTime)).time.time
}
// 後続でまとめた日付ごとの最新の体脂肪率をサーバー送信用にまとめる

おわりに

ヘルスコネクトの導入や移行対応について、ガイドラインやCodeLabに丁寧に書いてあるものの、いざ対応してみると意外と躓きポイントがありました。
今回のTipsでこれからヘルスコネクト対応する人たちの参考になれば幸いです。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる