c277325 · 12 files · +583 / −78 · 2026-07-18
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:
workout_result.json off the phone.
_verify_phone_workout in
_phone_verification.py.
.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.
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.
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.
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:
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.
_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.
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.
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.
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.
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.
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.
Five questions on the reasoning. Score: 0 / 0