トレーニング計画

このガイドは、ヘルスコネクトのバージョン 1.1.0-alpha12 以降に対応しています。

ヘルスコネクトには、トレーニング アプリがトレーニング プランを書き込み、ワークアウト アプリがトレーニング プランを読み取れるようにする計画されたエクササイズのデータ型が用意されています。記録されたエクササイズ(ワークアウト)を読み戻して、ユーザーがトレーニング目標を達成できるように、パーソナライズされたパフォーマンス分析を行うことができます。

ヘルスコネクトを利用できるか確認する

ヘルスコネクト アプリを使用する前に、ユーザーのデバイスでヘルスコネクトが利用可能であることを確認する必要があります。ヘルスコネクトは一部のデバイスにおいて、プリインストールされていない場合や、無効になっている場合があります。HealthConnectClient.getSdkStatus() メソッドを使用して、利用可能かどうかを確認できます。

ヘルスコネクトを利用できるか確認する方法

fun checkHealthConnectAvailability(context: Context) {
    val providerPackageName = "com.google.android.apps.healthdata"
    val availabilityStatus = HealthConnectClient.getSdkStatus(context, providerPackageName)

    if (availabilityStatus == HealthConnectClient.SDK_UNAVAILABLE) {
      // Health Connect is not available. Guide the user to install/enable it.
      // For example, show a dialog.
      return // early return as there is no viable integration
    }
    if (availabilityStatus == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED) {
      // Health Connect is available but requires an update.
      // Optionally redirect to package installer to find a provider, for example:
      val uriString = "market://details?id=$providerPackageName&url=healthconnect%3A%2F%2Fonboarding"
      context.startActivity(
        Intent(Intent.ACTION_VIEW).apply {
          setPackage("com.android.vending")
          data = Uri.parse(uriString)
          putExtra("overlay", true)
          putExtra("callerId", context.packageName)
        }
      )
      return
    }
    // Health Connect is available, obtain a HealthConnectClient instance
    val healthConnectClient = HealthConnectClient.getOrCreate(context)
    // Issue operations with healthConnectClient
}

getSdkStatus() から返されるステータスに応じて、必要に応じて Google Play ストアからヘルスコネクトをインストールまたは更新するようユーザーに案内できます。

機能の提供状況

ユーザーのデバイスがヘルスコネクトのトレーニング プランに対応しているかどうかを判断するには、クライアントで FEATURE_PLANNED_EXERCISE の利用可否を確認します。

