From d778367197c7e2f970949ded587259c1cb2ea1c2 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Sat, 17 Oct 2026 00:16:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EA=B8=B0=EC=83=81=20=EA=B1=B8?= =?UTF-8?q?=EC=9D=8C=EC=9D=B4=20=EA=B8=B0=EA=B8=B0=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=EC=97=90=EC=84=9C=EB=8F=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A0=95=EB=90=98=EA=B3=A0,=20=EA=B0=B1=EC=8B=A0=EB=90=9C=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C=20=EA=B8=B0=EB=A1=9D=EC=9D=80=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=84=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기상 걸음 신뢰 출처가 삼성헬스·구글 피트니스 둘뿐이라, 그 앱이 없는 폰(Health Connect 플랫폼·기기 자체 걸음)은 매일 UNTRUSTED_ORIGIN 으로 빠져 늦잠이 됐다. 기상은 10걸음 문턱이라 출처를 넓힌다(손입력은 계속 뺀다). - 앱은 sync 마다 그날 걸음 기록을 다시 보내고 기록 앱은 같은 기록의 걸음 수를 키워 가며 고치는데, 기상·걸음 판정이 같은 기록 id 의 처음 값만 남겨 첫 sync 의 작은 값에 묶였다. 마지막에 받은 값을 쓴다. Co-Authored-By: Claude Opus 5.5 --- .../evaluator/HealthEvaluator.java | 10 +++--- .../verification/evaluator/WakeEvaluator.java | 11 ++++-- .../service/VerificationConfigFactory.java | 13 ++++++- .../verification/EvaluatorAccuracyTest.java | 35 +++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java index 8b75bf66..d5fafe8c 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/HealthEvaluator.java @@ -53,7 +53,7 @@ public EvaluationOutcome evaluate(DayContext ctx) { boolean anyReading = false; boolean anyRejected = false; List intervals = new ArrayList<>(); // 구간이 있는 레코드 - Set seenRecords = new HashSet<>(); + Map latestByRecord = new LinkedHashMap<>(); double cumulativeMax = 0; // 구간이 없는 레코드(그날 누적값) if (ctx.signals() != null) { @@ -80,13 +80,15 @@ public EvaluationOutcome evaluate(DayContext ctx) { cumulativeMax = Math.max(cumulativeMax, value); // 구간 없음 → 누적값으로 본다 continue; } - // 같은 레코드가 두 번 남아 있으면(재전송) 한 번만 센다. - if (r.recordId() != null && !seenRecords.add(r.recordId())) continue; - intervals.add(new Measured(from, to, value)); + // 같은 레코드가 여러 번 왔으면(재전송·기록 앱의 갱신) 마지막에 받은 것 하나만 센다. + // 처음 받은 값을 남기면 걸음이 늘어난 갱신이 버려져 목표에 영영 못 닿는다. + if (r.recordId() != null) latestByRecord.put(r.recordId(), new Measured(from, to, value)); + else intervals.add(new Measured(from, to, value)); } } } + intervals.addAll(latestByRecord.values()); double best = Math.max(cumulativeMax, dedupedSum(intervals)); Map ev = new HashMap<>(); diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java index d7ebcd0c..46c91331 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/WakeEvaluator.java @@ -67,8 +67,11 @@ public EvaluationOutcome evaluate(DayContext ctx) { /** 창 안 걸음 합 — 출처 게이트를 통과한 구간 기록만, 창에 겹친 비율만큼, 겹친 기록은 한 번만. */ private double stepsInWindow(List signals, Window window, WakeConfig cfg, Set rejected) { if (signals == null) return 0; + // 같은 기록은 마지막에 받은 것으로 센다. 앱은 sync 마다 그날 기록을 다시 보내고, 기록 앱은 같은 기록의 + // 걸음 수·끝 시각을 키워 가며 고친다 — 처음 받은 값(예: 3걸음)에 묶이면 그날은 끝내 문턱을 못 넘는다. + // 신호는 받은 순서(행 id = 시간순)로 오므로 뒤에 온 것이 덮는다. + Map byRecord = new LinkedHashMap<>(); List clipped = new ArrayList<>(); - Set seenRecords = new HashSet<>(); for (SyncSignal s : signals) { if (!SignalType.HEALTH.name().equals(s.type()) || s.readings() == null) continue; for (HealthReading r : s.readings()) { @@ -82,16 +85,18 @@ private double stepsInWindow(List signals, Window window, WakeConfig Instant from = TimeWindows.parseInstant(r.startTime()); Instant to = TimeWindows.parseInstant(r.endTime()); if (from == null || to == null || !to.isAfter(from) || r.value() == null) continue; - if (r.recordId() != null && !seenRecords.add(r.recordId())) continue; // 재전송은 한 번만 Instant start = from.isBefore(window.start()) ? window.start() : from; Instant end = to.isAfter(window.end()) ? window.end() : to; if (!end.isAfter(start)) continue; // 창 밖 기록 double share = (double) (end.getEpochSecond() - start.getEpochSecond()) / (to.getEpochSecond() - from.getEpochSecond()); - clipped.add(new HealthEvaluator.Measured(start, end, r.value().doubleValue() * share)); + HealthEvaluator.Measured m = new HealthEvaluator.Measured(start, end, r.value().doubleValue() * share); + if (r.recordId() != null) byRecord.put(r.recordId(), m); + else clipped.add(m); } } + clipped.addAll(byRecord.values()); return HealthEvaluator.dedupedSum(clipped); } } diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java index be3fde4b..469de2b9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/service/VerificationConfigFactory.java @@ -30,6 +30,17 @@ public class VerificationConfigFactory { private static final List DEFAULT_TRUSTED_ORIGINS = List.of("com.sec.android.app.shealth", "com.google.android.apps.fitness"); + /** + * 기상 걸음의 신뢰 writer. 걸음 목표 판정(HEALTH)보다 넓다 — 기상은 「일어나 움직였는가」만 보는 + * 10걸음 문턱이라 위조해서 얻을 것이 적고, 삼성헬스·구글 피트니스가 없는 폰은 Health Connect 플랫폼이나 + * 시스템 자체가 걸음을 기록한다. 두 앱만 믿으면 그런 폰의 기상은 매일 실패한다. 손입력은 여전히 뺀다. + */ + private static final List DEFAULT_TRUSTED_WAKE_ORIGINS = List.of( + "com.sec.android.app.shealth", // 삼성헬스 + "com.google.android.apps.fitness", // 구글 피트니스 + "com.google.android.apps.healthdata", // Health Connect 플랫폼 + "android"); // 기기 자체 걸음 센서 + /** * 수면 기록의 신뢰 writer. 걸음·거리보다 넓다 — 수면은 Health Connect 플랫폼 자신과 * Play 서비스의 Sleep API 도 기록자로 올라오기 때문이다. 목록 밖 출처는 AUTO 로 적혀 있어도 @@ -68,7 +79,7 @@ public VerificationConfig build(Challenge challenge) { case "WAKE" -> wake = new WakeConfig( timeParam(params, "target_time", "07:00"), properties.wakeGraceMinutes(), properties.wakeMinSteps(), - DEFAULT_TRUSTED_ORIGINS, Polarity.ACHIEVEMENT, 1); + DEFAULT_TRUSTED_WAKE_ORIGINS, Polarity.ACHIEVEMENT, 1); case "SCREEN_TIME_MIN" -> screenTime = new ScreenTimeConfig( ScreenTimeMode.MIN, Polarity.ACHIEVEMENT, packages(params), intParam(params, "duration_min", 30), timeWindow(params), 1); diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java index 951d8c37..a3a5b834 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java @@ -109,6 +109,30 @@ private static SyncSignal steps(double count, Instant start, Instant end, Health null, null, null, TARGET.toString(), null, null, null, null); } + private static SyncSignal stepsRecord(String recordId, double count, Instant start, Instant end, Instant receivedAt) { + HealthReading reading = new HealthReading(recordId, "STEPS", BigDecimal.valueOf(count), "count", + start.toString(), end.toString(), null, WATCH); + return new SyncSignal("HEALTH", null, end.toString(), null, null, null, List.of(reading), + null, null, null, TARGET.toString(), null, null, null, receivedAt); + } + + private static UsageEvent use(String pkg, String type, Instant when) { + return new UsageEvent(pkg, type, when.toString()); + } + + private static SyncSignal usage(List events, Instant receivedAt) { + return new SyncSignal("SCREEN_TIME", null, receivedAt.toString(), null, null, null, null, + null, null, null, TARGET.toString(), events, null, null, receivedAt); + } + + private VerificationConfig screenTimeConfig(ScreenTimeMode mode, int goalMinutes) { + return new VerificationConfig(ScheduleType.FIXED_DAYS, null, MethodCombine.AND, + List.of(VerificationMethod.SCREEN_TIME), null, null, + new ScreenTimeConfig(mode, mode == ScreenTimeMode.MAX ? Polarity.CONSTRAINT : Polarity.ACHIEVEMENT, + List.of("com.instagram.android"), goalMinutes, null, 1), + null, null, List.of()); + } + private static SyncSignal sleep(Instant start, Instant end, HealthOrigin origin) { return new SyncSignal("SLEEP", null, end.toString(), null, null, null, null, null, null, null, null, null, null, @@ -256,6 +280,17 @@ void manualStepsAreExcluded() { assertThat(outcome.status()).isNotEqualTo(VerificationStatus.SUCCESS); } + @Test + @DisplayName("같은 기록이 갱신돼 다시 오면 마지막 값으로 센다 — 처음 받은 3걸음에 묶이지 않는다") + void updatedRecordUsesLatestValue() { + EvaluationOutcome outcome = evaluator.evaluate(ctx(wakeConfig("07:00"), List.of( + stepsRecord("hc-1", 3, at(7, 0), at(7, 2), at(7, 3)), + stepsRecord("hc-1", 24, at(7, 0), at(7, 8), at(7, 9))), List.of(), at(7, 10))); + + assertThat(outcome.status()).isEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.evidence()).containsEntry("value", 24L); + } + @Test @DisplayName("창이 열려 있고 아직 걸음이 없으면 기다린다") void waitsWhileWindowOpen() { From 9f62a1b255f25da755c246adc3f9c65942c76dcd Mon Sep 17 00:00:00 2001 From: selee1012 Date: Sat, 17 Oct 2026 00:16:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=A6=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=9D=B4=20=ED=99=94=EB=A9=B4=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=84=20=EC=9E=83=EA=B1=B0=EB=82=98,=20=EB=81=8A=EA=B8=B4?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=EC=9D=84=20=EC=9E=90=EC=A0=95=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=8A=98=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 앱 안에서 화면을 옮기면 PAUSED(A)→RESUMED(B)→STOPPED(A) 순인데, 화면 구분이 없어 STOPPED(A) 가 B 세션을 닫아 사용 시간이 빠졌다(최소 사용형 오실패). 세션이 열린 직후(5초 안) STOPPED 는 앞 화면 것으로 본다. - 앱은 화면 꺼짐을 보내지 않아, 닫힘 없이 끊긴 세션이 창 끝(자정)까지 늘어 최대 사용형이 USAGE_EXCEEDED 로 잘못 실패했다. 창이 닫히면 열린 세션을 그 기기에서 마지막으로 받은 시각에서 닫는다. Co-Authored-By: Claude Opus 5.5 --- .../evaluator/ScreenTimeEvaluator.java | 34 ++++++++++++-- .../verification/EvaluatorAccuracyTest.java | 45 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java index cff54fe5..60bbccd9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java +++ b/src/main/java/com/ruleup/ruleup_backend/verification/evaluator/ScreenTimeEvaluator.java @@ -45,9 +45,14 @@ public EvaluationOutcome evaluate(DayContext ctx) { Map open = new HashMap<>(); long accSec = processEvents(ctx.signals(), targets, window, open); - // 창 닫힘 이후면 남은 open 세션을 창 끝에서 종료(꼬리 시간 반영) + // 창 닫힘 이후면 남은 open 세션을 닫는다(꼬리 시간 반영). 닫는 시각은 창 끝이 아니라 그 기기에서 마지막으로 + // 받은 시각까지다 — 앱은 화면 꺼짐을 보내지 않아서, 대상 앱이 앞에 뜬 채 배터리가 꺼지거나 그 뒤로 sync 가 + // 없으면 세션이 자정까지 늘어나 최대 사용형이 쓰지 않은 시간으로 실패했다. 그 뒤에 받은 기록이 없으니 + // 그 뒤에 썼다는 근거도 없다. 받은 시각을 모르면(옛 행) 예전처럼 창 끝에서 닫는다. if (!ctx.now().isBefore(window.end())) { - accSec += closeOpenAt(window.end(), window, open); + Instant lastSeen = lastReceivedAt(ctx.signals()); + Instant closeAt = (lastSeen != null && lastSeen.isBefore(window.end())) ? lastSeen : window.end(); + accSec += closeOpenAt(closeAt, window, open); open.clear(); } @@ -86,6 +91,24 @@ public EvaluationOutcome evaluate(DayContext ctx) { private static final Set CLOSE_EVENTS = Set.of("PAUSED", "ACTIVITY_PAUSED", "STOPPED", "ACTIVITY_STOPPED", "MOVE_TO_BACKGROUND"); + /** 앞 화면의 정지로 보고 넘기는 간격 — 화면 전환 뒤 앞 화면 STOPPED 는 보통 1초 안에 온다. */ + private static final java.time.Duration STOP_AFTER_SWITCH = java.time.Duration.ofSeconds(5); + + private static boolean isStop(String type) { + return "STOPPED".equals(type) || "ACTIVITY_STOPPED".equals(type); + } + + /** 사용 기록을 실은 신호를 마지막으로 받은 시각. 모르면 null. */ + private Instant lastReceivedAt(List signals) { + Instant last = null; + if (signals == null) return null; + for (SyncSignal s : signals) { + if (!carriesUsage(s.type()) || s.receivedAt() == null) continue; + if (last == null || s.receivedAt().isAfter(last)) last = s.receivedAt(); + } + return last; + } + /** 화면이 꺼지면 어떤 앱도 쓰이고 있지 않다 — 열린 세션 전부를 닫는 시스템 이벤트다. */ private static final Set SCREEN_OFF_EVENTS = Set.of("SCREEN_OFF", "SCREEN_NON_INTERACTIVE", "DEVICE_SHUTDOWN"); @@ -130,7 +153,12 @@ private long processEvents(List signals, Set targets, Window if (OPEN_EVENTS.contains(type)) { open.putIfAbsent(pkg, at); // 이미 열려있으면 유지(가장 이른 것) } else if (CLOSE_EVENTS.contains(type)) { - Instant opened = open.remove(pkg); + Instant opened = open.get(pkg); + // 같은 앱 안에서 화면을 옮기면 PAUSED(A) → RESUMED(B) → STOPPED(A) 순으로 온다. 이벤트에 화면(액티비티) + // 구분이 없어 STOPPED(A) 가 방금 연 B 를 닫으면 B 를 쓴 시간이 통째로 빠진다. 열린 직후의 정지는 앞 화면의 + // 것으로 보고 넘긴다 — 강제 종료로 PAUSED 없이 STOPPED 만 남는 경우는 그보다 늦게 온다. + if (opened != null && isStop(type) && at.isBefore(opened.plus(STOP_AFTER_SWITCH))) continue; + open.remove(pkg); if (opened != null) addedSec += overlapSeconds(opened, at, window); } } diff --git a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java index a3a5b834..ea148223 100644 --- a/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java +++ b/src/test/java/com/ruleup/ruleup_backend/verification/EvaluatorAccuracyTest.java @@ -302,6 +302,51 @@ void waitsWhileWindowOpen() { } } + @Nested + @DisplayName("스크린타임 — 앱 형식 이벤트 짝짓기") + class ScreenTimePairing { + + private final ScreenTimeEvaluator evaluator = new ScreenTimeEvaluator(); + private static final String IG = "com.instagram.android"; + + @Test + @DisplayName("같은 앱 안에서 화면을 옮겨도 사용 시간이 끊기지 않는다 — 앞 화면 STOPPED 가 새 화면을 닫지 않는다") + void activitySwitchKeepsSession() { + Instant t = at(21, 0); + EvaluationOutcome outcome = evaluator.evaluate(ctx(screenTimeConfig(ScreenTimeMode.MIN, 15), List.of(usage(List.of( + use(IG, "RESUMED", t), + use(IG, "PAUSED", t.plusSeconds(60)), + use(IG, "RESUMED", t.plusSeconds(60)), + use(IG, "STOPPED", t.plusSeconds(61)), + use(IG, "PAUSED", t.plusSeconds(60 + 20 * 60))), at(21, 30))), List.of(), at(21, 30))); + + assertThat(outcome.status()).isEqualTo(VerificationStatus.SUCCESS); + assertThat(outcome.evidence()).containsEntry("usageMinutes", 21L); + } + + @Test + @DisplayName("닫힘 없이 끊긴 세션은 자정이 아니라 마지막으로 받은 시각에서 닫는다") + void openSessionClosesAtLastReceipt() { + // 20:00 에 열고 20:10 에 받은 뒤 기록이 끊겼다(배터리 꺼짐) — 자정까지 4시간을 쓴 것으로 보면 30분 한도 실패가 된다. + EvaluationOutcome outcome = evaluator.evaluate(ctx(screenTimeConfig(ScreenTimeMode.MAX, 30), List.of(usage(List.of( + use(IG, "RESUMED", at(20, 0))), at(20, 10))), List.of(), TARGET.plusDays(1).atStartOfDay(KST).toInstant().plusSeconds(60))); + + assertThat(outcome.failureReason()).isNull(); + assertThat(outcome.evidence()).containsEntry("usageMinutes", 10L); + } + + @Test + @DisplayName("PAUSED 없이 늦게 온 STOPPED(강제 종료)는 그대로 세션을 닫는다") + void lateStopStillCloses() { + EvaluationOutcome outcome = evaluator.evaluate(ctx(screenTimeConfig(ScreenTimeMode.MAX, 30), List.of(usage(List.of( + use(IG, "RESUMED", at(20, 0)), + use(IG, "STOPPED", at(20, 40))), at(20, 45))), List.of(), at(20, 50))); + + assertThat(outcome.failureReason()).isEqualTo("USAGE_EXCEEDED"); + assertThat(outcome.evidence()).containsEntry("usageMinutes", 40L); + } + } + @Nested @DisplayName("수면 — 신뢰 출처와 세그먼트 누적") class SleepTrustAndAccumulation {