Check Phone learns to look backward, and forgives shaky GPS

c277325 · 12 files · +583 / −78 · 2026-07-18

1. Background — what Check Phone actually checks

screen-locker locks the screen until a weekly workout quota is met. The status window has a voluntary "Check Phone" button (screen_locker/_status_view_verify.py, PhoneCheckMixin) that, on click, pulls real data off your Android phone over ADB and — if it verifies — logs it and applies the reward. It never trusts a typed claim; it always pulls the actual file first. Two sources feed it:

StrongLifts
Pulls workout_result.json off the phone. _verify_phone_workout in _phone_verification.py.
RunnerUp
Pulls the real .tcx file from /sdcard/Documents/RunnerUp/. _verify_runnerup_workout in _runnerup_verification.py.

Both of those functions were written to answer one specific question: did you work out today? They match on today's date only — StrongLifts by comparing the JSON's date field, RunnerUp by grepping filenames for today's date string. That's the right question for the locked screen, which decides whether to unlock right now. But the voluntary "Check Phone" button reused those same today-only functions, even though what it's really trying to answer is broader: is this week's quota met?

The quota itself, from _weekly_check.py, follows an anti-gaming rule established in the previous change to this repo: each verified workout (StrongLifts or RunnerUp) counts individually, however many happen on one day, because they're machine-checked and can't be faked. A manual self-report counts at most once per day. The weekly minimum is 4.

A week-aware scanner for RunnerUp already existed — _scan_and_fill_week_runnerup walks every day from Monday to today, and for each one still ADB-pulls and validates the real TCX file before writing anything. It was wired into the automatic login check and the --status CLI, but never into the "Check Phone" button. Nothing equivalent existed for StrongLifts at all.

2. Intuition — a same-day blind spot, and a shaky GPS trace

Here's what actually happened. Wednesday's status window connected to the phone and clicked Check Phone. The phone had a real RunnerUp run from yesterday — a 4.9 km run recorded the evening before, after the button's earlier check that day had already failed. The result:

Phone check (not_verified): StrongLifts: Workout JSON is from 2026-07-17, not today · RunnerUp: No RunnerUp activity found for today

Both messages are literally true and both are useless. There is a real, verifiable run — the button just never looked at any date but today.

Before
StrongLifts today? no →
RunnerUp today? no →
give up
After
StrongLifts today? no →
RunnerUp today? no →
scan Mon…today for anything unlogged

The week-scan fallback reuses the existing RunnerUp scanner and a new, symmetric StrongLifts one — both still pull and validate the real file via ADB, they just stop requiring the date to be today and instead accept anything from Monday through today that isn't logged yet.

The second problem the first one uncovered

Testing this against the real phone surfaced a second issue: that 4.9 km run should count, but MIN_RUN_DISTANCE_KM is 5.0. GPS traces measure distance by summing straight-line hops between sampled points, so tight turns, tree cover, and urban canyons make the recorded distance consistently shorter than the run actually was. A real 5 km run can legitimately log as 4.9. So a 5% tolerance was added — applied to distance only, since duration comes from the device clock and has no such noise:

recorded
4.90 km
×1.05 →
tolerant distance
5.145 km
≥ 5.0 →
verified
counts

Play with the whole chain below — it mirrors _verify_phone_then_runnerup's real order: StrongLifts today, then RunnerUp today, then the week-scan fallback, using the same 5% distance rule at every RunnerUp step.

3. Code — the change, by concern

3.1 Splitting the date-check out of StrongLifts validation

_validate_json_data used to do two jobs at once: check the date, then check the exercises/duration/succeeded content. The week-scan needs the second job without the first, so it was split:

+ def _validate_json_content(self, data: dict) -> tuple[str, str]:
+     """...no date check..."""
+     if not data.get("exercises"):
+         return "no_exercises", "No exercises found in the workout JSON"
+     ...

  def _validate_json_data(self, data: dict) -> tuple[str, str]:
      - today = time.strftime("%Y-%m-%d")
      - if data.get("date") != today: return "stale", ...
      - if not data.get("exercises"): ...
+     today = time.strftime("%Y-%m-%d")
+     if data.get("date") != today:
+         return "stale", f"Workout JSON is from {data.get('date')}, not today"
+     return self._validate_json_content(data)

_validate_json_data — the live, today-only check used by the locked screen's unlock chain — keeps behaving exactly as before. It's now just a thin date gate in front of the shared content check.

3.2 A week-scan backfill for StrongLifts (new — nothing existed before)

RunnerUp's _scan_and_fill_week_runnerup can find multiple unlogged days because RunnerUp writes one .tcx file per activity. StrongLifts only has one workout_result.json snapshot on the phone at a time, so its equivalent can only ever backfill one day per call — that's not a limitation of the fix, it's what the data actually allows:

