mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:07:41 +01:00
Silent-failure checker, mypy, pylint now clean. Hidden packages count as installed (pm list -u), pull gets a long timeout, wifi entries are matched by ro.serialno, and a missing log/drop dir is a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
216 lines
7.9 KiB
Python
216 lines
7.9 KiB
Python
"""Retire the stock RunnerUp app a week after the PC first credits an app run.
|
|
|
|
kuhy's workout_app records runs itself now. Once the PC has credited one of
|
|
them, RunnerUp is redundant: seven days later (``GRACE``) it is backed up and
|
|
uninstalled, but only if the app has imported the RunnerUp history (a marker
|
|
the app writes) and that import matches what ``processed/`` holds. With no
|
|
anchor (no app run ever credited) it never uninstalls.
|
|
|
|
The phone is not always on USB, so ``runnerup-retire.timer`` retries daily
|
|
until this succeeds. Every outcome is a typed :class:`RetireResult` whose
|
|
reason is logged at the level it deserves: ``info`` for the normal "not due
|
|
yet", ``warning`` for a skip that needs attention, ``error`` for a failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
from datetime import UTC, datetime, timedelta
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker._runnerup_retire_adb import PACKAGE, AdbError
|
|
from screen_locker._runnerup_retire_result import LEVEL, Outcome, RetireResult
|
|
from screen_locker._runnerup_retire_state import load_state, save_state
|
|
from screen_locker._runnerup_retire_tcx import app_run_ends, check_marker, find_anchor
|
|
|
|
if TYPE_CHECKING:
|
|
from screen_locker._runnerup_retire_adb import PhoneLike
|
|
from screen_locker._runnerup_retire_state import RetireState
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
GRACE = timedelta(days=7)
|
|
STATE_FILE = Path.home() / ".local" / "state" / "screen-locker" / "runnerup_retire.json"
|
|
WATCH_DIR = Path.home() / "data" / "cloud" / "RunnerUp"
|
|
LOG_FILE = Path(__file__).resolve().parent / "log.json"
|
|
MARKER_NAME = ".workout_app_import.json"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Paths:
|
|
"""Every on-disk location, so tests (and ``--status``) can redirect them."""
|
|
|
|
state_file: Path
|
|
watch_dir: Path
|
|
log_file: Path
|
|
|
|
@classmethod
|
|
def default(cls) -> Paths:
|
|
"""The live locations (read at call time so tests can patch them)."""
|
|
return cls(STATE_FILE, WATCH_DIR, LOG_FILE)
|
|
|
|
@property
|
|
def processed(self) -> Path:
|
|
"""Where endurain-import files the RunnerUp exports it has handled."""
|
|
return self.watch_dir / "processed"
|
|
|
|
@property
|
|
def marker(self) -> Path:
|
|
"""The app's historic-import marker."""
|
|
return self.watch_dir / MARKER_NAME
|
|
|
|
def anchor_dirs(self) -> tuple[Path, Path]:
|
|
"""Where an app run's TCX can sit: the drop dir, then ``processed/``."""
|
|
return (self.watch_dir, self.processed)
|
|
|
|
|
|
def derive_anchor(paths: Paths, state: RetireState) -> datetime | None:
|
|
"""The stored anchor, else the first credit of an app run found on disk."""
|
|
return state.anchor or find_anchor(paths.log_file, paths.anchor_dirs())
|
|
|
|
|
|
def _finish(paths: Paths, state: RetireState, now: datetime, why: str) -> None:
|
|
save_state(
|
|
paths.state_file, replace(state, done_at=now.isoformat(), done_reason=why)
|
|
)
|
|
|
|
|
|
def _probe(phone: PhoneLike) -> RetireResult | tuple[str, bool]:
|
|
"""``(serial, installed)`` if the phone answers, else the result explaining why."""
|
|
if not phone.available():
|
|
return RetireResult(
|
|
Outcome.ADB_MISSING, "adb is not installed (pacman -S android-tools)"
|
|
)
|
|
try:
|
|
device = phone.find_device()
|
|
if device.serial is None:
|
|
return RetireResult(
|
|
Outcome.DEVICE_ABSENT, f"{device.reason}; retrying next run"
|
|
)
|
|
return device.serial, phone.installed(device.serial)
|
|
except AdbError as exc:
|
|
_logger.exception("RunnerUp retirement: asking the phone failed")
|
|
return RetireResult(
|
|
Outcome.PHONE_ERROR, f"asking the phone failed: {exc}", logged=True
|
|
)
|
|
|
|
|
|
def _backup(
|
|
paths: Paths, phone: PhoneLike, state: RetireState, serial: str, now: datetime
|
|
) -> RetireState | RetireResult:
|
|
"""Back the phone's exports up once; a later retry reuses a finished backup."""
|
|
if state.backup_path and Path(state.backup_path).is_dir():
|
|
return state
|
|
final = paths.watch_dir / f"runnerup_phone_backup_{now:%Y-%m-%d}"
|
|
try:
|
|
made, files = phone.backup(serial, final)
|
|
except (AdbError, OSError) as exc:
|
|
_logger.exception("RunnerUp retirement: backup failed")
|
|
return RetireResult(Outcome.BACKUP_FAILED, f"backup failed: {exc}", logged=True)
|
|
_logger.info("Backed up %d RunnerUp TCX files to %s", files, made)
|
|
state = replace(state, backup_path=str(made))
|
|
save_state(paths.state_file, state)
|
|
return state
|
|
|
|
|
|
def _retire(
|
|
paths: Paths, phone: PhoneLike, state: RetireState, now: datetime, *, dry_run: bool
|
|
) -> RetireResult:
|
|
"""Everything after "it is due and the marker is good": the phone steps."""
|
|
probed = _probe(phone)
|
|
if isinstance(probed, RetireResult):
|
|
return probed
|
|
serial, present = probed
|
|
if not present:
|
|
if not dry_run:
|
|
_finish(paths, state, now, f"{PACKAGE} was already absent")
|
|
return RetireResult(Outcome.ALREADY_GONE, f"{PACKAGE} is already absent")
|
|
if dry_run:
|
|
return RetireResult(
|
|
Outcome.DRY_RUN, "would back up and uninstall (no changes made)"
|
|
)
|
|
backed = _backup(paths, phone, state, serial, now)
|
|
if isinstance(backed, RetireResult):
|
|
return backed
|
|
try:
|
|
phone.uninstall(serial)
|
|
except AdbError as exc:
|
|
_logger.exception("RunnerUp retirement: uninstall failed")
|
|
return RetireResult(
|
|
Outcome.UNINSTALL_FAILED, f"uninstall failed: {exc}", logged=True
|
|
)
|
|
_finish(paths, backed, now, f"uninstalled after backup to {backed.backup_path}")
|
|
return RetireResult(
|
|
Outcome.UNINSTALLED,
|
|
f"{PACKAGE} uninstalled; backup in {backed.backup_path}",
|
|
)
|
|
|
|
|
|
def _no_anchor(paths: Paths) -> RetireResult:
|
|
"""Why there is no anchor; a missing source is a warning, not "nothing yet"."""
|
|
absent = [str(p) for p in (paths.watch_dir, paths.log_file) if not p.exists()]
|
|
if absent:
|
|
return RetireResult(
|
|
Outcome.SOURCES_MISSING,
|
|
f"cannot look for app runs, missing: {', '.join(absent)}",
|
|
)
|
|
seen = len(app_run_ends(paths.anchor_dirs()))
|
|
return RetireResult(
|
|
Outcome.NO_ANCHOR,
|
|
f"no app-recorded run credited yet ({seen} app TCX on disk); nothing to retire",
|
|
)
|
|
|
|
|
|
def _gate(
|
|
paths: Paths, state: RetireState, now: datetime, *, dry_run: bool
|
|
) -> RetireState | RetireResult:
|
|
"""The no-phone checks; a state back means "due, marker good: go on"."""
|
|
if state.done_at:
|
|
return RetireResult(
|
|
Outcome.ALREADY_DONE, f"done {state.done_at}: {state.done_reason}"
|
|
)
|
|
anchor = derive_anchor(paths, state)
|
|
if anchor is None:
|
|
return _no_anchor(paths)
|
|
if not state.anchor_at:
|
|
state = replace(state, anchor_at=anchor.isoformat())
|
|
if not dry_run:
|
|
save_state(paths.state_file, state)
|
|
due = anchor + GRACE
|
|
if now < due:
|
|
return RetireResult(
|
|
Outcome.NOT_DUE, f"due {due:%Y-%m-%d %H:%M} (anchor {anchor:%Y-%m-%d})"
|
|
)
|
|
marker = check_marker(paths.marker, paths.processed)
|
|
if not marker.ok:
|
|
return RetireResult(
|
|
Outcome.MARKER_INVALID, f"{marker.reason}; NOT uninstalling"
|
|
)
|
|
return state
|
|
|
|
|
|
def run_retire(
|
|
paths: Paths,
|
|
phone: PhoneLike,
|
|
now: datetime | None = None,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> RetireResult:
|
|
"""One pass: uninstall RunnerUp if (and only if) every condition holds."""
|
|
when = now or datetime.now(UTC)
|
|
gated = _gate(paths, load_state(paths.state_file), when, dry_run=dry_run)
|
|
if isinstance(gated, RetireResult):
|
|
result = gated
|
|
else:
|
|
result = _retire(paths, phone, gated, when, dry_run=dry_run)
|
|
if not result.logged:
|
|
_logger.log(
|
|
LEVEL.get(result.outcome, logging.INFO),
|
|
"RunnerUp retirement: %s: %s",
|
|
result.outcome.value,
|
|
result.reason,
|
|
)
|
|
return result
|