Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.ruleup.verification.data.signal.health

import android.content.Context
import android.health.connect.HealthConnectException
import android.os.Build
import android.os.SystemClock
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
Expand Down Expand Up @@ -87,48 +89,75 @@ class HealthConnectCollector
gapRecorder.record(signalType, GapReason.PERMISSION_MISSING, now - GAP_WINDOW_MS, now, recoverable = true)
}

/** 백그라운드 읽기 할당량 초과로 읽지 못한 최근 구간. 다음 읽기에서 복구된다. */
private suspend fun recordRateLimitGap(signalType: String) {
val now = System.currentTimeMillis()
gapRecorder.record(signalType, GapReason.HC_RATE_LIMITED, now - GAP_WINDOW_MS, now, recoverable = true)
}

private fun HealthMetric.readPermission(): String =
when (this) {
HealthMetric.DISTANCE -> HealthPermission.getReadPermission(DistanceRecord::class)
HealthMetric.STEPS -> HealthPermission.getReadPermission(StepsRecord::class)
HealthMetric.EXERCISE_DURATION -> HealthPermission.getReadPermission(ExerciseSessionRecord::class)
}

// 플랫폼 HC(API 34+)는 할당량 초과를 IllegalStateException 으로 감싸 던진다
private fun IllegalStateException.isRateLimited(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
(cause as? HealthConnectException)?.errorCode == HealthConnectException.ERROR_RATE_LIMIT_EXCEEDED

private suspend fun captureHealth(
client: HealthConnectClient,
granted: Set<String>,
targets: Set<HealthTarget>,
) {
val zone = com.ruleup.domain.time.ServiceDate.ZONE
val now = Instant.now()
val todayStart = LocalDate.now(zone).atStartOfDay(zone).toInstant()
val date = LocalDate.now(zone).toString()
val range = TimeRangeFilter.between(todayStart, now)
// 서버는 귀속일 D 를 D+2 00:00 확정 전까지 재평가한다 → 자정을 넘겨도 전날 기록을 다시 보낸다
val yesterdayStart =
LocalDate
.now(zone)
.minusDays(1)
.atStartOfDay(zone)
.toInstant()
val range = TimeRangeFilter.between(yesterdayStart, now)

val rows = ArrayList<HealthReadingEntity>()
var denied = false
var rateLimited = false
for (target in targets) {
if (target.metric.readPermission() !in granted) {
denied = true
continue
}
try {
when (target.metric) {
HealthMetric.DISTANCE -> rows += readDistance(client, granted, range, date)
HealthMetric.STEPS -> rows += readSteps(client, granted, range, date)
HealthMetric.EXERCISE_DURATION -> rows += readExercise(client, granted, range, date, target.exerciseType)
HealthMetric.DISTANCE -> rows += readDistance(client, range)
HealthMetric.STEPS -> rows += readSteps(client, range)
HealthMetric.EXERCISE_DURATION -> rows += readExercise(client, range, target.exerciseType)
}
} catch (e: SecurityException) {
// 권한 회수, 또는 백그라운드 읽기 권한 없이 앱이 꺼진 동안 읽으려 한 경우
denied = true
} catch (e: IllegalStateException) {
// HC 클라이언트 일시 오류
if (e.isRateLimited()) rateLimited = true
}
}
// 조용히 빠지면 서버는 신호 없음(NO_SIGNAL)으로 본다 → 공백 사유를 남겨 판정을 유예시킨다(명세 §0.4)
if (denied) recordPermissionGap("HEALTH")
if (rateLimited) recordRateLimitGap("HEALTH")
// 미전송 스냅샷을 최신값으로 교체.
healthReadingDao.deleteUntagged()
if (rows.isNotEmpty()) healthReadingDao.insertAll(rows)
}

private suspend fun readDistance(
client: HealthConnectClient,
granted: Set<String>,
range: TimeRangeFilter,
date: String,
): List<HealthReadingEntity> {
if (HealthPermission.getReadPermission(DistanceRecord::class) !in granted) return emptyList()
return client
): List<HealthReadingEntity> =
client
.readRecords(ReadRecordsRequest(DistanceRecord::class, range))
.records
.map { rec ->
Expand All @@ -138,19 +167,14 @@ class HealthConnectCollector
metadata = rec.metadata,
startTime = rec.startTime,
endTime = rec.endTime,
date = date,
)
}
}