+ def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
+     if not self._has_adb_device():
+         return 0
+     data = self._pull_workout_app_json()
+     date_str = data.get("date") if data else None
+     if not date_str:
+         return 0
+     today = datetime.now(tz=timezone.utc).astimezone().date()
+     week_start = today - timedelta(days=today.weekday())
+     entry_date = date.fromisoformat(date_str)   # ValueError -> 0, logged
+     if not week_start <= entry_date <= today:
+         return 0
+     status, msg = self._validate_json_content(data)   # no date gate
+     if status != "verified":
+         return 0
+     ...
+     if write_signed_entry(log_file, date_str, workout_data).appended:
+         return 1
+     return 0

write_signed_entry dedups by workout_id (here "phone_verified:{date}"), so re-scanning an already-filled day is a safe no-op — calling this twice in a row correctly returns 1 then 0.

3.3 Wiring both week-scans into the button

The old return type was a bare 4-tuple, which had no room for "nothing verified today, but something got backfilled." A small frozen dataclass replaces it, and a new helper runs both scanners and applies whatever bonus is earned — reusing the exact prev/new-count math the login auto-check and --status already use, specifically not routing through _apply_workout_credit, which always files under today by design and would mis-date a backfilled entry:

+ @dataclass(frozen=True)
+ class PhoneCheckOutcome:
+     credited_type: str | None
+     status: str
+     phone_message: str
+     runnerup_message: str | None
+     week_fill_message: str | None = None

+ def _backfill_week_and_apply_bonus(verifier) -> str | None:
+     prev_count = count_weekly_workouts(verifier.log_file)
+     filled = verifier._scan_and_fill_week_runnerup(verifier.log_file)
+     filled += verifier._try_fill_stronglifts_for_week(verifier.log_file)
+     if not filled:
+         return None
+     new_count = count_weekly_workouts(verifier.log_file)
+     bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
+     if bonus > 0:
+         verifier._adjust_shutdown_time_by(bonus)
+     return f"Auto-filled {filled} workout(s) from earlier this week." + (
+         f" +{bonus}h shutdown time." if bonus > 0 else ""
+     )

_verify_phone_then_runnerup calls this only after both today-checks fail, and _on_phone_check_result branches on it: a verified-today result still goes through _apply_workout_credit as before; a week_fill_message is shown as-is, because the backfill step already wrote the entry and applied its own bonus — running credit again there would double it.

3.4 The RunnerUp GPS tolerance

  distance_km = data["distance_m"] / 1000
+ if distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) < MIN_RUN_DISTANCE_KM:
-     if distance_km < MIN_RUN_DISTANCE_KM:
      msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
      return "too_short", msg
+ if distance_km < MIN_RUN_DISTANCE_KM:
+     _logger.info("...within %.0f%% GPS tolerance — accepted.", ...)

RUNNERUP_DISTANCE_TOLERANCE = 0.05 lives in _constants.py next to the minimum it adjusts. The log line on a tolerance-assisted pass exists so a below-threshold-but-accepted run is still visible in the logs, never a silent pass — the entry itself still stores the true, un-inflated distance.

Superseded 2026-08-14. The numbers on this page describe the rule as it stood on 2026-07-18 and are kept for that history. The thresholds and their shape have both since changed: duration and distance were an AND here (30+ min and 5.0+ km); they are now an OR — a run qualifies at MIN_RUN_DISTANCE_KM = 3.5 (inclusive, still with the 5% GPS tolerance described above) or at MIN_RUN_DURATION_MINUTES = 40 (strict, no tolerance). The 30-minute floor every run had to clear is gone, so a fast 5 km now passes too. The interactive demo and quiz below still use the old 5.0 km AND-rule.

3.5 The CLI bug this surfaced (unrelated file, same root cause)

Verifying the fix against the real phone produced a week with two workouts on one day for the first time. That's when _status.py's --status CLI printed "Need 2 more to reach the minimum (4)" for a week that was actually 4/4 — the GUI, which uses count_weekly_workouts directly, showed the correct number.

- before_count = 0
- for i in range(7):
-     ...
-     if _print_day_line(d, ...):
-         before_count += 1          # capped at 1 per day, regardless of
-                                     # how many workouts that day held
+ for i in range(7):
+     ...
+     _print_day_line(d, ...)        # display only now
+ before_count = count_weekly_workouts(log_file)   # the real anti-gaming count

before_count had been derived from _print_day_line's boolean return — one checkmark per day, regardless of how many workouts that day held. It happened to agree with the real count for years, because until this change no day ever legitimately held two verified entries. The fix makes the CLI compute the same weekly total the GUI already used, so the two views can no longer disagree.

4. Quiz

Five questions on the reasoning. Score: 0 / 0