if (healthConnectClient
     .features
     .getFeatureStatus(
       HealthConnectFeatures.FEATURE_PLANNED_EXERCISE
     ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE) {

  // Feature is available
} else {
  // Feature isn't available
}
詳しくは、機能の提供状況を確認するをご覧ください。

必要な権限

予定されたエクササイズへのアクセスは、次の権限によって保護されています。

  • android.permission.health.READ_PLANNED_EXERCISE
  • android.permission.health.WRITE_PLANNED_EXERCISE

計画されたエクササイズ機能をアプリに追加するには、まず、PlannedExerciseSession データ型に対する権限をリクエストします。

計画されたエクササイズを記述するために宣言する必要がある権限は次のとおりです。

<application>
  <uses-permission
android:name="android.permission.health.WRITE_PLANNED_EXERCISE" />
...
</application>

予定されたエクササイズを読み取るには、次の権限をリクエストする必要があります。

<application>
  <uses-permission
android:name="android.permission.health.READ_PLANNED_EXERCISE" />
...
</application>

ユーザーに権限をリクエストする

クライアント インスタンスを作成した後、アプリはユーザーに権限をリクエストする必要があります。ユーザーがいつでも権限を付与または拒否できるようにする必要があります。 そのためには、必要なデータ型の権限セットを作成します。まず、セット内の権限が Android マニフェストで宣言されていることを確認します。

val permissions =
    setOf(
        HealthPermission.getReadPermission(HeartRateRecord::class),
        HealthPermission.getWritePermission(HeartRateRecord::class),
        HealthPermission.getReadPermission(PlannedExerciseSessionRecord::class),
        HealthPermission.getWritePermission(PlannedExerciseSessionRecord::class),
        HealthPermission.getReadPermission(ExerciseSessionRecord::class),
        HealthPermission.getWritePermission(ExerciseSessionRecord::class)
    )
getGrantedPermissions を使用して、アプリが必要な権限をすでに持っているかどうかを確認します。持っていない場合は、createRequestPermissionResultContract を使用して権限をリクエストします。ヘルスコネクトの権限画面が表示されます。
val permissions = setOf(
        HealthPermission.getReadPermission(StepsRecord::class),
        HealthPermission.getWritePermission(StepsRecord::class),
        HealthPermission.getReadPermission(HeartRateRecord::class),
        HealthPermission.getWritePermission(HeartRateRecord::class)
    )

val requestPermissionsLauncher = rememberLauncherForActivityResult(
    contract = PermissionController.createRequestPermissionResultContract()
) { grantedPermissions ->
    if (grantedPermissions.containsAll(permissions)) {
        coroutineScope.launch { snackbarHostState.showSnackbar("Permissions granted!") }
    } else {
        coroutineScope.launch { snackbarHostState.showSnackbar("Permissions denied.") }
    }
}
ユーザーはいつでも権限を付与または取り消すことができるため、アプリは権限を使用する前に毎回権限をチェックし、権限が失われた状況に対応できるように設計する必要があります。

トレーニング プランはエクササイズ セッションにリンクされています。したがって、ヘルスコネクトのこの機能を最大限に活用するには、トレーニング プランに関連する各レコードタイプを使用する権限をユーザーが付与する必要があります。

たとえば、トレーニング プランで一連のランニング中のユーザーの心拍数を測定する場合、エクササイズ セッションを書き込み、後で評価するために結果を読み取るには、デベロッパーが次の権限を宣言し、ユーザーがそれを付与する必要があります。

  • android.permission.health.READ_EXERCISE
  • android.permission.health.READ_EXERCISE_ROUTES
  • android.permission.health.READ_HEART_RATE
  • android.permission.health.WRITE_EXERCISE
  • android.permission.health.WRITE_EXERCISE_ROUTE
  • android.permission.health.WRITE_HEART_RATE

ただし、トレーニング プランを作成し、プランに照らしてパフォーマンスを評価するアプリは、トレーニング プランを使用し、実際のエクササイズ データを書き込むアプリとは異なることがよくあります。アプリの種類によっては、すべての読み取り / 書き込み権限が必要なわけではありません。たとえば、アプリの種類ごとに必要な権限は次のとおりです。

トレーニング計画アプリ ワークアウト アプリ
WRITE_PLANNED_EXERCISE READ_PLANNED_EXERCISE
READ_EXERCISE WRITE_EXERCISE
READ_EXERCISE_ROUTES WRITE_EXERCISE_ROUTE
READ_HEART_RATE WRITE_HEART_RATE

計画されたエクササイズ セッションの記録に含まれる情報

  • セッションのタイトル。
  • 予定されているエクササイズ ブロックのリスト。
  • セッションの開始時間と終了時間。
  • エクササイズの種類。
  • アクティビティのメモ。
  • メタデータ。
  • 完了したエクササイズ セッション ID - この計画されたエクササイズ セッションに関連するエクササイズ セッションが完了すると、自動的に書き込まれます。

予定されたエクササイズ ブロックのレコードに含まれる情報

計画されたエクササイズ ブロックには、エクササイズ ステップのリストが含まれており、さまざまなステップ グループの繰り返しをサポートします(たとえば、アームカール、バーピー、クランチを 5 回連続で行うなど)。

計画されたエクササイズのステップ記録に含まれる情報

サポートされている集計

このデータ型でサポートされている集計はありません。

使用例

ユーザーが 2 日後に 90 分間のランニングを計画しているとします。このランニングでは、湖の周りを 3 周し、目標心拍数は 90 ~ 110 bpm です。

  1. トレーニング計画アプリでユーザーが定義した、以下の内容を含む計画的なエクササイズ セッション。
    1. 実行の予定開始日時と予定終了日時
    2. エクササイズの種類(ランニング)
    3. ラップ数(繰り返し)
    4. 心拍数のパフォーマンス目標(90 ~ 110 bpm)
  2. この情報は、エクササイズ ブロックとステップにグループ化され、トレーニング プラン アプリによって PlannedExerciseSessionRecord としてヘルスコネクトに書き込まれます。
  3. ユーザーが、予定されたセッション(ランニング)を実行します。
  4. セッションに関連するエクササイズ データは、次のいずれかの方法で記録されます。
    1. セッション中にウェアラブル デバイスによって測定された。たとえば、心拍数などです。このデータは、アクティビティのレコードタイプとしてヘルスコネクトに書き込まれます。この場合は HeartRateRecord です。
    2. セッション後にユーザーが手動で更新する。たとえば、実際の実行の開始と終了を示します。このデータは ExerciseSessionRecord としてヘルスコネクトに書き込まれます。
  5. 後で、トレーニング プラン アプリはヘルスコネクトからデータを読み取り、計画されたエクササイズ セッションでユーザーが設定した目標に対する実際のパフォーマンスを評価します。

エクササイズを計画して目標を設定する

ユーザーは、将来のエクササイズを計画し、目標を設定できます。これを計画されたエクササイズ セッションとしてヘルスコネクトに書き込みます。

使用例で説明した例では、ユーザーは 2 日後に 90 分間のランニングを計画しています。このランニングでは、湖の周りを 3 周し、目標心拍数は 90 ~ 110 bpm です。

このようなスニペットは、計画されたエクササイズ セッションをヘルスコネクトに記録するアプリのフォーム ハンドラにあります。また、トレーニングを提供するサービスとの統合の取り込みポイントでも確認できます。

// Verify the user has granted all necessary permissions for this task
val grantedPermissions =
    healthConnectClient.permissionController.getGrantedPermissions()

if (!grantedPermissions.contains(
        HealthPermission.getWritePermission(PlannedExerciseSessionRecord::class))) {
    // The user hasn't granted the app permission to write planned exercise session data.
    Log.w("HealthConnect", "Write permission for PlannedExerciseSessionRecord not granted.")
    return
}

val plannedExerciseSessionRecord = PlannedExerciseSessionRecord(
    startTime = startTime,
    endTime = endTime,
    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
    blocks = listOf(
        PlannedExerciseBlock(
            repetitions = 1, steps = listOf(
                PlannedExerciseStep(
                    exerciseType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING,
                    exercisePhase = PlannedExerciseStep.EXERCISE_PHASE_ACTIVE,
                    completionGoal = ExerciseCompletionGoal.RepetitionsGoal(repetitions = 3),
                    performanceTargets = listOf(
                        ExercisePerformanceTarget.HeartRateTarget(
                            minHeartRate = 90.0, maxHeartRate = 110.0
                        )
                    )
                ),
            ), description = "Three laps around the lake"
        )
    ),
    title = "Run at lake",
    notes = null,
    metadata = Metadata.activelyRecorded(
        device = Device(type = Device.Companion.TYPE_PHONE),
    ),
    startZoneOffset = null,
    endZoneOffset = null,
)

try {
    // Attempt to insert the record
    val response = healthConnectClient.insertRecords(listOf(plannedExerciseSessionRecord))

    // If execution reaches here, the insert succeeded.
    // Safely extract the ID using firstOrNull()
    val insertedPlannedExerciseSessionId = response.recordIdsList.firstOrNull()

    if (insertedPlannedExerciseSessionId != null) {
        Log.d("HealthConnect", "Successfully inserted planned exercise session ID: $insertedPlannedExerciseSessionId")
    } else {
        Log.w("HealthConnect", "Insertion succeeded but no record IDs were returned.")
    }

} catch (e: Exception) {
    // Handle API failures, database errors, or system issues safely without crashing
    Log.e("HealthConnect", "Failed to insert planned exercise session record", e)
}

エクササイズとアクティビティのデータを記録する

2 日後、ユーザーは実際のエクササイズ セッションを記録します。これをエクササイズ セッションとしてヘルスコネクトに書き込みます。

この例では、ユーザーのセッション継続時間が計画された継続時間と完全に一致しています。

次のスニペットは、エクササイズ セッションをヘルスコネクトに記録するアプリのフォーム ハンドラにあります。また、エクササイズ セッションを検出してログに記録できるウェアラブルのデータの取り込みハンドラとエクスポート ハンドラにも含まれている可能性があります。

insertedPlannedExerciseSessionId は、前の例から再利用されています。実際のアプリでは、ユーザーが既存のセッションのリストから計画されたエクササイズ セッションを選択することで、ID が決定されます。

// Verify the user has granted all necessary permissions for this task
val grantedPermissions =
    healthConnectClient.permissionController.getGrantedPermissions()
if (!grantedPermissions.contains(
        HealthPermission.getWritePermission(ExerciseSessionRecord::class))) {
    // The user doesn't granted the app permission to write exercise session data.
    return
}

val sessionDuration = Duration.ofMinutes(90)
val sessionEndTime = Instant.now()
val sessionStartTime = sessionEndTime.minus(sessionDuration)

val exerciseSessionRecord = ExerciseSessionRecord(
    startTime = sessionStartTime,
    startZoneOffset = ZoneOffset.UTC,
    endTime = sessionEndTime,
    endZoneOffset = ZoneOffset.UTC,
    exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
    segments = listOf(
        ExerciseSegment(
            startTime = sessionStartTime,
            endTime = sessionEndTime,
            repetitions = 3,
            segmentType = ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING
        )
    ),
    title = "Run at lake",
    plannedExerciseSessionId = insertedPlannedExerciseSessionId,
    metadata = Metadata.activelyRecorded(
        device = Device(type = Device.Companion.TYPE_PHONE)
    )
)
val insertedExerciseSessions =
    healthConnectClient.insertRecords(listOf(exerciseSessionRecord))

ウェアラブルは、ランニング中の心拍数も記録します。次のスニペットを使用すると、ターゲット範囲内のレコードを生成できます。

実際のアプリでは、このスニペットの主要な部分は、ウェアラブルからのメッセージのハンドラにあります。このハンドラは、収集時に測定値をヘルスコネクトに書き込みます。

// Verify the user has granted all necessary permissions for this task
val grantedPermissions =
    healthConnectClient.permissionController.getGrantedPermissions()
if (!grantedPermissions.contains(
        HealthPermission.getWritePermission(HeartRateRecord::class))) {
    // The user doesn't granted the app permission to write heart rate record data.
    return
}

val samples = mutableListOf<HeartRateRecord.Sample>()
var currentTime = sessionStartTime
while (currentTime.isBefore(sessionEndTime)) {
    val bpm = Random.nextInt(21) + 90
    val heartRateRecord = HeartRateRecord.Sample(
        time = currentTime,
        beatsPerMinute = bpm.toLong(),
    )
    samples.add(heartRateRecord)
    currentTime = currentTime.plusSeconds(180)
}

val heartRateRecord = HeartRateRecord(
    startTime = sessionStartTime,
    startZoneOffset = ZoneOffset.UTC,
    endTime = sessionEndTime,
    endZoneOffset = ZoneOffset.UTC,
    samples = samples,
    metadata = Metadata.activelyRecorded(
        device = Device(type = Device.Companion.TYPE_WATCH)
    )
)
val insertedHeartRateRecords = healthConnectClient.insertRecords(listOf(heartRateRecord))

成果目標を評価する

ユーザーのワークアウトの翌日に、記録されたエクササイズを取得し、計画されたエクササイズ目標を確認して、追加のデータ型を評価し、設定された目標が達成されたかどうかを判断できます。

このようなスニペットは、成果目標を評価する定期的なジョブや、エクササイズのリストを読み込んでアプリに成果目標に関する通知を表示する場合に役立ちます。

    // Verify the user has granted all necessary permissions for this task
    val grantedPermissions =
        healthConnectClient.permissionController.getGrantedPermissions()
    if (!grantedPermissions.containsAll(
            listOf(
                HealthPermission.getReadPermission(ExerciseSessionRecord::class),
                HealthPermission.getReadPermission(PlannedExerciseSessionRecord::class),
                HealthPermission.getReadPermission(HeartRateRecord::class)
            )
        )
    ) {
        // The user doesn't granted the app permission to read exercise session record data.
        return
    }

    val searchDuration = Duration.ofDays(1)
    val searchEndTime = Instant.now()
    val searchStartTime = searchEndTime.minus(searchDuration)

    val response = healthConnectClient.readRecords(
        ReadRecordsRequest<ExerciseSessionRecord>(
            timeRangeFilter = TimeRangeFilter.between(searchStartTime, searchEndTime)
        )
    )
    for (exerciseRecord in response.records) {
        val plannedExerciseRecordId = exerciseRecord.plannedExerciseSessionId
        val plannedExerciseRecord =
            if (plannedExerciseRecordId == null) null else healthConnectClient.readRecord(
                PlannedExerciseSessionRecord::class, plannedExerciseRecordId
            ).record
        if (plannedExerciseRecord != null) {
            val aggregateRequest = AggregateRequest(
                metrics = setOf(HeartRateRecord.BPM_AVG),
                timeRangeFilter = TimeRangeFilter.between(
                    exerciseRecord.startTime, exerciseRecord.endTime
                ),
            )
            val aggregationResult = healthConnectClient.aggregate(aggregateRequest)

            val maxBpm = aggregationResult[HeartRateRecord.BPM_MAX]
            val minBpm = aggregationResult[HeartRateRecord.BPM_MIN]
            if (maxBpm != null && minBpm != null) {
                plannedExerciseRecord.blocks.forEach { block ->
                    block.steps.forEach { step ->
                        step.performanceTargets.forEach { target ->
                            when (target) {
                                is ExercisePerformanceTarget.HeartRateTarget -> {
                                    val minTarget = target.minHeartRate
                                    val maxTarget = target.maxHeartRate
                                    if(
                                        minBpm >= minTarget && maxBpm <= maxTarget
                                    ) {
                                        // Success!
                                    }
                                }
                                // Handle more target types
                            }
                        }
                    }
                }
            }
        }
    }
}

