mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Real fixes: split over-long functions (lock-decision chain, manual sync, validation, phone verification, status), hoist a dead lazy import, context-manage Xvfb and temp dirs, add encodings, share the silent-failure argv loop. lock_grab_handoff read lock._recovery, which gatelock's LockWindow does not have (its public property is recovery); a contract test now checks every attribute the handoff reads against the real class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
228 lines
9.6 KiB
Python
228 lines
9.6 KiB
Python
"""Phone workout verification: Firebase sync first, ADB/HTTP as fallback."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from http import client as _http_client
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
from screen_locker._adb_transport import AdbTransportMixin
|
|
from screen_locker._constants import (
|
|
MIN_WORKOUT_DURATION_MINUTES,
|
|
WORKOUT_APP_JSON_REMOTES,
|
|
WORKOUT_DURATION_ACCEPT_MINUTES,
|
|
)
|
|
from screen_locker._day import today_str
|
|
from screen_locker._http_workout_fetch import HttpWorkoutFetchMixin
|
|
from screen_locker._log_mixin import write_signed_entry
|
|
from screen_locker._time_check import check_clock_skew
|
|
from screen_locker._workout_sync import pull_synced_workout
|
|
|
|
_HTTPConnection = _http_client.HTTPConnection
|
|
_HTTPException = _http_client.HTTPException
|
|
_HTTP_OK = _http_client.OK
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _in_current_week(date_str: str) -> bool:
|
|
"""True if *date_str* parses and falls in this ISO week up to today."""
|
|
today = datetime.now(tz=UTC).astimezone().date()
|
|
week_start = today - timedelta(days=today.weekday())
|
|
try:
|
|
entry_date = date.fromisoformat(date_str)
|
|
except ValueError:
|
|
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
|
|
return False
|
|
return week_start <= entry_date <= today
|
|
|
|
|
|
class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
|
"""Mixin providing phone-based workout verification via ADB."""
|
|
|
|
def _pull_workout_app_json(self) -> dict | None:
|
|
"""Pull workout_result.json from the phone (ADB, no root needed).
|
|
|
|
The app writes to one of several candidate paths (see sync_service.dart),
|
|
so we pull every candidate and prefer the one dated today. A stale file
|
|
can linger at a fallback path from a day the primary write failed, so
|
|
"first that parses" is not safe — we explicitly favour today's data and
|
|
only fall back to a stale/older payload if no candidate is from today.
|
|
"""
|
|
tmp = Path(tempfile.gettempdir()) / "workout_result.json"
|
|
today = today_str()
|
|
first_parsed: dict | None = None
|
|
for remote in WORKOUT_APP_JSON_REMOTES:
|
|
ok, _ = self._run_adb(["pull", remote, str(tmp)])
|
|
if not ok:
|
|
continue
|
|
try:
|
|
data = json.loads(tmp.read_text())
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
_logger.warning(
|
|
"Workout JSON pulled from %s is unreadable (%s) — skipping "
|
|
"that candidate and trying the remaining phone paths",
|
|
remote,
|
|
exc,
|
|
)
|
|
continue
|
|
if data.get("date") == today:
|
|
return data
|
|
if first_parsed is None:
|
|
first_parsed = data
|
|
return first_parsed
|
|
|
|
def _validate_json_content(self, data: dict) -> tuple[str, str]:
|
|
"""Validate a workout JSON's exercises/duration, regardless of its date.
|
|
|
|
Split out of :meth:`_validate_json_data` so the week-scan backfill
|
|
(``_try_fill_stronglifts_for_week``) can reuse the same content
|
|
checks against a past-in-week date without the today-only gate.
|
|
"""
|
|
if not data.get("exercises"):
|
|
return "no_exercises", "No exercises found in the workout JSON"
|
|
duration_min = data.get("duration_seconds", 0) / 60.0
|
|
# Accept bar carries the hidden leeway; the message advertises the
|
|
# round number only (see _constants.py).
|
|
if duration_min < WORKOUT_DURATION_ACCEPT_MINUTES:
|
|
return (
|
|
"too_short",
|
|
(
|
|
f"Workout too short! {duration_min:.0f} min logged, "
|
|
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min."
|
|
),
|
|
)
|
|
flag = "all succeeded" if data.get("succeeded") else "partial"
|
|
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
|
|
|
|
def _validate_json_data(self, data: dict) -> tuple[str, str]:
|
|
"""Validate parsed workout JSON. Returns (status, message).
|
|
|
|
Requires today's date — this is the live unlock-chain check, which
|
|
must reflect a workout done *right now*. For crediting a workout
|
|
found dated earlier in the week, see ``_try_fill_stronglifts_for_week``.
|
|
"""
|
|
today = today_str()
|
|
if data.get("date") != today:
|
|
return "stale", f"Workout JSON is from {data.get('date')}, not today"
|
|
return self._validate_json_content(data)
|
|
|
|
def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
|
|
"""Backfill a past-this-week StrongLifts workout found on the phone.
|
|
|
|
Unlike RunnerUp's per-activity TCX exports, the phone holds only one
|
|
``workout_result.json`` snapshot at a time, so this fills at most one
|
|
day per call — that mirrors :meth:`_scan_and_fill_week_runnerup`'s
|
|
contract (0 when nothing new to fill) without pretending there could
|
|
be more than one candidate. Still pulls and validates the real file
|
|
via ADB before writing anything — never trusts a claim.
|
|
|
|
Returns 1 if a new verified entry was appended, else 0 (also 0 when
|
|
no phone is connected, no JSON is available, or its date falls
|
|
outside the current ISO week).
|
|
"""
|
|
if not self._has_adb_device():
|
|
_logger.info(
|
|
"Phone not connected; skipping StrongLifts week-scan backfill."
|
|
)
|
|
return 0
|
|
data = self._pull_workout_app_json()
|
|
date_str = data.get("date") if data else None
|
|
if not date_str or not _in_current_week(date_str):
|
|
return 0
|
|
status, msg = self._validate_json_content(data)
|
|
if status != "verified":
|
|
return 0
|
|
workout_data = {
|
|
"type": "phone_verified",
|
|
"source": f"Auto-scanned: {msg}",
|
|
"duration_minutes": round(data.get("duration_seconds", 0) / 60.0, 1),
|
|
}
|
|
if write_signed_entry(log_file, date_str, workout_data).appended:
|
|
_logger.info("Auto-filled StrongLifts entry for %s: %s", date_str, msg)
|
|
return 1
|
|
return 0
|
|
|
|
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
|
|
|
def _pull_sync_result(self) -> tuple[tuple[str, str] | None, str | None]:
|
|
"""Firebase sync first: the validated result (if any) and sync error.
|
|
|
|
Firebase sync is the primary channel -- it works without the phone
|
|
being on the same network as the PC. Only a *verified* result
|
|
short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
|
phone's last push predates today, or a later push failed offline)
|
|
must not preempt a fresher ADB pull, so anything less than
|
|
"verified" is kept by the caller as a fallback-of-last-resort
|
|
candidate. A sync error never blocks unlocking either; `sync_failed`
|
|
only surfaces if ADB/HTTP also come up empty.
|
|
"""
|
|
synced_data, sync_error = pull_synced_workout()
|
|
if synced_data is None:
|
|
if sync_error is not None:
|
|
_logger.info(
|
|
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
|
)
|
|
return None, sync_error
|
|
sync_result = self._validate_json_data(synced_data)
|
|
if sync_result[0] != "verified":
|
|
_logger.info(
|
|
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
|
sync_result[0],
|
|
)
|
|
return sync_result, sync_error
|
|
|
|
def _verify_phone_workout(self) -> tuple[str, str]:
|
|
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
|
|
|
|
Returns (status, message). Status values:
|
|
verified / too_short / not_verified / no_phone / sync_failed /
|
|
stale / no_exercises / clock_tampered.
|
|
"""
|
|
clock_ok, clock_msg = check_clock_skew()
|
|
if not clock_ok:
|
|
return "clock_tampered", clock_msg
|
|
|
|
sync_result, sync_error = self._pull_sync_result()
|
|
if sync_result is not None and sync_result[0] == "verified":
|
|
return sync_result
|
|
|
|
# Prefer ADB when a device is visible, but if the pull yields no usable
|
|
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
|
|
# server may still hold today's workout even when the file pull fails.
|
|
adb_connected = self._is_phone_connected()
|
|
if adb_connected:
|
|
data = self._pull_workout_app_json()
|
|
if data is not None:
|
|
return self._validate_json_data(data)
|
|
_logger.info("ADB pull found no workout JSON — trying HTTP scan...")
|
|
else:
|
|
_logger.info("No ADB device — trying HTTP scan on local network...")
|
|
|
|
data = self._fetch_http_workout()
|
|
if data is not None:
|
|
return self._validate_json_data(data)
|
|
return _no_data_result(sync_result, sync_error, adb_connected=adb_connected)
|
|
|
|
|
|
def _no_data_result(
|
|
sync_result: tuple[str, str] | None,
|
|
sync_error: str | None,
|
|
*,
|
|
adb_connected: bool,
|
|
) -> tuple[str, str]:
|
|
"""The verdict when neither ADB nor HTTP produced a workout JSON."""
|
|
if sync_result is not None:
|
|
return sync_result
|
|
if sync_error is not None:
|
|
return "sync_failed", sync_error
|
|
if adb_connected:
|
|
return (
|
|
"not_verified",
|
|
"Workout app JSON not found. Complete a workout in the app first.",
|
|
)
|
|
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|