private suspend fun readSteps(
client: HealthConnectClient,
granted: Set<String>,
range: TimeRangeFilter,
date: String,
): List<HealthReadingEntity> {
if (HealthPermission.getReadPermission(StepsRecord::class) !in granted) return emptyList()
return client
): List<HealthReadingEntity> =
client
.readRecords(ReadRecordsRequest(StepsRecord::class, range))
.records
.map { rec ->
Expand All @@ -160,20 +184,15 @@ class HealthConnectCollector
metadata = rec.metadata,
startTime = rec.startTime,
endTime = rec.endTime,
date = date,
)
}
}

private suspend fun readExercise(
client: HealthConnectClient,
granted: Set<String>,
range: TimeRangeFilter,
date: String,
exerciseType: String?,
): List<HealthReadingEntity> {
if (HealthPermission.getReadPermission(ExerciseSessionRecord::class) !in granted) return emptyList()
return client
): List<HealthReadingEntity> =
client
.readRecords(ReadRecordsRequest(ExerciseSessionRecord::class, range))
.records
.mapNotNull { rec ->
Expand All @@ -187,10 +206,8 @@ class HealthConnectCollector
metadata = rec.metadata,
startTime = rec.startTime,
endTime = rec.endTime,
date = date,
)
}
}

private suspend fun captureSleep(
client: HealthConnectClient,
Expand Down Expand Up @@ -225,6 +242,7 @@ class HealthConnectCollector
recordPermissionGap("SLEEP")
return
} catch (e: IllegalStateException) {
if (e.isRateLimited()) recordRateLimitGap("SLEEP")
return
}
sleepSessionDao.deleteUntagged()
Expand All @@ -238,7 +256,6 @@ class HealthConnectCollector
metadata: Metadata,
startTime: Instant,
endTime: Instant,
date: String,
): HealthReadingEntity =
HealthReadingEntity(
recordId = metadata.id,
Expand All @@ -248,7 +265,8 @@ class HealthConnectCollector
endTime = endTime.toEpochMilli(),
originPackage = metadata.dataOrigin.packageName,
recordingMethod = recordingMethodOf(metadata.recordingMethod),
date = date,
// 읽기 범위가 이틀에 걸치므로 수집일이 아니라 기록 시작 시각의 KST 날짜로 귀속한다
date = startTime.atZone(com.ruleup.domain.time.ServiceDate.ZONE).toLocalDate().toString(),
occurredAt = endTime.toEpochMilli(),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ fun FailureReason.failureText(): String =
FailureReason.INSUFFICIENT_DISTANCE -> "이동 거리가 목표에 못 미쳤어요"
FailureReason.INSUFFICIENT_STEPS -> "걸음 수가 목표에 못 미쳤어요"
// 부정행위로 단정하지 않는다
FailureReason.UNTRUSTED_HEALTH_SOURCE -> "직접 입력한 기록은 인정되지 않아요"
FailureReason.UNTRUSTED_HEALTH_SOURCE -> "인정되지 않는 앱의 기록이거나 직접 입력한 기록이에요"
FailureReason.INSUFFICIENT_USAGE -> "앱 사용 시간이 목표에 못 미쳤어요"
FailureReason.USAGE_EXCEEDED -> "앱 사용 시간이 목표를 넘었어요"
FailureReason.WOKE_UP_LATE -> "목표 시각까지 잠금 해제 기록이 없어요"
FailureReason.WOKE_UP_LATE -> "목표 시각까지 걸음 기록이 없어요"
FailureReason.PHONE_USED_IN_BLOCK_WINDOW -> "사용하지 않기로 한 시간에 사용 기록이 있어요"
FailureReason.SLEPT_LATE -> "목표 취침 시각을 넘겨 잠들었어요"
FailureReason.INSUFFICIENT_SLEEP -> "수면 시간이 목표에 못 미쳤어요"
Expand Down
Loading