エクササイズ セッション

エクササイズ セッションには、ランニングからバドミントンまでさまざまなものがあります。

エクササイズ セッションを作成する

セッションを含む挿入リクエストの作成方法は以下のとおりです。

suspend fun writeExerciseSession(healthConnectClient: HealthConnectClient) {
    healthConnectClient.insertRecords(
        listOf(
            ExerciseSessionRecord(
                startTime = START_TIME,
                startZoneOffset = START_ZONE_OFFSET,
                endTime = END_TIME,
                endZoneOffset = END_ZONE_OFFSET,
                exerciseType = ExerciseSessionRecord.ExerciseType.RUNNING,
                title = "My Run",
                metadata = Metadata.manualEntry()
            ),
            // ... other records
        )
    )
}

エクササイズ セッションを読み取る

以下はエクササイズ セッションを読み取る方法の例です。

suspend fun readExerciseSessions(
    healthConnectClient: HealthConnectClient,
    startTime: Instant,
    endTime: Instant
) {
    val response =
        healthConnectClient.readRecords(
            ReadRecordsRequest(
                ExerciseSessionRecord::class,
                timeRangeFilter = TimeRangeFilter.between(startTime, endTime)
            )
        )
    for (exerciseRecord in response.records) {
        // Process each exercise record
        // Optionally pull in with other data sources of the same time range.
        val distanceRecord =
            healthConnectClient
                .readRecords(
                    ReadRecordsRequest(
                        DistanceRecord::class,
                        timeRangeFilter =
                            TimeRangeFilter.between(
                                exerciseRecord.startTime,
                                exerciseRecord.endTime
                            )
                    )
                )
                .records
    }
}

サブタイプ データを作成する

セッションをオプションのサブタイプ データで構成することもできます。このような追加情報により、セッションの内容を詳しく記録できます。

たとえば、エクササイズ セッションには ExerciseSegment クラス、ExerciseLap クラス、ExerciseRoute クラスを含められます。

val segments = listOf(
  ExerciseSegment(
    startTime = Instant.parse("2022-01-02T10:10:10Z"),
    endTime = Instant.parse("2022-01-02T10:10:13Z"),
    segmentType = ActivitySegmentType.BENCH_PRESS,
    repetitions = 373
  )
)

val laps = listOf(
  ExerciseLap(
    startTime = Instant.parse("2022-01-02T10:10:10Z"),
    endTime = Instant.parse("2022-01-02T10:10:13Z"),
    length = 0.meters
  )
)

ExerciseSessionRecord(
  exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS,
    startTime = Instant.parse("2022-01-02T10:10:10Z"),
    endTime = Instant.parse("2022-01-02T10:10:13Z"),
  startZoneOffset = ZoneOffset.UTC,
  endZoneOffset = ZoneOffset.UTC,
  segments = segments,
  laps = laps,
  route = route,
  metadata = Metadata.manualEntry()
)