헬스 커넥트는 학습 앱이 학습 계획을 작성하고 운동 앱이 학습 계획을 읽을 수 있도록 계획된 운동 데이터 유형을 제공합니다. 기록된 운동은 사용자가 학습 목표를 달성하는 데 도움이 되는 맞춤형 실적 분석을 위해 다시 읽을 수 있습니다.
헬스 커넥트 사용 가능 여부 확인
헬스 커넥트를 사용하기 전에 앱에서 사용자 기기에서 헬스 커넥트를 사용할 수 있는지 확인해야 합니다. 헬스 커넥트는 일부 기기에 사전 설치되어 있지 않거나 사용 중지되어 있을 수 있습니다.
HealthConnectClient.getSdkStatus() 메서드를 사용하여 사용 가능 여부를 확인할 수 있습니다.
헬스 커넥트 사용 가능 여부를 확인하는 방법
fun checkHealthConnectAvailability(context: Context) { val providerPackageName = "com.google.android.apps.healthdata" // Or get from HealthConnectClient.DEFAULT_PROVIDER_PACKAGE_NAME 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_EXERCISEandroid.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_EXERCISEandroid.permission.health.READ_EXERCISE_ROUTESandroid.permission.health.READ_HEART_RATEandroid.permission.health.WRITE_EXERCISEandroid.permission.health.WRITE_EXERCISE_ROUTEandroid.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번 연속으로 실행).
- 블록에 대한 설명입니다.
- 계획된 운동 단계 목록입니다.
- 반복 횟수입니다.
계획된 운동 단계 레코드에 포함된 정보
- 단계에 대한 설명입니다.
- 운동 카테고리입니다.
- 운동 유형입니다.
- 실적 타겟 목록입니다.
- 완료 목표입니다.
지원되는 집계
이 데이터 유형에 지원되는 집계는 없습니다.
사용 예시
사용자가 지금부터 이틀 후에 90분 달리기를 계획한다고 가정해 보겠습니다. 이 달리기는 목표 심박수가 90~110bpm인 호수 주변을 3바퀴 도는 것으로 구성됩니다.
- 다음과 같은 계획된 운동 세션은 학습 계획 앱에서 사용자가 정의합니다.
- 달리기의 계획된 시작 및 종료
- 운동 유형 (달리기)
- 랩 수 (반복)
- 심박수 실적 타겟 (90~110bpm)
- 이 정보는 운동 블록과 단계로 그룹화되고 학습 계획 앱에서
PlannedExerciseSessionRecord로 헬스 커넥트에 작성됩니다. - 사용자가 계획된 세션 (달리기)을 실행합니다.
- 세션과 관련된 운동 데이터는 다음 중 하나로 기록됩니다.
- 세션 중에 웨어러블 기기에서 기록합니다. 예를 들어 심박수입니다.
이 데이터는 활동의 레코드 유형으로 헬스 커넥트에 작성됩니다. 이 경우
HeartRateRecord입니다. - 세션 후 사용자가 수동으로 기록합니다. 예를 들어 실제 달리기의 시작과 종료를 나타냅니다. 이 데이터는
ExerciseSessionRecord로 헬스 커넥트에 작성됩니다.
- 세션 중에 웨어러블 기기에서 기록합니다. 예를 들어 심박수입니다.
이 데이터는 활동의 레코드 유형으로 헬스 커넥트에 작성됩니다. 이 경우
- 나중에 학습 계획 앱은 헬스 커넥트에서 데이터를 읽어 계획된 운동 세션에서 사용자가 설정한 목표 대비 실제 실적을 평가합니다.
운동 계획 및 타겟 설정
사용자는 향후 운동을 계획하고 타겟을 설정할 수 있습니다. 이를 계획된 운동 세션 으로 헬스 커넥트에 작성합니다.
사용 예시에 설명된 예에서 사용자는 지금부터 이틀 후에 90분 달리기를 계획합니다. 이 달리기는 목표 심박수가 90~110bpm인 호수 주변을 3바퀴 도는 것으로 구성됩니다.
이와 같은 스니펫은 계획된 운동 세션을 헬스 커넥트에 기록하는 앱의 양식 핸들러에서 찾을 수 있습니다. 학습을 제공하는 서비스와 같은 통합의 수집 지점에서 찾을 수도 있습니다.
// 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( 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) }
운동 및 활동 데이터 로깅
이틀 후 사용자가 실제 운동 세션을 기록합니다. 이를 운동 세션 으로 헬스 커넥트에 작성합니다.
이 예에서 사용자의 세션 시간은 계획된 기간과 정확히 일치했습니다.
다음 스니펫은 운동 세션을 헬스 커넥트에 기록하는 앱의 양식 핸들러에서 찾을 수 있습니다. 운동 세션을 감지하고 기록할 수 있는 웨어러블 기기의 데이터 수집 및 내보내기 핸들러에서도 찾을 수 있습니다.
여기서 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( 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( 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()
)