From 68fabab6f2fc7b5ca6f9812602fca6919842ae78 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 23 Oct 2026 16:11:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(verification):=20=EA=B1=B8=EC=9D=8C=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EB=82=A0=EB=B6=80=ED=84=B0=EB=A1=9C=20=EB=84=93=ED=9E=88?= =?UTF-8?q?=EA=B3=A0=20=EC=9D=BD=EA=B8=B0=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?gap=20=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EA=B3=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Health Connect 읽기 범위를 「오늘 00:00~지금」에서 「전날 00:00~지금」으로 넓힌다. 서버가 귀속일 D 를 D+2 00:00 확정 전까지 재평가하므로, 자정을 넘긴 뒤에도 전날 기록을 다시 보낸다. readings 의 date 는 수집일이 아니라 기록 시작 시각의 KST 날짜로 귀속한다. - 대상 지표의 읽기 권한이 없으면 PERMISSION_MISSING gap 을 남긴다. 지금까지는 걸음만 꺼지고 거리가 켜져 있으면 gap 없이 빈 값으로 나갔다. - 플랫폼 HC 할당량 초과(ERROR_RATE_LIMIT_EXCEEDED)를 HC_RATE_LIMITED gap 으로 보고한다. 지금까지는 IllegalStateException 으로 조용히 삼켜졌다. - 실패 사유 문구: WOKE_UP_LATE 는 걸음 기준으로, UNTRUSTED_HEALTH_SOURCE 는 인정되지 않는 앱까지 포함하도록 바꾼다. Refs #629 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../signal/health/HealthConnectCollector.kt | 76 ++++++++++++------- .../verification/domain/entity/FailureCopy.kt | 4 +- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/health/HealthConnectCollector.kt b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/health/HealthConnectCollector.kt index b3d42c2aa..38a270b44 100644 --- a/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/health/HealthConnectCollector.kt +++ b/verification/data/src/main/kotlin/com/ruleup/verification/data/signal/health/HealthConnectCollector.kt @@ -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 @@ -87,6 +89,24 @@ 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, @@ -94,28 +114,40 @@ class HealthConnectCollector ) { 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() 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) @@ -123,12 +155,9 @@ class HealthConnectCollector private suspend fun readDistance( client: HealthConnectClient, - granted: Set, range: TimeRangeFilter, - date: String, - ): List { - if (HealthPermission.getReadPermission(DistanceRecord::class) !in granted) return emptyList() - return client + ): List = + client .readRecords(ReadRecordsRequest(DistanceRecord::class, range)) .records .map { rec -> @@ -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, range: TimeRangeFilter, - date: String, - ): List { - if (HealthPermission.getReadPermission(StepsRecord::class) !in granted) return emptyList() - return client + ): List = + client .readRecords(ReadRecordsRequest(StepsRecord::class, range)) .records .map { rec -> @@ -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, range: TimeRangeFilter, - date: String, exerciseType: String?, - ): List { - if (HealthPermission.getReadPermission(ExerciseSessionRecord::class) !in granted) return emptyList() - return client + ): List = + client .readRecords(ReadRecordsRequest(ExerciseSessionRecord::class, range)) .records .mapNotNull { rec -> @@ -187,10 +206,8 @@ class HealthConnectCollector metadata = rec.metadata, startTime = rec.startTime, endTime = rec.endTime, - date = date, ) } - } private suspend fun captureSleep( client: HealthConnectClient, @@ -225,6 +242,7 @@ class HealthConnectCollector recordPermissionGap("SLEEP") return } catch (e: IllegalStateException) { + if (e.isRateLimited()) recordRateLimitGap("SLEEP") return } sleepSessionDao.deleteUntagged() @@ -238,7 +256,6 @@ class HealthConnectCollector metadata: Metadata, startTime: Instant, endTime: Instant, - date: String, ): HealthReadingEntity = HealthReadingEntity( recordId = metadata.id, @@ -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(), ) diff --git a/verification/domain/src/main/kotlin/com/ruleup/verification/domain/entity/FailureCopy.kt b/verification/domain/src/main/kotlin/com/ruleup/verification/domain/entity/FailureCopy.kt index 011cdde39..f21b57cb1 100644 --- a/verification/domain/src/main/kotlin/com/ruleup/verification/domain/entity/FailureCopy.kt +++ b/verification/domain/src/main/kotlin/com/ruleup/verification/domain/entity/FailureCopy.kt @@ -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 -> "수면 시간이 목표에 못 미쳤어요"