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
Expand Up @@ -53,7 +53,7 @@ public EvaluationOutcome evaluate(DayContext ctx) {
boolean anyReading = false;
boolean anyRejected = false;
List<Measured> intervals = new ArrayList<>(); // 구간이 있는 레코드
Set<String> seenRecords = new HashSet<>();
Map<String, Measured> latestByRecord = new LinkedHashMap<>();
double cumulativeMax = 0; // 구간이 없는 레코드(그날 누적값)

if (ctx.signals() != null) {
Expand All @@ -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));
// 같은 레코드가 여러 번 왔으면(재전송·기록 앱의 갱신) <b>마지막에 받은 것</b> 하나만 센다.
// 처음 받은 값을 남기면 걸음이 늘어난 갱신이 버려져 목표에 영영 못 닿는다.
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<String, Object> ev = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ public EvaluationOutcome evaluate(DayContext ctx) {
Map<String, Instant> open = new HashMap<>();
long accSec = processEvents(ctx.signals(), targets, window, open);

// 창 닫힘 이후면 남은 open 세션을 창 끝에서 종료(꼬리 시간 반영)
// 창 닫힘 이후면 남은 open 세션을 닫는다(꼬리 시간 반영). 닫는 시각은 창 끝이 아니라 <b>그 기기에서 마지막으로
// 받은 시각</b>까지다 — 앱은 화면 꺼짐을 보내지 않아서, 대상 앱이 앞에 뜬 채 배터리가 꺼지거나 그 뒤로 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();
}

Expand Down Expand Up @@ -86,6 +91,24 @@ public EvaluationOutcome evaluate(DayContext ctx) {
private static final Set<String> 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<SyncSignal> 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<String> SCREEN_OFF_EVENTS =
Set.of("SCREEN_OFF", "SCREEN_NON_INTERACTIVE", "DEVICE_SHUTDOWN");
Expand Down Expand Up @@ -130,7 +153,12 @@ private long processEvents(List<SyncSignal> signals, Set<String> 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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ public EvaluationOutcome evaluate(DayContext ctx) {
/** 창 안 걸음 합 — 출처 게이트를 통과한 구간 기록만, 창에 겹친 비율만큼, 겹친 기록은 한 번만. */
private double stepsInWindow(List<SyncSignal> signals, Window window, WakeConfig cfg, Set<String> rejected) {
if (signals == null) return 0;
// 같은 기록은 <b>마지막에 받은 것</b>으로 센다. 앱은 sync 마다 그날 기록을 다시 보내고, 기록 앱은 같은 기록의
// 걸음 수·끝 시각을 키워 가며 고친다 — 처음 받은 값(예: 3걸음)에 묶이면 그날은 끝내 문턱을 못 넘는다.
// 신호는 받은 순서(행 id = 시간순)로 오므로 뒤에 온 것이 덮는다.
Map<String, HealthEvaluator.Measured> byRecord = new LinkedHashMap<>();
List<HealthEvaluator.Measured> clipped = new ArrayList<>();
Set<String> seenRecords = new HashSet<>();
for (SyncSignal s : signals) {
if (!SignalType.HEALTH.name().equals(s.type()) || s.readings() == null) continue;
for (HealthReading r : s.readings()) {
Expand All @@ -82,16 +85,18 @@ private double stepsInWindow(List<SyncSignal> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ public class VerificationConfigFactory {
private static final List<String> DEFAULT_TRUSTED_ORIGINS =
List.of("com.sec.android.app.shealth", "com.google.android.apps.fitness");

/**
* 기상 걸음의 신뢰 writer. 걸음 목표 판정(HEALTH)보다 <b>넓다</b> — 기상은 「일어나 움직였는가」만 보는
* 10걸음 문턱이라 위조해서 얻을 것이 적고, 삼성헬스·구글 피트니스가 없는 폰은 Health Connect 플랫폼이나
* 시스템 자체가 걸음을 기록한다. 두 앱만 믿으면 그런 폰의 기상은 매일 실패한다. 손입력은 여전히 뺀다.
*/
private static final List<String> 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. 걸음·거리보다 <b>넓다</b> — 수면은 Health Connect 플랫폼 자신과
* Play 서비스의 Sleep API 도 기록자로 올라오기 때문이다. 목록 밖 출처는 AUTO 로 적혀 있어도
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsageEvent> 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,
Expand Down Expand Up @@ -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() {
Expand All @@ -267,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 {
Expand Down
Loading