mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
156 lines
5.3 KiB
Python
156 lines
5.3 KiB
Python
"""Mixin: RunnerUp root-DB pull path (fallback when no TCX exports found)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
import shutil
|
|
import sqlite3
|
|
import tempfile
|
|
import time
|
|
from typing import Any
|
|
|
|
from screen_locker._constants import (
|
|
RUNNERUP_DB_SDCARD_TMP,
|
|
RUNNERUP_PACKAGES,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RunnerUpDbMixin:
|
|
"""Mixin: root DB pull for RunnerUp workout verification.
|
|
|
|
Called as fallback when no TCX export files are found for today.
|
|
Relies on _adb_shell, _run_adb, and _validate_runnerup_data from MRO.
|
|
"""
|
|
|
|
def _find_runnerup_package(self) -> str | None:
|
|
"""Return the first installed RunnerUp package name, or None."""
|
|
for pkg in RUNNERUP_PACKAGES:
|
|
ok, out = self._adb_shell(f"pm list packages {pkg}")
|
|
if ok and pkg in out:
|
|
return pkg
|
|
return None
|
|
|
|
def _pull_runnerup_db(self) -> str | None:
|
|
"""Pull RunnerUp's SQLite DB from the device to a local temp file.
|
|
|
|
Copies the DB and WAL/SHM sidecar files via root shell to ``/sdcard``
|
|
(accessible without root by adb pull), then pulls them locally.
|
|
WAL files must travel with the main DB so that ``PRAGMA wal_checkpoint``
|
|
can merge in-flight writes.
|
|
|
|
Returns the local DB path on success, or ``None`` on any failure.
|
|
"""
|
|
pkg = self._find_runnerup_package()
|
|
if pkg is None:
|
|
_logger.info("RunnerUp not installed (tried %s)", RUNNERUP_PACKAGES)
|
|
return None
|
|
|
|
db_device = f"/data/data/{pkg}/databases/runnerup.db"
|
|
tmp_dir = tempfile.mkdtemp(prefix="runnerup_verify_")
|
|
local_db = str(Path(tmp_dir) / "runnerup.db")
|
|
|
|
ok, err = self._adb_shell(
|
|
f"cp {db_device} {RUNNERUP_DB_SDCARD_TMP}",
|
|
root=True,
|
|
)
|
|
if not ok:
|
|
_logger.info("Failed to copy RunnerUp DB to sdcard: %s", err)
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
return None
|
|
|
|
for suffix in ("-wal", "-shm"):
|
|
self._adb_shell(
|
|
f"test -f {db_device}{suffix} "
|
|
f"&& cp {db_device}{suffix} {RUNNERUP_DB_SDCARD_TMP}{suffix} "
|
|
f"|| true",
|
|
root=True,
|
|
)
|
|
|
|
ok, _ = self._run_adb(["pull", RUNNERUP_DB_SDCARD_TMP, local_db])
|
|
if not ok:
|
|
_logger.info("adb pull of RunnerUp DB failed")
|
|
self._cleanup_runnerup_sdcard()
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
return None
|
|
|
|
for suffix in ("-wal", "-shm"):
|
|
self._run_adb(
|
|
["pull", f"{RUNNERUP_DB_SDCARD_TMP}{suffix}", f"{local_db}{suffix}"]
|
|
)
|
|
|
|
self._cleanup_runnerup_sdcard()
|
|
return local_db
|
|
|
|
def _cleanup_runnerup_sdcard(self) -> None:
|
|
"""Remove temporary RunnerUp DB files from the sdcard."""
|
|
for suffix in ("", "-wal", "-shm"):
|
|
self._adb_shell(
|
|
f"test -f {RUNNERUP_DB_SDCARD_TMP}{suffix} "
|
|
f"&& rm {RUNNERUP_DB_SDCARD_TMP}{suffix} || true",
|
|
root=True,
|
|
)
|
|
|
|
def _query_todays_run(self, db_path: str) -> dict[str, Any] | None:
|
|
"""Query the pulled RunnerUp DB for today's most recent activity.
|
|
|
|
Runs ``PRAGMA wal_checkpoint`` first so that uncommitted WAL entries
|
|
are visible (important for runs just finished before connecting).
|
|
"""
|
|
local_midnight = time.mktime((*time.localtime()[:3], 0, 0, 0, 0, 0, -1))
|
|
local_end = local_midnight + 86400
|
|
|
|
try:
|
|
with sqlite3.connect(db_path, timeout=5) as conn:
|
|
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT start_time, distance, time, type
|
|
FROM activity
|
|
WHERE deleted = 0
|
|
AND start_time >= ?
|
|
AND start_time < ?
|
|
ORDER BY start_time DESC
|
|
LIMIT 1
|
|
""",
|
|
(local_midnight, local_end),
|
|
)
|
|
row = cursor.fetchone()
|
|
except sqlite3.Error as exc:
|
|
_logger.warning(
|
|
"RunnerUp activity query against %s failed: %s — treating it as "
|
|
"no run recorded today",
|
|
db_path,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
start_time, distance_m, duration_seconds, sport = row
|
|
return {
|
|
"start_time": int(start_time or 0),
|
|
"distance_m": float(distance_m or 0),
|
|
"duration_seconds": int(duration_seconds or 0),
|
|
"sport": int(sport or 0),
|
|
}
|
|
|
|
def _verify_runnerup_via_db(self) -> tuple[str, str]:
|
|
"""Verify today's run via root DB pull (fallback path)."""
|
|
db_path = self._pull_runnerup_db()
|
|
if db_path is None:
|
|
return "not_verified", "Could not retrieve RunnerUp database from phone"
|
|
|
|
try:
|
|
run_data = self._query_todays_run(db_path)
|
|
finally:
|
|
shutil.rmtree(Path(db_path).parent, ignore_errors=True)
|
|
|
|
if run_data is None:
|
|
return "not_verified", "No RunnerUp activity found for today"
|
|
|
|
return self._validate_runnerup_data(run_data)
|