screen-locker/screen_locker/_runnerup_verification.py
Krzysztof kuhy Rudnicki a2ce5049f0
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Qualify a run on EITHER duration or distance, not both
MIN_RUN_DURATION_MINUTES and MIN_RUN_DISTANCE_KM were an AND (30+ min AND
5.0+ km), which rejected a fast 5 km run that took under 30 minutes. They
are now an OR at 40 min / 3.5 km: a run qualifies at either threshold
alone. Distance keeps its 5% GPS tolerance; duration, from the device
clock, does not.

Fixed one stale test whose fixture (1 min / 6 km) predated the OR change:
6 km alone now qualifies under the new rule, so the fixture needed to fail
BOTH branches to still exercise the "no file verified, return first
result" path it was written for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:04:18 +02:00

311 lines
12 KiB
Python

"""RunnerUp run auto-verification via ADB (file-based path + shared validation).
File-based (no root, works over WiFi): reads per-activity TCX files that
RunnerUp's File Synchronizer writes to ``/sdcard/Documents/RunnerUp/``.
Root DB fallback lives in ``_runnerup_db.py``.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import logging
from pathlib import Path
import shutil
import tempfile
from typing import Any
import xml.etree.ElementTree as ET
from screen_locker._constants import (
MIN_RUN_DISTANCE_KM,
MIN_RUN_DURATION_MINUTES,
RUNNERUP_ACCEPTED_SPORTS,
RUNNERUP_DISTANCE_TOLERANCE,
RUNNERUP_EXPORT_DIRS,
)
from screen_locker._log_mixin import write_signed_entry
from screen_locker._runnerup_db import RunnerUpDbMixin
from screen_locker._time_check import check_clock_skew
_logger = logging.getLogger(__name__)
_SPORT_NAMES: dict[int, str] = {
0: "Running",
1: "Biking",
2: "Other",
3: "Orienteering",
4: "Walking",
5: "Treadmill",
6: "Gym",
7: "Stationary Bike",
}
# TCX uses sport name strings; map back to integer codes for unified validation.
_TCX_SPORT_TO_INT: dict[str, int] = {v: k for k, v in _SPORT_NAMES.items()}
# TCX XML namespace used by Garmin/RunnerUp.
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
class RunnerUpVerificationMixin(RunnerUpDbMixin):
"""Mixin providing RunnerUp-based workout verification via ADB."""
# ------------------------------------------------------------------
# File-based path (no root required)
# ------------------------------------------------------------------
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
"""Return adb paths of RunnerUp TCX exports for the given date, or empty list.
Args:
date_str: ISO date string in ``YYYY-MM-DD`` format matched against
TCX filenames (``RunnerUp_YYYY-MM-DD-HH-MM-SS_xxx.tcx``).
"""
found: list[str] = []
for dirpath in RUNNERUP_EXPORT_DIRS:
ok, out = self._run_adb(["shell", "ls", dirpath])
if not ok or not out.strip():
continue
for raw in out.strip().splitlines():
name = raw.strip()
if date_str in name and name.endswith(".tcx"):
remote = f"{dirpath}/{name}"
if remote not in found:
found.append(remote)
return found
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
local_path = str(Path(tmp_dir) / "activity.tcx")
try:
ok, _ = self._run_adb(["pull", remote_path, local_path])
if not ok or not Path(local_path).exists():
_logger.info("Failed to pull TCX file: %s", remote_path)
return None
return self._parse_tcx(local_path)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
"""Parse a local TCX file and return activity summary dict.
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
multi-segment runs (pause/resume) are counted in full.
"""
try:
tree = ET.parse(tcx_path)
except ET.ParseError as exc:
_logger.warning(
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
"be verified and will not count",
tcx_path,
exc,
)
return None
root = tree.getroot()
activity = root.find(f".//{{{_TCX_NS}}}Activity")
if activity is None:
_logger.info("No Activity element in TCX file")
return None
sport_str = activity.get("Sport", "")
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
total_seconds = 0.0
total_distance = 0.0
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
if t_elem is not None and t_elem.text:
total_seconds += float(t_elem.text)
if d_elem is not None and d_elem.text:
total_distance += float(d_elem.text)
return {
"sport": sport_int,
"duration_seconds": int(total_seconds),
"distance_m": total_distance,
}
def _verify_runnerup_via_files(self) -> tuple[str, str] | None:
"""Try to verify today's run via TCX export files.
Returns ``(status, message)`` if a today's file was found (even if it
fails validation), or ``None`` if no today's file exists at all
(caller should try the root DB path instead).
"""
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
exports = self._find_runnerup_exports_for_date(today)
if not exports:
_logger.warning(
"No RunnerUp TCX export found for %s in %s — a run today will "
"not be verified unless RunnerUp's File Synchronizer "
"auto-export is enabled and the app stays enabled long enough "
"to sync (check it isn't disabled by focus mode).",
today,
RUNNERUP_EXPORT_DIRS,
)
return None
# Try each file; return the best result (verified > validation error).
best: tuple[str, str] | None = None
for remote in exports:
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
status, msg = self._validate_runnerup_data(data)
if status == "verified":
return status, msg
if best is None:
best = (status, msg)
# All files found but none passed validation.
return best or (
"not_verified",
"RunnerUp TCX export found but could not be read",
)
def _try_fill_runnerup_for_date(self, date_str: str, log_file: Path) -> bool:
"""Append a verified RunnerUp entry for ``date_str`` if not already logged.
Appends via the write chokepoint, which dedups by ``workout_id``
(``runnerup_verified:{date}``): re-scanning a day whose run is already
recorded is a no-op, but a day that only holds, say, a manual workout
still gets the run appended alongside it (multiple workouts per day).
Returns True if a new verified entry was appended for ``date_str``.
"""
for remote in self._find_runnerup_exports_for_date(date_str):
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
status, msg = self._validate_runnerup_data(data)
if status != "verified":
_logger.warning(
"RunnerUp export %s for %s did not qualify (%s): %s",
remote,
date_str,
status,
msg,
)
continue
workout_data = {
"type": "runnerup_verified",
"source": f"Auto-scanned: {msg}",
"distance_km": round(data["distance_m"] / 1000, 2),
"duration_minutes": round(data["duration_seconds"] / 60, 1),
}
if write_signed_entry(log_file, date_str, workout_data).appended:
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
return True
return False
return False
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
"""Scan the current ISO week for RunnerUp runs and append any not logged.
Returns the count of newly appended entries (0 if phone not connected).
"""
if not self._has_adb_device():
_logger.info(
"Phone not connected; skipping auto-scan for past RunnerUp exports."
)
return 0
now = datetime.now(tz=timezone.utc).astimezone()
today = now.date()
week_start = today - timedelta(days=today.weekday())
filled = 0
current = week_start
while current <= today:
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), log_file):
filled += 1
current += timedelta(days=1)
return filled
# ------------------------------------------------------------------
# Shared validation
# ------------------------------------------------------------------
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
"""Validate a RunnerUp activity against configured thresholds.
Returns ``(status, message)`` following the same contract as
``PhoneVerificationMixin._verify_phone_workout``.
"""
sport = data["sport"]
if sport not in RUNNERUP_ACCEPTED_SPORTS:
sport_name = _SPORT_NAMES.get(sport, f"unknown({sport})")
return (
"wrong_sport",
f"Activity type '{sport_name}' doesn't count as a qualifying run",
)
duration_min = data["duration_seconds"] / 60
distance_km = data["distance_m"] / 1000
# Either criterion qualifies the run on its own (see _constants.py).
# Distance gets the GPS tolerance; duration, from the device clock,
# does not.
distance_ok = (
distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
)
duration_ok = duration_min > MIN_RUN_DURATION_MINUTES
if not distance_ok and not duration_ok:
msg = (
f"Run was {distance_km:.1f} km / {duration_min:.0f} min — need "
f"{MIN_RUN_DISTANCE_KM:g}+ km or {MIN_RUN_DURATION_MINUTES}+ min"
)
return "too_short", msg
if distance_ok and distance_km < MIN_RUN_DISTANCE_KM:
_logger.info(
"RunnerUp distance %.2f km is under the %.1f km minimum but "
"within %.0f%% GPS tolerance — accepted.",
distance_km,
MIN_RUN_DISTANCE_KM,
RUNNERUP_DISTANCE_TOLERANCE * 100,
)
sport_name = _SPORT_NAMES.get(sport, str(sport))
return (
"verified",
f"{sport_name}: {distance_km:.1f} km in {duration_min:.0f} min",
)
# ------------------------------------------------------------------
# Main entry point
# ------------------------------------------------------------------
def _verify_runnerup_workout(self) -> tuple[str, str]:
"""Verify today's run via RunnerUp.
Tries TCX file exports first (no root, works over WiFi); falls back to
root DB pull if no today's files are found.
Status values: ``verified | not_verified | no_phone | too_short |
wrong_sport | clock_tampered``.
"""
skew_ok, skew_msg = check_clock_skew()
if not skew_ok:
return "clock_tampered", skew_msg
if not self._has_adb_device():
return (
"no_phone",
"Phone not connected — plug in via USB or enable wireless ADB",
)
# Path 1: file-based (no root needed).
file_result = self._verify_runnerup_via_files()
if file_result is not None:
_logger.info("RunnerUp file-based result: %s", file_result[0])
return file_result
# Path 2: root DB pull (fallback when no export files found yet).
# On a non-rooted device this path cannot succeed; _verify_runnerup_via_db
# reports that plainly rather than as a generic failure.
return self._verify_runnerup_via_db()