Merge main, use shared dufs login

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:15:15 +01:00
commit 97d73b31f5
71 changed files with 5353 additions and 225 deletions

View File

@ -48,6 +48,13 @@ so a run finished after 20:00 pushes the hour later without the phone being
plugged in; the per-minute shutdown check then lifts the night lockdown on its
own once the hour is past "now" (digital-wellbeing's check script).
**Retiring RunnerUp.** A week after the PC first credits a run recorded by the
workout app (TCX author `kuhy workout_app`), `runnerup-retire.timer` (daily, retries
until the phone is attached) backs `/sdcard/Documents/RunnerUp` up to
`~/data/cloud/RunnerUp/runnerup_phone_backup_<date>/` and uninstalls `org.runnerup.free`,
once the app's `.workout_app_import.json` matches `processed/`; no app run, no uninstall.
`python3 -m screen_locker.runnerup_retire --status` / `--dry-run` change nothing.
## MCP server (Claude Code integration)
screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so

View File

@ -17,6 +17,8 @@ BONUS_SERVICE_NAME="earner-bonus.service"
BONUS_PATH_NAME="earner-bonus.path"
RUNNERUP_WATCH_NAME="runnerup-watch.service"
WORKOUT_POKE_NAME="workout-poke.service"
RETIRE_SERVICE_NAME="runnerup-retire.service"
RETIRE_TIMER_NAME="runnerup-retire.timer"
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
# the tkinter MODULE ships inside the `python` package, but the shared library
@ -147,6 +149,12 @@ cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh"
cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME"
# The RunnerUp retirement: a week after the workout app's first credited run
# it backs the old app's exports up and uninstalls it from the phone. A daily
# timer, because the phone is not always attached; a no-op until then.
cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME"
cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME"
# Update paths in the service file to use absolute paths
REPO_ROOT="$SCRIPT_DIR"
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
@ -179,6 +187,9 @@ systemctl --user restart "$RUNNERUP_WATCH_NAME"
systemctl --user enable "$WORKOUT_POKE_NAME"
systemctl --user restart "$WORKOUT_POKE_NAME"
# Enable the daily RunnerUp retirement retry
systemctl --user enable --now "$RETIRE_TIMER_NAME"
# Verify enforcement is actually armed. enable can silently no-op when systemd
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
# on 2026-08-04 -- so a successful `enable` is NOT proof the locker will run.

View File

@ -17,6 +17,8 @@ UNITS=(
"earner-bonus.service"
"runnerup-watch.service"
"workout-poke.service"
"runnerup-retire.timer"
"runnerup-retire.service"
)
for unit in "${UNITS[@]}"; do

15
runnerup-retire.service Normal file
View File

@ -0,0 +1,15 @@
[Unit]
Description=Back up and uninstall the stock RunnerUp app a week after the workout app's first credited run
[Service]
Type=oneshot
# screen_locker is pip-installed (editable), like workout-sync.service, so no
# WorkingDirectory/PYTHONPATH. Headless: no DISPLAY, no Tk, no lock screen.
#
# Exit 0 for "not due yet", "no app run credited yet" and "phone not attached"
# (the timer retries), non-zero when a human is needed: invalid import marker,
# adb missing, a failed backup or a refused uninstall. That shows up in
# `systemctl --user --failed`. Every skip says why in the journal:
# journalctl --user -u runnerup-retire
# State and the anchor: python3 -m screen_locker.runnerup_retire --status
ExecStart=/usr/bin/python3 -m screen_locker.runnerup_retire

18
runnerup-retire.timer Normal file
View File

@ -0,0 +1,18 @@
[Unit]
Description=Retry the RunnerUp retirement daily until the phone has been cleaned up
# No After=graphical-session.target: timers.target is ordered before it, and
# that After= closes a cycle systemd resolves by deleting this timer's job
# (see early-bird-workout-check.timer for the 2026-08-04 incident).
[Timer]
# The phone is not always on USB, so a failed or skipped pass is simply tried
# again: shortly after boot and once a day. Most passes are an instant no-op
# (not due yet, or already done), so the timer stays installed.
OnBootSec=10min
OnCalendar=*-*-* 19:00:00
# Persistent=true: a PC that was off at 19:00 still gets that day's pass.
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,215 @@
"""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

View File

@ -0,0 +1,181 @@
"""The phone side of the RunnerUp retirement: find it, back it up, uninstall.
Every call carries ``-s <serial>`` and goes through one ``run`` seam, so tests
never touch a device. adb's exit code is not trusted for the uninstall: it
counts only when the output says ``Success`` AND ``pm list packages`` no
longer lists the package.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from pathlib import Path
import shutil
import subprocess
from typing import TYPE_CHECKING, Any, NoReturn, Protocol
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
PHONE_SERIAL = "23181JEGR08034"
PACKAGE = "org.runnerup.free"
REMOTE_DIR = "/sdcard/Documents/RunnerUp"
_ADB_TIMEOUT_S = 50
# The whole export history (TCX + GPX) over wifi adb; the oneshot unit has no
# start timeout, so a slow pull is allowed to finish.
_PULL_TIMEOUT_S = 900
class AdbError(RuntimeError):
"""An adb call failed; the message says what and why."""
def _fail(message: str) -> NoReturn:
raise AdbError(message)
@dataclass(frozen=True)
class Device:
"""Outcome of looking for the phone: the adb serial to use, or why not."""
serial: str | None
reason: str
class PhoneLike(Protocol):
"""What the retirement needs from a phone (tests substitute a fake)."""
def available(self) -> bool:
"""Whether adb can be run at all."""
def find_device(self) -> Device:
"""The attached phone, or why there is none."""
def installed(self, dev: str, *, hidden: bool = True) -> bool:
"""Whether RunnerUp is on the phone."""
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
"""Pull the exports to ``final``."""
def uninstall(self, dev: str) -> None:
"""Uninstall RunnerUp or raise ``AdbError``."""
class Phone:
"""adb calls against one phone, with the process runner injectable."""
def __init__(
self,
serial: str = PHONE_SERIAL,
*,
which: Callable[[str], str | None] = shutil.which,
run: Callable[..., Any] = subprocess.run,
) -> None:
"""Pin the phone's serial; ``which``/``run`` are the test seams."""
self.serial = serial
self._which = which
self._run = run
def available(self) -> bool:
"""Whether an ``adb`` binary is on PATH."""
return self._which("adb") is not None
def _adb(self, *args: str, timeout: int = _ADB_TIMEOUT_S) -> str:
cmd = ["adb", *args]
try:
done = self._run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
)
except (OSError, subprocess.TimeoutExpired) as exc:
msg = f"{' '.join(cmd)} did not run: {exc}"
raise AdbError(msg) from exc
if done.returncode:
detail = (done.stdout + done.stderr).strip()
_fail(f"{' '.join(cmd)} exited {done.returncode}: {detail}")
return str(done.stdout)
def _is_phone(self, serial: str) -> bool:
"""Whether an ``adb devices`` entry is the phone.
USB and mDNS serials embed the phone's serial; a plain ``ip:port``
wifi entry does not, so that one is asked for its ``ro.serialno``.
"""
if self.serial in serial:
return True
if ":" not in serial:
return False
try:
asked = self._adb("-s", serial, "shell", "getprop", "ro.serialno")
except AdbError as exc:
_logger.warning("Could not read the serial of %s (%s)", serial, exc)
return False
return asked.strip() == self.serial
def find_device(self) -> Device:
"""The attached phone, over USB or wifi (a wireless serial embeds it)."""
listing = self._adb("devices")
states: list[str] = []
for line in listing.splitlines()[1:]:
serial, _, state = line.partition("\t")
if not self._is_phone(serial):
continue
if state.strip() == "device":
return Device(serial, "attached")
states.append(state.strip())
if states:
return Device(None, f"phone is {'/'.join(states)}, not ready")
return Device(None, f"phone {self.serial} is not attached")
def installed(self, dev: str, *, hidden: bool = True) -> bool:
"""Whether the package is on the phone (exact ``package:`` line).
``hidden`` adds ``-u`` so an app that focus mode hides or disables
still counts as present; it must not be mistaken for an uninstall.
"""
flag = ["-u"] if hidden else []
out = self._adb("-s", dev, "shell", "pm", "list", "packages", *flag, PACKAGE)
return f"package:{PACKAGE}" in out.split()
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
"""Pull the phone's export folder next to ``final``; return (dir, tcx count).
Pulled into a staging dir and renamed only once every phone ``.tcx``
is on disk, so a half backup never looks like a finished one. The
result is a subfolder, never top-level: a top-level ``*.tcx`` would be
credited by runnerup-watch.
"""
listing = self._adb("-s", dev, "shell", "ls", REMOTE_DIR)
wanted = sum(1 for n in listing.split() if n.endswith(".tcx"))
staging = final.with_name(final.name + ".partial")
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
self._adb("-s", dev, "pull", REMOTE_DIR, str(staging), timeout=_PULL_TIMEOUT_S)
pulled = staging / Path(REMOTE_DIR).name
got = len(list(pulled.rglob("*.tcx"))) if pulled.is_dir() else -1
if got < wanted:
_fail(f"backup pulled {got} of {wanted} TCX files into {staging}")
target, n = final, 1
while target.exists():
n += 1
target = final.with_name(f"{final.name}-{n}")
pulled.rename(target)
staging.rmdir()
return target, got
def uninstall(self, dev: str) -> None:
"""Uninstall the package and prove it is gone, or raise ``AdbError``."""
try:
out = self._adb("-s", dev, "uninstall", PACKAGE)
except AdbError as exc:
# adb exits 1 with "Failure [DELETE_FAILED_...]": keep that reason
# (a device-owner policy can block it) in the error.
msg = f"uninstall refused: {exc}"
raise AdbError(msg) from exc
if "Success" not in out:
_fail(f"uninstall did not report Success: {out.strip()!r}")
if self.installed(dev, hidden=False):
_fail(f"{PACKAGE} is still listed after uninstall")
_logger.info("Uninstalled %s from %s", PACKAGE, dev)

View File

@ -0,0 +1,62 @@
"""Outcomes of one RunnerUp-retirement pass, and how loudly each is logged."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import logging
__all__ = ["LEVEL", "Outcome", "RetireResult"]
class Outcome(StrEnum):
"""How a run ended; the value is what the journal shows."""
UNINSTALLED = "uninstalled"
ALREADY_GONE = "already-gone"
ALREADY_DONE = "already-done"
NO_ANCHOR = "no-anchor"
SOURCES_MISSING = "sources-missing"
NOT_DUE = "not-due"
DRY_RUN = "dry-run"
DEVICE_ABSENT = "device-absent"
MARKER_INVALID = "marker-invalid"
ADB_MISSING = "adb-missing"
PHONE_ERROR = "phone-error"
BACKUP_FAILED = "backup-failed"
UNINSTALL_FAILED = "uninstall-failed"
# Level per outcome; the failures also make the unit exit non-zero.
LEVEL = {
Outcome.DEVICE_ABSENT: logging.WARNING,
Outcome.SOURCES_MISSING: logging.WARNING,
Outcome.MARKER_INVALID: logging.WARNING,
Outcome.ADB_MISSING: logging.ERROR,
Outcome.PHONE_ERROR: logging.ERROR,
Outcome.BACKUP_FAILED: logging.ERROR,
Outcome.UNINSTALL_FAILED: logging.ERROR,
}
_NONZERO = frozenset(
{
Outcome.MARKER_INVALID,
Outcome.ADB_MISSING,
Outcome.PHONE_ERROR,
Outcome.BACKUP_FAILED,
Outcome.UNINSTALL_FAILED,
}
)
@dataclass(frozen=True)
class RetireResult:
"""What happened and why, in a sentence a human can act on."""
outcome: Outcome
reason: str
logged: bool = False # the failure was already logged where it was caught
@property
def ok(self) -> bool:
"""False for outcomes the unit should surface as failed."""
return self.outcome not in _NONZERO

View File

@ -0,0 +1,71 @@
"""The retirement's own memory: the anchor, the backup, the done stamp.
One small JSON file (path: ``_runnerup_retire.STATE_FILE``). Written
atomically and only by a real run (``--status`` and ``--dry-run`` never write),
so the file is also the proof that a run happened. The anchor is stored once
and never moved: a later, earlier-looking credit must not push the deadline.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from datetime import datetime
import json
import logging
import os
from pathlib import Path
import tempfile
_logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RetireState:
"""What a previous run recorded (all ISO strings, empty until known)."""
anchor_at: str = ""
backup_path: str = ""
done_at: str = ""
done_reason: str = ""
@property
def anchor(self) -> datetime | None:
"""The stored anchor as an aware datetime, or None."""
return datetime.fromisoformat(self.anchor_at) if self.anchor_at else None
def load_state(path: Path) -> RetireState:
"""Read the state; a missing file is a fresh start, a bad one is loud."""
if not path.exists():
return RetireState()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning(
"Retire state %s is unreadable (%s): starting from scratch, so the "
"anchor is re-derived and the uninstall re-checked",
path,
exc,
)
return RetireState()
if not isinstance(raw, dict):
_logger.warning("Retire state %s is not a JSON object: ignored", path)
return RetireState()
known = {f.name for f in fields(RetireState)}
clean = {k: v for k, v in raw.items() if k in known and isinstance(v, str)}
try:
state = RetireState(**clean)
_ = state.anchor
except ValueError as exc:
_logger.warning("Retire state %s holds a bad anchor (%s): ignored", path, exc)
return RetireState()
return state
def save_state(path: Path, state: RetireState) -> None:
"""Atomically replace the state file (creates the directory on first write)."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".retire-", suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(asdict(state), handle, indent=2)
Path(tmp).replace(path)

View File

@ -0,0 +1,210 @@
"""What the RunnerUp retirement reads off disk: app runs, the marker, the anchor.
Split from :mod:`screen_locker._runnerup_retire` (250-line cap). Everything
here is read-only; nothing touches the phone.
Telling the two sources apart is done on the TCX *content* (``<Author>`` or
``<Creator>`` named ``kuhy workout_app``, the app's ``kRunSourceName``), never
on the file name: the app may name its files however it likes.
The anchor is "when the PC first CREDITED an app run". The credit chokepoint
(``log.json``) stores a run's TCX end as ``completed_at`` on its
``runnerup_verified`` entry and stamps the entry itself with the moment the PC
wrote it. So the anchor is the ``timestamp`` of the entry whose
``completed_at`` equals an app TCX's last trackpoint. Joining on the end time
(not the day) is what makes a same-day RunnerUp run, which fills the day's one
credit slot first, correctly yield no anchor for the app run.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
import logging
import re
from typing import TYPE_CHECKING, Any
from screen_locker._log_io import load_workout_log
from screen_locker._runnerup_tcx import parse_tcx_file
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
_logger = logging.getLogger(__name__)
APP_SOURCE_NAME = "kuhy workout_app"
_APP_RE = re.compile(
rb"<(?:Author|Creator)\b[^>]*>\s*<Name>\s*"
+ re.escape(APP_SOURCE_NAME.encode())
+ rb"\s*</Name>"
)
# The WebDAV copy of a RunnerUp export may carry the device name
# (RunnerUp_Pixel_6a_2026-...) that the phone's own copy lacks.
_DEVICE_PREFIX = re.compile(r"^RunnerUp_(?:[A-Za-z0-9]+_)*?(?=\d{4}-\d{2}-\d{2}-)")
_END_TOLERANCE_S = 1.0
def run_key(name: str) -> str:
"""A run's identity: its file name without any device-name prefix."""
return _DEVICE_PREFIX.sub("RunnerUp_", name)
def list_tcx(directory: Path) -> list[Path]:
"""Finished ``*.tcx`` files directly in ``directory`` (dotfiles are partial)."""
if not directory.is_dir():
return []
return sorted(
p for p in directory.glob("*.tcx") if p.is_file() and not p.name.startswith(".")
)
def is_app_tcx(path: Path) -> bool:
"""Whether the TCX names the new app as its author or creator."""
try:
data = path.read_bytes()
except OSError as exc:
_logger.warning(
"Cannot read %s (%s): counting it as a RunnerUp file", path, exc
)
return False
return bool(_APP_RE.search(data))
def _ended_at(path: Path) -> float | None:
"""The run's end exactly as the credit path computed it (same parser)."""
data = parse_tcx_file(str(path))
if data is None or data.get("ended_at") is None:
_logger.warning("App TCX %s has no usable end time: it cannot anchor", path)
return None
return float(data["ended_at"])
def _credited_at(entry: dict[str, Any]) -> datetime | None:
try:
stamp = datetime.fromisoformat(str(entry["timestamp"]))
except (KeyError, ValueError) as exc:
_logger.warning("Credit entry has no usable timestamp (%r): skipped", exc)
return None
return stamp if stamp.tzinfo else stamp.astimezone()
def _as_unix(raw: object) -> float | None:
"""A unix time from a number or numeric string (the log stores both)."""
if isinstance(raw, int | float) and not isinstance(raw, bool):
return float(raw)
if isinstance(raw, str) and re.fullmatch(r"\d+(\.\d+)?", raw):
return float(raw)
return None
def _credit_ends(log_file: Path) -> list[tuple[float, datetime]]:
"""``(TCX end, PC credit time)`` of every credited run that recorded its end."""
found: list[tuple[float, datetime]] = []
for entries in load_workout_log(log_file).values():
for entry in entries:
data = entry.get("workout_data") or {}
if data.get("type") != "runnerup_verified":
continue
end = _as_unix(data.get("completed_at"))
if end is None:
continue # an older entry that never stored its run's end
when = _credited_at(entry)
if when is not None:
found.append((end, when))
return found
def app_run_ends(dirs: Iterable[Path]) -> list[float]:
"""The end time of every app-authored TCX that has a usable one."""
return [
end
for directory in dirs
for path in list_tcx(directory)
if is_app_tcx(path) and (end := _ended_at(path)) is not None
]
def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None:
"""When the PC first credited an app-recorded run, or None if it never did."""
ends = app_run_ends(dirs)
credited = [
when
for run_end, when in _credit_ends(log_file)
if any(abs(run_end - end) <= _END_TOLERANCE_S for end in ends)
]
return min(credited) if credited else None
@dataclass(frozen=True)
class MarkerCheck:
"""Verdict on the app's historic-import marker."""
ok: bool
reason: str
def _bad(reason: str) -> MarkerCheck:
return MarkerCheck(ok=False, reason=reason)
def _marker_names(marker: object) -> tuple[list[str] | None, str]:
"""The marker's ``tcx_names`` if its shape is valid, else a reason."""
if not isinstance(marker, dict):
return None, "marker is not a JSON object"
names, count = marker.get("tcx_names"), marker.get("count")
if not isinstance(marker.get("completed_at"), str) or not marker["completed_at"]:
return None, "marker has no completed_at"
if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
return None, "marker tcx_names is not a list of names"
if isinstance(count, bool) or not isinstance(count, int) or count != len(names):
return None, f"marker count {count!r} != {len(names)} listed names"
if any("/" in n or n.startswith(".") for n in names):
return None, "marker tcx_names must be bare file names"
return names, ""
def _read_marker(marker_file: Path) -> tuple[object, str]:
"""The parsed marker JSON, or ``(None, why it could not be read)``."""
if not marker_file.exists():
why = f"{marker_file} is missing: the app has not imported yet"
else:
try:
return json.loads(marker_file.read_text(encoding="utf-8")), ""
except (OSError, ValueError) as exc:
why = f"{marker_file} is unreadable ({exc})"
_logger.warning("Import marker problem: %s", why)
return None, why
_logger.warning("Import marker problem: %s", why)
return None, why
def _compare(names: list[str], processed: Path) -> MarkerCheck:
listed = {run_key(n) for n in names}
if len(listed) != len(names):
return _bad("marker lists the same run more than once")
have = {run_key(p.name) for p in list_tcx(processed) if not is_app_tcx(p)}
if absent := sorted(listed - have):
return _bad(f"{len(absent)} listed run(s) not in processed/: {absent[:3]}")
if unlisted := sorted(have - listed):
return _bad(
f"{len(unlisted)} RunnerUp run(s) in processed/ are not in the "
f"marker ({unlisted[:3]}): re-run the app's import"
)
return MarkerCheck(ok=True, reason=f"{len(listed)} runs match")
def check_marker(marker_file: Path, processed: Path) -> MarkerCheck:
"""Validate the app's import marker against what ``processed/`` really holds.
Valid means: the file parses, ``count`` equals the number of listed names,
and the listed runs are exactly the RunnerUp-recorded (non-app) runs in
``processed/``. A run is counted once however many copies exist (the
device-prefixed WebDAV twin and the phone-named copy are one run).
"""
marker, why = _read_marker(marker_file)
if why:
return _bad(why)
names, reason = _marker_names(marker)
return _compare(names, processed) if names is not None else _bad(reason)

View File

@ -65,48 +65,53 @@ class RunnerUpTcxMixin:
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.
"""Parse a local TCX file (see :func:`parse_tcx_file`)."""
return parse_tcx_file(tcx_path)
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
def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None:
"""Parse a local TCX file and return activity summary dict.
sport_str = activity.get("Sport", "")
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
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
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)
root = tree.getroot()
activity = root.find(f".//{{{_TCX_NS}}}Activity")
if activity is None:
_logger.info("No Activity element in TCX file")
return None
return {
"sport": sport_int,
"duration_seconds": int(total_seconds),
"distance_m": total_distance,
"walking": is_walking_export(Path(tcx_path).name, sport_str),
**track_summary(activity),
}
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,
"walking": is_walking_export(Path(tcx_path).name, sport_str),
**track_summary(activity),
}
def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None:

View File

@ -0,0 +1,99 @@
"""``python3 -m screen_locker.runnerup_retire``: uninstall RunnerUp, a week on.
What ``runnerup-retire.service`` runs. ``--status`` prints anchor, due date,
marker validity and (with ``--query-phone``) whether RunnerUp is installed;
it never writes and never talks to the phone unless asked. ``--dry-run`` runs
every check, including the read-only adb queries, but writes nothing, pulls
nothing and uninstalls nothing. The exit code is non-zero only for outcomes
that need a human (bad marker, adb missing, failed backup or uninstall).
"""
from __future__ import annotations
import argparse
from datetime import UTC, datetime
import logging
import sys
from typing import TYPE_CHECKING
from screen_locker._runnerup_retire import (
GRACE,
Paths,
derive_anchor,
run_retire,
)
from screen_locker._runnerup_retire_adb import PHONE_SERIAL, AdbError, Phone
from screen_locker._runnerup_retire_state import load_state
from screen_locker._runnerup_retire_tcx import check_marker
if TYPE_CHECKING:
from screen_locker._runnerup_retire_adb import PhoneLike
__all__ = ["main", "status_lines"]
_logger = logging.getLogger(__name__)
def _installed_line(phone: PhoneLike, *, query: bool) -> str:
if not query:
return "installed: not queried (add --query-phone)"
try:
device = phone.find_device()
if device.serial is None:
return f"installed: unknown ({device.reason})"
return f"installed: {'yes' if phone.installed(device.serial) else 'no'}"
except AdbError as exc:
_logger.warning("--status could not ask the phone: %s", exc)
return f"installed: unknown ({exc})"
def status_lines(
paths: Paths, phone: PhoneLike, now: datetime, *, query_phone: bool = False
) -> list[str]:
"""The ``--status`` report. Read-only: nothing is created or saved."""
state = load_state(paths.state_file)
anchor = derive_anchor(paths, state)
if anchor is None:
anchor_line, due_line = "anchor: none (no app run credited yet)", "due: never"
else:
origin = "stored" if state.anchor_at else "derived, not yet stored"
due = anchor + GRACE
days = (due - now).total_seconds() / 86400
anchor_line = f"anchor: {anchor.isoformat()} ({origin})"
due_line = f"due: {due:%Y-%m-%d %H:%M} ({days:+.1f} days)"
marker = check_marker(paths.marker, paths.processed)
return [
anchor_line,
due_line,
f"marker: {'valid' if marker.ok else 'INVALID'} ({marker.reason})",
f"done: {state.done_at or 'no'}",
_installed_line(phone, query=query_phone),
]
def main(argv: list[str] | None = None) -> int:
"""CLI entry; returns the process exit code."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0])
parser.add_argument(
"--status", action="store_true", help="print state, change nothing"
)
parser.add_argument(
"--dry-run", action="store_true", help="check everything, change nothing"
)
parser.add_argument(
"--query-phone", action="store_true", help="with --status: ask adb"
)
parser.add_argument("--serial", default=PHONE_SERIAL, help="phone adb serial")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
paths, phone = Paths.default(), Phone(args.serial)
if args.status:
now = datetime.now(UTC)
lines = status_lines(paths, phone, now, query_phone=args.query_phone)
sys.stdout.write("\n".join(lines) + "\n")
return 0
return 0 if run_retire(paths, phone, dry_run=args.dry_run).ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -105,4 +105,20 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
"workday_penalty.json",
("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"),
),
# RunnerUp retirement: the anchor/done stamp, the dufs drop directory it
# reads the marker and TCX files from (and writes the phone backup into),
# and the credit log the anchor is derived from. A test must never see, or
# create files under, the user's real RunnerUp folder.
(
"runnerup_retire.json",
("_runnerup_retire.STATE_FILE",),
),
(
"RunnerUp",
("_runnerup_retire.WATCH_DIR",),
),
(
"retire_log.json",
("_runnerup_retire.LOG_FILE",),
),
)

View File

@ -0,0 +1,137 @@
"""Builders for the RunnerUp retirement tests: TCX files, marker, credit log."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import json
from typing import TYPE_CHECKING, Any
from screen_locker._runnerup_retire import Paths
from screen_locker._runnerup_retire_adb import AdbError, Device
if TYPE_CHECKING:
from pathlib import Path
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
APP = "kuhy workout_app"
# 2026-11-01T10:30:00Z, the end of every TCX built by default.
END_UNIX = 1793529000.0
def tcx_text(
*, author: str = "RunnerUp", creator: str = "", end: str = "2026-11-01T10:30:00Z"
) -> str:
"""A tiny TCX: two trackpoints (the last is the run's end) and an author."""
creator_xml = f"<Creator><Name>{creator}</Name></Creator>" if creator else ""
return (
f'<?xml version="1.0"?><TrainingCenterDatabase xmlns="{_NS}"><Activities>'
'<Activity Sport="Running"><Id>2026-11-01T10:00:00Z</Id>'
"<Lap><TotalTimeSeconds>1800</TotalTimeSeconds>"
"<DistanceMeters>5000</DistanceMeters><Track>"
"<Trackpoint><Time>2026-11-01T10:00:00Z</Time>"
"<DistanceMeters>0</DistanceMeters></Trackpoint>"
f"<Trackpoint><Time>{end}</Time><DistanceMeters>5000</DistanceMeters>"
f"</Trackpoint></Track></Lap>{creator_xml}</Activity></Activities>"
f'<Author xsi:type="Application_t" xmlns:xsi="x"><Name>{author}</Name>'
"</Author></TrainingCenterDatabase>"
)
def put_tcx(folder: Path, name: str, **kwargs: str) -> Path:
"""Write a TCX named ``name`` into ``folder`` (created if needed)."""
folder.mkdir(parents=True, exist_ok=True)
path = folder / name
path.write_text(tcx_text(**kwargs), encoding="utf-8")
return path
def put_marker(watch: Path, names: list[str], count: int | None = None) -> Path:
"""Write the app's import marker into the drop folder."""
watch.mkdir(parents=True, exist_ok=True)
marker = watch / ".workout_app_import.json"
body = {
"completed_at": "2026-11-02T08:00:00Z",
"count": len(names) if count is None else count,
"tcx_names": names,
}
marker.write_text(json.dumps(body), encoding="utf-8")
return marker
def put_log(log_file: Path, entries: dict[str, list[dict[str, Any]]]) -> None:
"""Write a day-keyed credit log."""
log_file.write_text(json.dumps(entries), encoding="utf-8")
def credit(
timestamp: str, completed_at: object = None, kind: str = "runnerup_verified"
) -> dict[str, Any]:
"""One log entry as the credit chokepoint writes it."""
data: dict[str, Any] = {"type": kind}
if completed_at is not None:
data["completed_at"] = completed_at
return {"timestamp": timestamp, "workout_data": data}
ANCHOR = datetime(2026, 11, 1, 10, 45, tzinfo=UTC)
DUE = ANCHOR + timedelta(days=7)
RUN = "RunnerUp_2026-08-12-17-42-11_Running.tcx"
@dataclass
class FakePhone:
"""Records what the retirement asked of the phone."""
adb: bool = True
device: Device = field(default_factory=lambda: Device("S", "attached"))
present: bool = True
ask_error: AdbError | None = None
backup_error: Exception | None = None
uninstall_error: AdbError | None = None
calls: list[str] = field(default_factory=list)
def available(self) -> bool:
"""Available."""
return self.adb
def find_device(self) -> Device:
"""Find device."""
if self.ask_error:
raise self.ask_error
return self.device
def installed(self, dev: str, *, hidden: bool = True) -> bool:
"""Installed."""
self.calls.append(f"installed {dev}")
return self.present
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
"""Backup."""
self.calls.append("backup")
if self.backup_error:
raise self.backup_error
final.mkdir(parents=True)
return final, 3
def uninstall(self, dev: str) -> None:
"""Uninstall."""
self.calls.append("uninstall")
if self.uninstall_error:
raise self.uninstall_error
def world(tmp_path: Path, *, anchored: bool = True, marker: bool = True) -> Paths:
"""A drop folder with one RunnerUp run, one app run, and the credit log."""
paths = Paths(
tmp_path / "state" / "s.json", tmp_path / "drop", tmp_path / "log.json"
)
put_tcx(paths.processed, RUN)
put_tcx(paths.processed, "app.tcx", author=APP)
put_log(
paths.log_file,
{"d": [credit(ANCHOR.isoformat(), END_UNIX)]} if anchored else {},
)
if marker:
put_marker(paths.watch_dir, [RUN])
return paths

View File

@ -0,0 +1,216 @@
"""RunnerUp retirement: when it fires, what it refuses, and what it records."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING
import pytest
from screen_locker import _runnerup_retire as rr
from screen_locker._runnerup_retire import Outcome, Paths, run_retire
from screen_locker._runnerup_retire_adb import AdbError, Device
from screen_locker._runnerup_retire_state import RetireState, load_state, save_state
from screen_locker.tests._retire_helpers import (
ANCHOR,
DUE,
RUN,
FakePhone,
put_marker,
world,
)
if TYPE_CHECKING:
from pathlib import Path
def go(paths: Paths, phone: FakePhone, now: datetime = DUE, *, dry_run: bool = False):
"""Go."""
return run_retire(paths, phone, now, dry_run=dry_run)
def test_no_anchor_never_touches_anything(tmp_path: Path) -> None:
"""Test no anchor never touches anything."""
paths = world(tmp_path, anchored=False)
phone = FakePhone()
result = go(paths, phone, DUE + timedelta(days=900))
assert result.outcome is Outcome.NO_ANCHOR
assert result.ok
assert not phone.calls
assert not paths.state_file.exists()
def test_missing_sources_are_a_warning_not_a_quiet_nothing(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test missing sources are a warning not a quiet nothing."""
paths = Paths(tmp_path / "s.json", tmp_path / "nodrop", tmp_path / "nolog.json")
with caplog.at_level(logging.INFO):
result = go(paths, FakePhone())
assert result.outcome is Outcome.SOURCES_MISSING
assert result.ok
assert "nodrop" in result.reason
assert "nolog.json" in result.reason
assert caplog.records[-1].levelno == logging.WARNING
def test_no_anchor_says_how_many_app_runs_it_saw(tmp_path: Path) -> None:
"""Test no anchor says how many app runs it saw."""
paths = world(tmp_path, anchored=False)
assert "(1 app TCX on disk)" in go(paths, FakePhone()).reason
def test_not_due_persists_the_anchor_and_says_when(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test not due persists the anchor and says when."""
paths = world(tmp_path)
with caplog.at_level(logging.INFO):
result = go(paths, FakePhone(), DUE - timedelta(minutes=1))
assert result.outcome is Outcome.NOT_DUE
assert "2026-11-08 10:45" in result.reason
assert load_state(paths.state_file).anchor == ANCHOR
assert [r.levelno for r in caplog.records] == [logging.INFO]
def test_stored_anchor_wins_over_the_log(tmp_path: Path) -> None:
"""Test stored anchor wins over the log."""
paths = world(tmp_path, anchored=False)
save_state(paths.state_file, RetireState(anchor_at=ANCHOR.isoformat()))
assert go(paths, FakePhone(), ANCHOR).outcome is Outcome.NOT_DUE
def test_dry_run_writes_nothing_and_stops_before_the_phone_changes(
tmp_path: Path,
) -> None:
"""Test dry run writes nothing and stops before the phone changes."""
paths = world(tmp_path)
phone = FakePhone()
result = go(paths, phone, dry_run=True)
assert result.outcome is Outcome.DRY_RUN
assert phone.calls == ["installed S"]
assert not paths.state_file.exists()
gone = go(paths, FakePhone(present=False), dry_run=True)
assert gone.outcome is Outcome.ALREADY_GONE
assert not paths.state_file.exists()
@pytest.mark.parametrize("variant", ["missing", "mismatch"])
def test_invalid_marker_blocks_the_uninstall_loudly(
tmp_path: Path, caplog: pytest.LogCaptureFixture, variant: str
) -> None:
"""Test invalid marker blocks the uninstall loudly."""
paths = world(tmp_path, marker=variant == "mismatch")
if variant == "mismatch":
put_marker(paths.watch_dir, [RUN], count=2)
phone = FakePhone()
with caplog.at_level(logging.WARNING):
result = go(paths, phone)
assert result.outcome is Outcome.MARKER_INVALID
assert not result.ok
assert "NOT uninstalling" in result.reason
assert not phone.calls
assert caplog.records[-1].levelno == logging.WARNING
def test_adb_missing_is_a_failure(tmp_path: Path) -> None:
"""Test adb missing is a failure."""
result = go(world(tmp_path), FakePhone(adb=False))
assert result.outcome is Outcome.ADB_MISSING
assert not result.ok
def test_absent_phone_retries_quietly_but_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test absent phone retries quietly but warns."""
phone = FakePhone(device=Device(None, "phone S is not attached"))
with caplog.at_level(logging.WARNING):
result = go(world(tmp_path), phone)
assert result.outcome is Outcome.DEVICE_ABSENT
assert result.ok
assert "not attached" in caplog.text
assert not phone.calls
def test_adb_error_while_asking_is_a_logged_failure(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test adb error while asking is a logged failure."""
phone = FakePhone(ask_error=AdbError("server died"))
with caplog.at_level(logging.ERROR):
result = go(world(tmp_path), phone)
assert result.outcome is Outcome.PHONE_ERROR
assert not result.ok
assert len(caplog.records) == 1 # logged once, where it was caught
def test_already_uninstalled_is_recorded_done(tmp_path: Path) -> None:
"""Test already uninstalled is recorded done."""
paths = world(tmp_path)
result = go(paths, FakePhone(present=False))
assert result.outcome is Outcome.ALREADY_GONE
assert load_state(paths.state_file).done_at
assert go(paths, FakePhone()).outcome is Outcome.ALREADY_DONE
def test_full_run_backs_up_then_uninstalls_then_never_again(tmp_path: Path) -> None:
"""Test full run backs up then uninstalls then never again."""
paths = world(tmp_path)
phone = FakePhone()
result = go(paths, phone)
assert result.outcome is Outcome.UNINSTALLED
assert phone.calls == ["installed S", "backup", "uninstall"]
backup = paths.watch_dir / "runnerup_phone_backup_2026-11-08"
assert backup.is_dir()
assert not list(paths.watch_dir.glob("*.tcx")) # nothing top-level
state = load_state(paths.state_file)
assert state.anchor == ANCHOR
assert state.backup_path == str(backup)
assert state.done_at
again = FakePhone()
assert go(paths, again).outcome is Outcome.ALREADY_DONE
assert not again.calls
@pytest.mark.parametrize("error", [AdbError("short pull"), OSError("disk full")])
def test_failed_backup_blocks_the_uninstall(
tmp_path: Path, caplog: pytest.LogCaptureFixture, error: Exception
) -> None:
"""Test failed backup blocks the uninstall."""
phone = FakePhone(backup_error=error)
with caplog.at_level(logging.ERROR):
result = go(world(tmp_path), phone)
assert result.outcome is Outcome.BACKUP_FAILED
assert not result.ok
assert "uninstall" not in phone.calls
assert str(error) in caplog.text
def test_failed_uninstall_keeps_the_backup_for_the_retry(tmp_path: Path) -> None:
"""Test failed uninstall keeps the backup for the retry."""
paths = world(tmp_path)
first = FakePhone(uninstall_error=AdbError("DELETE_FAILED_DEVICE_POLICY_MANAGER"))
result = go(paths, first)
assert result.outcome is Outcome.UNINSTALL_FAILED
assert not result.ok
assert not load_state(paths.state_file).done_at
retry = FakePhone()
assert go(paths, retry, DUE + timedelta(days=1)).outcome is Outcome.UNINSTALLED
assert retry.calls == ["installed S", "uninstall"] # no second backup
def test_default_paths_are_the_module_constants() -> None:
"""Test default paths are the module constants."""
paths = Paths.default()
assert paths.state_file == rr.STATE_FILE
assert paths.marker == rr.WATCH_DIR / rr.MARKER_NAME
assert paths.processed == rr.WATCH_DIR / "processed"
assert paths.anchor_dirs() == (rr.WATCH_DIR, rr.WATCH_DIR / "processed")
def test_run_retire_defaults_to_the_real_clock(tmp_path: Path) -> None:
"""Test run retire defaults to the real clock."""
paths = world(tmp_path)
assert run_retire(paths, FakePhone(), dry_run=True).outcome is Outcome.DRY_RUN

View File

@ -0,0 +1,225 @@
"""RunnerUp retirement: the adb wrapper and the state file (no real adb)."""
from __future__ import annotations
from pathlib import Path
import subprocess
from types import SimpleNamespace
from typing import Any
import pytest
from screen_locker import _runnerup_retire_adb as ra
SERIAL = ra.PHONE_SERIAL
PKG_LINE = f"package:{ra.PACKAGE}\n"
class FakeAdb:
"""A ``subprocess.run`` stand-in: replies by the adb sub-command."""
def __init__(self, replies: dict[str, Any] | None = None) -> None:
"""Init ."""
self.replies = replies or {}
self.calls: list[list[str]] = []
def __call__(self, cmd: list[str], **_: object) -> SimpleNamespace:
"""Call ."""
self.calls.append(cmd)
for key, reply in self.replies.items():
if key in " ".join(cmd):
if isinstance(reply, BaseException):
raise reply
code, out, err = reply(cmd) if callable(reply) else reply
return SimpleNamespace(returncode=code, stdout=out, stderr=err)
return SimpleNamespace(returncode=0, stdout="", stderr="")
def phone(fake: FakeAdb, adb: str | None = "/usr/bin/adb") -> ra.Phone:
"""Phone."""
return ra.Phone(run=fake, which=lambda _name: adb)
def test_available_follows_which() -> None:
"""Test available follows which."""
assert phone(FakeAdb()).available() is True
assert phone(FakeAdb(), adb=None).available() is False
@pytest.mark.parametrize(
"boom", [FileNotFoundError("adb"), subprocess.TimeoutExpired("adb", 1)]
)
def test_adb_failing_to_run_is_an_adb_error(boom: BaseException) -> None:
"""Test adb failing to run is an adb error."""
with pytest.raises(ra.AdbError, match="did not run"):
phone(FakeAdb({"devices": boom})).find_device()
def test_nonzero_exit_carries_the_output() -> None:
"""Test nonzero exit carries the output."""
fake = FakeAdb({"devices": (1, "", "daemon not running")})
with pytest.raises(ra.AdbError, match="daemon not running"):
phone(fake).find_device()
@pytest.mark.parametrize(
("listing", "serial", "reason"),
[
(f"List of devices attached\n{SERIAL}\tdevice\n", SERIAL, "attached"),
(
f"List of devices attached\nadb-{SERIAL}-x._adb-tls-connect._tcp\tdevice\n",
f"adb-{SERIAL}-x._adb-tls-connect._tcp",
"attached",
),
(f"List of devices attached\n{SERIAL}\tunauthorized\n", None, "unauthorized"),
("List of devices attached\nOTHER\tdevice\n", None, "not attached"),
("List of devices attached\n", None, "not attached"),
],
)
def test_find_device(listing: str, serial: str | None, reason: str) -> None:
"""Test find device."""
device = phone(FakeAdb({"devices": (0, listing, "")})).find_device()
assert device.serial == serial
assert reason in device.reason
def test_wifi_entry_is_asked_for_its_hardware_serial() -> None:
"""Test wifi entry is asked for its hardware serial."""
listing = "List of devices attached\n192.168.1.5:5555\tdevice\n"
fake = FakeAdb({"devices": (0, listing, ""), "getprop": (0, f"{SERIAL}\n", "")})
device = phone(fake).find_device()
assert device.serial == "192.168.1.5:5555"
assert ["adb", "-s", "192.168.1.5:5555", "shell", "getprop", "ro.serialno"] in (
fake.calls
)
def test_wifi_entry_of_another_phone_is_not_ours() -> None:
"""Test wifi entry of another phone is not ours."""
listing = "List of devices attached\n10.0.0.2:5555\tdevice\n"
fake = FakeAdb({"devices": (0, listing, ""), "getprop": (0, "OTHER\n", "")})
assert phone(fake).find_device().serial is None
def test_wifi_entry_that_will_not_answer_is_warned_about(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test wifi entry that will not answer is warned about."""
listing = "List of devices attached\n10.0.0.2:5555\tdevice\n"
fake = FakeAdb({"devices": (0, listing, ""), "getprop": (1, "", "offline")})
with caplog.at_level("WARNING"):
assert phone(fake).find_device().serial is None
assert "Could not read the serial" in caplog.text
def test_presence_probe_sees_hidden_packages_but_the_final_check_does_not() -> None:
"""Test presence probe sees hidden packages but the final check does not."""
fake = FakeAdb({"pm list": (0, PKG_LINE, "")})
phone(fake).installed("dev")
phone(fake).installed("dev", hidden=False)
assert "-u" in fake.calls[0]
assert "-u" not in fake.calls[1]
def test_pull_gets_a_long_timeout_and_other_calls_the_short_one(
tmp_path: Path,
) -> None:
"""Test pull gets a long timeout and other calls the short one."""
timeouts: dict[str, object] = {}
make = _pull_creates(1)
def run(cmd: list[str], **kw: object) -> SimpleNamespace:
"""Run."""
timeouts[cmd[3]] = kw["timeout"]
code, out, err = make(cmd) if cmd[3] == "pull" else (0, "a.tcx\n", "")
return SimpleNamespace(returncode=code, stdout=out, stderr=err)
ra.Phone(run=run, which=lambda _n: "adb").backup("dev", tmp_path / "b")
assert timeouts == {"shell": 50, "pull": 900}
def test_installed_needs_the_exact_package_line() -> None:
"""Test installed needs the exact package line."""
yes = FakeAdb({"pm list": (0, PKG_LINE, "")})
assert phone(yes).installed("dev") is True
assert yes.calls[0][:3] == ["adb", "-s", "dev"]
other = FakeAdb({"pm list": (0, f"package:{ra.PACKAGE}.pro\n", "")})
assert phone(other).installed("dev") is False
def _pull_creates(count: int, root: str = "RunnerUp"):
"""Pull creates."""
def make(cmd: list[str]) -> tuple[int, str, str]:
"""Make."""
folder = Path(cmd[-1]) / root
folder.mkdir(parents=True)
for i in range(count):
(folder / f"r{i}.tcx").write_text("x")
return 0, "", ""
return make
def test_backup_stages_then_renames_and_counts(tmp_path: Path) -> None:
"""Test backup stages then renames and counts."""
fake = FakeAdb({"ls": (0, "a.tcx\nb.tcx\nb.gpx\n", ""), "pull": _pull_creates(2)})
final = tmp_path / "backup_2026"
target, count = phone(fake).backup("dev", final)
assert (target, count) == (final, 2)
assert len(list(final.glob("*.tcx"))) == 2
assert not (tmp_path / "backup_2026.partial").exists()
def test_backup_never_overwrites_an_existing_folder(tmp_path: Path) -> None:
"""Test backup never overwrites an existing folder."""
final = tmp_path / "backup"
final.mkdir()
(tmp_path / "backup.partial").mkdir() # a stale half-pull is cleared
fake = FakeAdb({"ls": (0, "a.tcx\n", ""), "pull": _pull_creates(1)})
target, _ = phone(fake).backup("dev", final)
assert target == tmp_path / "backup-2"
def test_backup_short_pull_is_an_error_and_not_renamed(tmp_path: Path) -> None:
"""Test backup short pull is an error and not renamed."""
fake = FakeAdb({"ls": (0, "a.tcx\nb.tcx\n", ""), "pull": _pull_creates(1)})
with pytest.raises(ra.AdbError, match="pulled 1 of 2"):
phone(fake).backup("dev", tmp_path / "b")
assert not (tmp_path / "b").exists()
def test_backup_pull_that_creates_nothing_is_an_error(tmp_path: Path) -> None:
"""Test backup pull that creates nothing is an error."""
fake = FakeAdb({"ls": (0, "a.tcx\n", "")})
with pytest.raises(ra.AdbError, match="pulled -1 of 1"):
phone(fake).backup("dev", tmp_path / "b")
def test_uninstall_succeeds_only_when_the_package_is_gone() -> None:
"""Test uninstall succeeds only when the package is gone."""
fake = FakeAdb({"uninstall": (0, "Success\n", ""), "pm list": (0, "", "")})
phone(fake).uninstall("dev")
assert ["adb", "-s", "dev", "uninstall", ra.PACKAGE] in fake.calls
def test_uninstall_refusal_keeps_adbs_reason() -> None:
"""Test uninstall refusal keeps adbs reason."""
fail = "Failure [DELETE_FAILED_DEVICE_POLICY_MANAGER]"
fake = FakeAdb({"uninstall": (1, fail, "")})
with pytest.raises(ra.AdbError, match="DELETE_FAILED_DEVICE_POLICY_MANAGER"):
phone(fake).uninstall("dev")
def test_uninstall_without_success_text_is_an_error() -> None:
"""Test uninstall without success text is an error."""
fake = FakeAdb({"uninstall": (0, "weird\n", "")})
with pytest.raises(ra.AdbError, match="did not report Success"):
phone(fake).uninstall("dev")
def test_uninstall_that_leaves_the_package_is_an_error() -> None:
"""Test uninstall that leaves the package is an error."""
fake = FakeAdb({"uninstall": (0, "Success\n", ""), "pm list": (0, PKG_LINE, "")})
with pytest.raises(ra.AdbError, match="still listed"):
phone(fake).uninstall("dev")

View File

@ -0,0 +1,141 @@
"""RunnerUp retirement CLI: ``--status`` is read-only, exit codes follow outcomes."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
import pytest
from screen_locker import runnerup_retire as cli
from screen_locker._runnerup_retire import Outcome, Paths, RetireResult
from screen_locker._runnerup_retire_adb import AdbError, Device
from screen_locker._runnerup_retire_state import RetireState, save_state
from screen_locker.tests._retire_helpers import put_marker
from screen_locker.tests.test_runnerup_retire import ANCHOR, DUE, RUN, FakePhone, world
if TYPE_CHECKING:
from pathlib import Path
def _tree(root: Path) -> set[str]:
"""Tree."""
return {str(p.relative_to(root)) for p in root.rglob("*")}
def test_status_without_anchor(tmp_path: Path) -> None:
"""Test status without anchor."""
paths = world(tmp_path, anchored=False, marker=False)
lines = cli.status_lines(paths, FakePhone(), DUE)
assert lines[0].startswith("anchor: none")
assert lines[1] == "due: never"
assert "INVALID" in lines[2]
assert lines[3] == "done: no"
assert "not queried" in lines[4]
def test_status_is_strictly_read_only_and_marks_a_derived_anchor(
tmp_path: Path,
) -> None:
"""Test status is strictly read only and marks a derived anchor."""
paths = world(tmp_path)
before = _tree(tmp_path)
lines = cli.status_lines(paths, FakePhone(), DUE - timedelta(days=1))
assert _tree(tmp_path) == before
assert not paths.state_file.parent.exists()
assert "derived, not yet stored" in lines[0]
assert "2026-11-08 10:45 (+1.0 days)" in lines[1]
assert lines[2] == "marker: valid (1 runs match)"
def test_status_with_stored_anchor_and_done(tmp_path: Path) -> None:
"""Test status with stored anchor and done."""
paths = world(tmp_path, anchored=False)
save_state(
paths.state_file,
RetireState(anchor_at=ANCHOR.isoformat(), done_at="2026-11-09T00:00:00+00:00"),
)
lines = cli.status_lines(paths, FakePhone(), DUE)
assert "(stored)" in lines[0]
assert lines[3] == "done: 2026-11-09T00:00:00+00:00"
@pytest.mark.parametrize(
("phone", "expected"),
[
(FakePhone(present=True), "installed: yes"),
(FakePhone(present=False), "installed: no"),
(
FakePhone(device=Device(None, "phone S is not attached")),
"installed: unknown (phone S is not attached)",
),
(FakePhone(ask_error=AdbError("boom")), "installed: unknown (boom)"),
],
)
def test_status_query_phone(tmp_path: Path, phone: FakePhone, expected: str) -> None:
"""Test status query phone."""
paths = world(tmp_path)
lines = cli.status_lines(paths, phone, DUE, query_phone=True)
assert lines[4] == expected
def test_main_status_prints_and_exits_zero(
capsys: pytest.CaptureFixture[str],
) -> None:
"""Test main status prints and exits zero."""
assert not cli.main(["--status"]) # the isolated defaults: an empty world
out = capsys.readouterr().out
assert "anchor: none" in out
assert "not queried" in out
def test_main_status_query_phone_uses_the_serial(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Test main status query phone uses the serial."""
seen: list[str] = []
def fake_phone(serial: str) -> FakePhone:
"""Fake phone."""
seen.append(serial)
return FakePhone()
monkeypatch.setattr(cli, "Phone", fake_phone)
assert not cli.main(["--status", "--query-phone", "--serial", "XYZ"])
assert seen == ["XYZ"]
assert "installed: yes" in capsys.readouterr().out
@pytest.mark.parametrize(
("outcome", "code"),
[(Outcome.NOT_DUE, 0), (Outcome.DEVICE_ABSENT, 0), (Outcome.UNINSTALL_FAILED, 1)],
)
def test_main_exit_code_follows_the_outcome(
monkeypatch: pytest.MonkeyPatch, outcome: Outcome, code: int
) -> None:
"""Test main exit code follows the outcome."""
calls: list[bool] = []
def fake_run(paths: Paths, phone: object, *, dry_run: bool) -> RetireResult:
"""Fake run."""
calls.append(dry_run)
return RetireResult(outcome, "why")
monkeypatch.setattr(cli, "run_retire", fake_run)
assert cli.main(["--dry-run"]) == code
assert calls == [True]
def test_main_with_nothing_to_retire_is_a_clean_noop() -> None:
"""Test main with nothing to retire is a clean noop."""
assert not cli.main([])
def test_status_marker_line_for_a_valid_marker_uses_real_files(
tmp_path: Path,
) -> None:
"""Test status marker line for a valid marker uses real files."""
paths = world(tmp_path, marker=False)
put_marker(paths.watch_dir, [RUN])
now = datetime.now(UTC)
assert cli.status_lines(paths, FakePhone(), now)[2].startswith("marker: valid")

View File

@ -0,0 +1,117 @@
"""RunnerUp retirement: validating the app's import marker against processed/."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import pytest
from screen_locker import _runnerup_retire_tcx as rt
from screen_locker.tests._retire_helpers import APP, put_marker, put_tcx
if TYPE_CHECKING:
from pathlib import Path
def _processed(tmp_path: Path) -> Path:
"""Processed."""
processed = tmp_path / "processed"
put_tcx(processed, "RunnerUp_2026-08-12-17-42-11_Running.tcx")
put_tcx(processed, "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx")
put_tcx(processed, "RunnerUp_2026-08-14-00-58-45_Running.tcx")
put_tcx(processed, "app_2026-11-01.tcx", author=APP)
return processed
_LISTED = [
"RunnerUp_2026-08-12-17-42-11_Running.tcx",
"RunnerUp_2026-08-14-00-58-45_Running.tcx",
]
def test_marker_valid_counts_device_twins_and_ignores_app_files(
tmp_path: Path,
) -> None:
"""Test marker valid counts device twins and ignores app files."""
processed = _processed(tmp_path)
marker = put_marker(tmp_path, _LISTED)
check = rt.check_marker(marker, processed)
assert check.ok
assert "2 runs" in check.reason
def test_marker_may_name_the_device_prefixed_copy(tmp_path: Path) -> None:
"""Test marker may name the device prefixed copy."""
processed = _processed(tmp_path)
names = ["RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx", _LISTED[1]]
assert rt.check_marker(put_marker(tmp_path, names), processed).ok
def test_marker_missing_is_not_valid(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test marker missing is not valid."""
with caplog.at_level(logging.WARNING):
check = rt.check_marker(tmp_path / "m.json", tmp_path)
assert not check.ok
assert "has not imported yet" in check.reason
assert "marker problem" in caplog.text
def test_marker_unreadable_and_bad_json(tmp_path: Path) -> None:
"""Test marker unreadable and bad json."""
folder_as_marker = tmp_path / "m"
folder_as_marker.mkdir()
assert "unreadable" in rt.check_marker(folder_as_marker, tmp_path).reason
bad = tmp_path / "bad.json"
bad.write_text("{nope")
assert "unreadable" in rt.check_marker(bad, tmp_path).reason
@pytest.mark.parametrize(
("body", "fragment"),
[
("[]", "not a JSON object"),
('{"count": 0, "tcx_names": []}', "no completed_at"),
('{"completed_at": "", "count": 0, "tcx_names": []}', "no completed_at"),
('{"completed_at": "t", "count": 0, "tcx_names": "x"}', "not a list"),
('{"completed_at": "t", "count": 0, "tcx_names": [1]}', "not a list"),
('{"completed_at": "t", "count": 3, "tcx_names": ["a"]}', "count 3"),
('{"completed_at": "t", "count": true, "tcx_names": ["a"]}', "count True"),
('{"completed_at": "t", "count": "1", "tcx_names": ["a"]}', "count '1'"),
('{"completed_at": "t", "count": 1, "tcx_names": ["x/a"]}', "bare file"),
('{"completed_at": "t", "count": 1, "tcx_names": [".a"]}', "bare file"),
],
)
def test_marker_shape_errors(tmp_path: Path, body: str, fragment: str) -> None:
"""Test marker shape errors."""
marker = tmp_path / "m.json"
marker.write_text(body)
check = rt.check_marker(marker, tmp_path)
assert not check.ok
assert fragment in check.reason
def test_marker_listing_one_run_twice_is_invalid(tmp_path: Path) -> None:
"""Test marker listing one run twice is invalid."""
processed = _processed(tmp_path)
twin = "RunnerUp_Pixel_6a_2026-08-12-17-42-11_Running.tcx"
marker = put_marker(tmp_path, [_LISTED[0], twin])
assert "more than once" in rt.check_marker(marker, processed).reason
def test_marker_naming_a_run_not_in_processed_is_invalid(tmp_path: Path) -> None:
"""Test marker naming a run not in processed is invalid."""
processed = _processed(tmp_path)
marker = put_marker(tmp_path, [*_LISTED, "RunnerUp_2099-01-01-00-00-00_X.tcx"])
reason = rt.check_marker(marker, processed).reason
assert "not in processed/" in reason
def test_marker_missing_a_processed_run_asks_for_a_reimport(tmp_path: Path) -> None:
"""Test marker missing a processed run asks for a reimport."""
processed = _processed(tmp_path)
marker = put_marker(tmp_path, _LISTED[:1])
reason = rt.check_marker(marker, processed).reason
assert "re-run the app's import" in reason

View File

@ -0,0 +1,52 @@
"""RunnerUp retirement: the state file round-trip and its loud failures."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from screen_locker._runnerup_retire_state import RetireState, load_state, save_state
if TYPE_CHECKING:
from pathlib import Path
def test_state_roundtrip_and_anchor(tmp_path: Path) -> None:
"""Test state roundtrip and anchor."""
path = tmp_path / "deep" / "s.json"
assert load_state(path) == RetireState()
save_state(path, RetireState(anchor_at="2026-11-01T10:45:00+00:00"))
assert load_state(path).anchor is not None
assert RetireState().anchor is None
@pytest.mark.parametrize(
"body", ["{nope", "[]", json.dumps({"anchor_at": "not a date"})]
)
def test_bad_state_is_loud_and_ignored(
tmp_path: Path, body: str, caplog: pytest.LogCaptureFixture
) -> None:
"""Test bad state is loud and ignored."""
path = tmp_path / "s.json"
path.write_text(body)
with caplog.at_level("WARNING"):
assert load_state(path) == RetireState()
assert "Retire state" in caplog.text
def test_unreadable_state_is_loud(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test unreadable state is loud."""
with caplog.at_level("WARNING"):
assert load_state(tmp_path) == RetireState()
assert "unreadable" in caplog.text
def test_state_ignores_unknown_and_non_string_fields(tmp_path: Path) -> None:
"""Test state ignores unknown and non string fields."""
path = tmp_path / "s.json"
path.write_text(json.dumps({"done_at": 5, "extra": "x", "backup_path": "/b"}))
assert load_state(path) == RetireState(backup_path="/b")

View File

@ -0,0 +1,189 @@
"""RunnerUp retirement: app-run detection, anchor derivation, marker validation."""
from __future__ import annotations
from datetime import UTC, datetime
import logging
from typing import TYPE_CHECKING
import pytest
from screen_locker import _runnerup_retire_tcx as rt
from screen_locker.tests._retire_helpers import (
APP,
END_UNIX,
credit,
put_log,
put_tcx,
)
if TYPE_CHECKING:
from pathlib import Path
_AT = "2026-11-01T10:45:00+00:00"
def test_run_key_drops_device_prefix_only() -> None:
"""Test run key drops device prefix only."""
plain = "RunnerUp_2026-08-22-23-51-04_Running.tcx"
twin = "RunnerUp_Pixel_6a_2026-08-22-23-51-04_Running.tcx"
assert rt.run_key(twin) == plain
assert rt.run_key(plain) == plain
def test_list_tcx_skips_dotfiles_other_types_and_missing_dir(tmp_path: Path) -> None:
"""Test list tcx skips dotfiles other types and missing dir."""
assert rt.list_tcx(tmp_path / "nope") == []
put_tcx(tmp_path, "a.tcx")
put_tcx(tmp_path, ".partial.tcx")
(tmp_path / "a.gpx").write_text("x")
(tmp_path / "dir.tcx").mkdir()
assert [p.name for p in rt.list_tcx(tmp_path)] == ["a.tcx"]
@pytest.mark.parametrize(
("kwargs", "expected"),
[
({"author": APP}, True),
({"creator": APP}, True),
({"author": "RunnerUp"}, False),
({"author": f"{APP} fork"}, False),
],
)
def test_is_app_tcx_reads_author_and_creator(
tmp_path: Path, kwargs: dict[str, str], expected: bool
) -> None:
"""Test is app tcx reads author and creator."""
assert rt.is_app_tcx(put_tcx(tmp_path, "r.tcx", **kwargs)) is expected
def test_is_app_tcx_ignores_the_name_in_free_text(tmp_path: Path) -> None:
"""Test is app tcx ignores the name in free text."""
path = tmp_path / "n.tcx"
path.write_text(f"<Notes>{APP}</Notes>")
assert rt.is_app_tcx(path) is False
def test_is_app_tcx_unreadable_is_a_loud_non_app(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test is app tcx unreadable is a loud non app."""
with caplog.at_level(logging.WARNING):
assert rt.is_app_tcx(tmp_path) is False
assert "Cannot read" in caplog.text
def _app_run(tmp_path: Path, name: str = "app.tcx", where: str = "") -> Path:
"""App run."""
return put_tcx(tmp_path / where if where else tmp_path, name, author=APP)
def test_anchor_is_the_credit_time_of_the_entry_matching_the_run_end(
tmp_path: Path,
) -> None:
"""Test anchor is the credit time of the entry matching the run end."""
_app_run(tmp_path, where="processed")
log = tmp_path / "log.json"
put_log(
log,
{
"2026-11-01": [credit(_AT, END_UNIX)],
"2026-11-02": [credit("2026-11-02T09:00:00+00:00", "1700000000.0")],
},
)
found = rt.find_anchor(log, [tmp_path, tmp_path / "processed"])
assert found == datetime(2026, 11, 1, 10, 45, tzinfo=UTC)
def test_anchor_accepts_a_stringified_end_and_the_earliest_credit(
tmp_path: Path,
) -> None:
"""Test anchor accepts a stringified end and the earliest credit."""
_app_run(tmp_path)
log = tmp_path / "log.json"
put_log(
log,
{
"a": [credit("2026-11-03T00:00:00+00:00", str(END_UNIX))],
"b": [credit("2026-11-02T00:00:00+00:00", END_UNIX + 0.4)],
},
)
found = rt.find_anchor(log, [tmp_path])
assert found == datetime(2026, 11, 2, tzinfo=UTC)
def test_same_day_runnerup_credit_does_not_anchor_the_app_run(tmp_path: Path) -> None:
"""Test same day runnerup credit does not anchor the app run."""
_app_run(tmp_path)
log = tmp_path / "log.json"
put_log(log, {"2026-11-01": [credit(_AT, END_UNIX - 3600)]})
assert rt.find_anchor(log, [tmp_path]) is None
def test_no_app_run_means_no_anchor(tmp_path: Path) -> None:
"""Test no app run means no anchor."""
put_tcx(tmp_path, "r.tcx")
log = tmp_path / "log.json"
put_log(log, {"d": [credit(_AT, END_UNIX)]})
assert rt.find_anchor(log, [tmp_path]) is None
def test_unreadable_or_trackless_app_tcx_cannot_anchor(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test unreadable or trackless app tcx cannot anchor."""
(tmp_path / "bad.tcx").write_text(f"<Author><Name>{APP}</Name></Author>")
log = tmp_path / "log.json"
put_log(log, {"d": [credit(_AT, END_UNIX)]})
with caplog.at_level(logging.WARNING):
assert rt.find_anchor(log, [tmp_path]) is None
assert "cannot anchor" in caplog.text
def test_log_entries_that_cannot_match_are_skipped(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Test log entries that cannot match are skipped."""
_app_run(tmp_path)
log = tmp_path / "log.json"
put_log(
log,
{
"d": [
credit(_AT), # an older entry without completed_at
credit(_AT, True),
credit(_AT, "soon"),
credit(_AT, END_UNIX, kind="manual_workout"),
{
"timestamp": "garbage",
"workout_data": {
"type": "runnerup_verified",
"completed_at": END_UNIX,
},
},
{"workout_data": None},
]
},
)
with caplog.at_level(logging.WARNING):
assert rt.find_anchor(log, [tmp_path]) is None
assert "no usable timestamp" in caplog.text
def test_naive_credit_timestamps_are_read_as_local_time(tmp_path: Path) -> None:
"""Test naive credit timestamps are read as local time."""
_app_run(tmp_path)
log = tmp_path / "log.json"
put_log(log, {"d": [credit("2026-11-01T10:45:00", END_UNIX)]})
found = rt.find_anchor(log, [tmp_path])
assert found is not None
assert found.tzinfo is not None
def test_app_run_ends_lists_every_app_run_in_every_folder(tmp_path: Path) -> None:
"""Test app run ends lists every app run in every folder."""
_app_run(tmp_path, "a.tcx")
_app_run(tmp_path, "b.tcx", where="processed")
put_tcx(tmp_path, "r.tcx")
ends = rt.app_run_ends([tmp_path, tmp_path / "processed"])
assert ends == [END_UNIX, END_UNIX]

View File

@ -29,3 +29,15 @@ Implementing design_v2.md improvements.
- Thresholds that decide credit (3.5 km / 25 min) live on the PC only.
- GPS sits behind `PositionSource` (`position_source*.dart`); imported
RunnerUp runs are stored with `upload_state='never'` (table CHECK).
- RunnerUp history import (`runnerup_importer.dart`): once, on app start, on
the phone only (never the sandbox). PROPFINDs `/RunnerUp/processed/`, stores
every `.tcx` as `runnerup_import`/`never`, then PUTs the dotfile marker
`/RunnerUp/.workout_app_import.json` = `{"completed_at": ISO-UTC, "count": N,
"tcx_names": [...]}`. The importer never PUTs a `.tcx` (a top-level one would
be credited by `runnerup_watch.sh`). A reinstall re-imports and rewrites the
marker with a new `completed_at`: the PC must not use it as a clock.
- `runs`/`run_points` travel in backup.json (app-made runs only; the RunnerUp
history comes back from the PC) and in the user export (version 3, all runs).
Points are one compact JSON string per run.
- dufs login: `dufs_credentials.dart` (`dufs_url`, `dufs_user`, `dufs_password`
in secure storage).

View File

@ -16,6 +16,7 @@ import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/run_upload_credentials.dart';
import 'package:workout_app/services/run_uploader.dart';
import 'package:workout_app/services/runnerup_importer.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_device_id.dart';
import 'package:workout_app/services/sync_service.dart';
@ -129,6 +130,9 @@ void main(List<String> args) async {
if (!Platform.isLinux && !Sandbox.enabled) {
await HttpServerService.instance.start();
}
// One-time RunnerUp history import (no-op in the sandbox and off-phone,
// and once it completed). Off the launch path: it is many downloads.
unawaited(RunnerUpImporter().runGuarded());
runApp(const WorkoutApp());
}
// coverage:ignore-end

View File

@ -11,8 +11,14 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/screens/run_detail_screen.dart';
import 'package:workout_app/services/done_today.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import 'package:workout_app/services/run_format.dart';
import 'package:workout_app/services/run_history_merge.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
@ -23,6 +29,7 @@ part 'history_screen_charts.dart';
part 'history_screen_data.dart';
part 'history_screen_painter.dart';
part 'history_screen_planned.dart';
part 'history_screen_runs.dart';
part 'history_screen_tiles.dart';
const _kTotal = 'Total (all workouts)';
@ -44,6 +51,7 @@ class HistoryScreen extends StatefulWidget {
this.httpClient,
this.firebaseFactory,
this.clock,
this.tileProviderOpener = openOsmTileProvider,
});
/// Overrides the HTTP client used to read synced workouts (tests only).
@ -56,6 +64,9 @@ class HistoryScreen extends StatefulWidget {
/// defaults to [DateTime.now], injected by tests.
final DateTime Function()? clock;
/// Where a run's map tiles come from; a fake in tests.
final TileProviderOpener tileProviderOpener;
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
@ -64,6 +75,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Map<String, dynamic>> _rows = [];
List<Map<String, dynamic>> _syncedRows = [];
List<Map<String, dynamic>> _plannedRows = [];
List<RunRecord> _runs = [];
bool _loading = true;
String _selected = _kTotal;
List<String> _exerciseNames = [];
@ -103,13 +115,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
// coverage:ignore-end
}
final today = dayKey((widget.clock ?? DateTime.now)());
final synced = _syncedOnly(remote, today);
final runs = await StorageService.instance.listRuns();
// The phone's own copy of a run (it has the map) replaces the PC's entry.
final synced = withoutLocalRuns(_syncedOnly(remote, today), runs);
final planned = _plannedOnly(remote, today);
if (mounted) {
setState(() {
_rows = rows;
_syncedRows = synced;
_plannedRows = planned;
_runs = runs;
_exerciseNames = names;
_selectedState = state;
_loading = false;
@ -137,8 +152,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
}
// ── Data helpers ──────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final allNames = [_kTotal, ..._exerciseNames];
@ -155,7 +168,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
? const Center(child: CircularProgressIndicator())
// Synced and planned entries count too: a phone with no local
// sessions must still list (and let the user delete) its plans.
: _rows.isEmpty && _syncedRows.isEmpty && _plannedRows.isEmpty
: _rows.isEmpty &&
_syncedRows.isEmpty &&
_plannedRows.isEmpty &&
_runs.isEmpty
? Center(
child: Text(
'No workouts yet.',
@ -180,28 +196,24 @@ class _HistoryScreenState extends State<HistoryScreen> {
);
}
void _shiftMonth(int delta) => setState(() {
_calendarMonth = DateTime(
_calendarMonth.year,
_calendarMonth.month + delta,
);
});
List<Widget> _buildTotalView() => [
..._plannedSection(),
if (_runs.isNotEmpty) ...[
_RunsStrip(runs: _runs, onOpen: _openRun),
const SizedBox(height: 16),
],
const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
const SizedBox(height: 6),
_WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))),
const SizedBox(height: 16),
WorkoutCalendar(
workoutDates: _allWorkoutDates(_rows, _syncedRows),
month: _calendarMonth,
onPrevMonth: () => setState(() {
_calendarMonth = DateTime(
_calendarMonth.year,
_calendarMonth.month - 1,
);
}),
onNextMonth: () => setState(() {
_calendarMonth = DateTime(
_calendarMonth.year,
_calendarMonth.month + 1,
);
}),
),
_calendar(_allWorkoutDates(_rows, _syncedRows, _runs)),
const SizedBox(height: 16),
const _SectionLabel('ALL SESSIONS'),
const SizedBox(height: 8),
@ -223,22 +235,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
const SizedBox(height: 6),
_WeightChart(points: _exerciseWeightPoints(_rows, name)),
const SizedBox(height: 16),
WorkoutCalendar(
workoutDates: _exerciseDates(_rows, name),
month: _calendarMonth,
onPrevMonth: () => setState(() {
_calendarMonth = DateTime(
_calendarMonth.year,
_calendarMonth.month - 1,
);
}),
onNextMonth: () => setState(() {
_calendarMonth = DateTime(
_calendarMonth.year,
_calendarMonth.month + 1,
);
}),
),
_calendar(_exerciseDates(_rows, name)),
const SizedBox(height: 16),
_SectionLabel(name.toUpperCase()),
const SizedBox(height: 8),

View File

@ -117,12 +117,17 @@ List<(DateTime, double)> _rollingAvg2(List<(DateTime, double)> pts) {
///
/// Synced dates are included so a day you only worked out on the PC (a
/// RunnerUp run) still marks the calendar here.
///
/// Runs recorded here count by their local start day, which is also the day
/// of the PC entry they replaced.
Set<String> _allWorkoutDates(
List<Map<String, dynamic>> rows,
List<Map<String, dynamic>> syncedRows,
List<RunRecord> runs,
) => {
...rows.map((r) => r['date'] as String),
...syncedRows.map((r) => '${r['date']}'),
...runs.map((r) => dayKey(r.track.startedAt.toLocal())),
};
/// Synced kinds this phone has no local record of, so showing them can't

View File

@ -0,0 +1,126 @@
// The runs overview on HistoryScreen: one fixed-height strip of compact
// tiles (newest first, swipe sideways for older ones) so it costs no
// vertical space (R7). No words: icons and numbers only.
//
// See history_screen_charts.dart for why these are `part` files.
part of 'history_screen.dart';
/// Pieces of the State kept here so history_screen.dart stays under the line
/// cap: the month-paged calendar and opening a run's detail.
extension _RunsAndCalendar on _HistoryScreenState {
Widget _calendar(Set<String> dates) => WorkoutCalendar(
workoutDates: dates,
month: _calendarMonth,
onPrevMonth: () => _shiftMonth(-1),
onNextMonth: () => _shiftMonth(1),
);
Future<void> _openRun(RunRecord record) async {
final full = await StorageService.instance.getRun(record.track.id);
if (full == null || !mounted) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => RunDetailScreen(
track: full.track,
tileProviderOpener: widget.tileProviderOpener,
),
),
);
}
}
class _RunsStrip extends StatelessWidget {
const _RunsStrip({required this.runs, required this.onOpen});
final List<RunRecord> runs;
final void Function(RunRecord) onOpen;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SizedBox(
key: const Key('runs_strip'),
height: 92,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: runs.length,
separatorBuilder: (_, _) => const SizedBox(width: AppSpacing.sm),
itemBuilder: (_, i) => _RunTile(
run: runs[i].track,
scheme: scheme,
onTap: () => onOpen(runs[i]),
),
),
);
}
}
class _RunTile extends StatelessWidget {
const _RunTile({
required this.run,
required this.scheme,
required this.onTap,
});
final RunTrack run;
final ColorScheme scheme;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final isRun = run.sport == RunSport.running;
final value = TextStyle(
color: scheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
);
return Semantics(
button: true,
label: isRun ? 'Run' : 'Walk',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
width: 104,
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: scheme.outline),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
isRun ? Icons.directions_run : Icons.directions_walk,
size: 18,
color: scheme.primary,
),
const Spacer(),
Text(
formatRunDay(run.startedAt),
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
Text(formatDistanceKm(run.distanceM), style: value),
Text(
formatDuration(run.totalSeconds),
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,160 @@
/// One run on one screen: the whole route on a full-bleed map with its
/// numbers (distance, moving time, average pace, date) in a panel over the
/// bottom edge. Nothing scrolls (R7); no words (textgate), icons name each
/// value.
library;
import 'package:flutter/material.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import 'package:workout_app/services/run_format.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/run_map.dart';
/// Height reserved under the route for the stats panel, so the fitted route
/// is not hidden behind it.
const _panelClearance = 190.0;
/// Detail of the finished [track] (it must carry its points).
class RunDetailScreen extends StatelessWidget {
/// Creates the screen; [tileProviderOpener] is swapped by tests.
const RunDetailScreen({
required this.track,
super.key,
this.tileProviderOpener = openOsmTileProvider,
});
/// The run, with points (see `StorageServiceRuns.getRun`).
final RunTrack track;
/// Where the map's tiles come from.
final TileProviderOpener tileProviderOpener;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
return Scaffold(
body: Stack(
fit: StackFit.expand,
children: [
RunMap.route(
points: track.points,
fitPadding: EdgeInsets.fromLTRB(
AppSpacing.xl,
top + 64,
AppSpacing.xl,
_panelClearance,
),
tileProviderOpener: tileProviderOpener,
),
Positioned(
left: AppSpacing.sm,
top: top + AppSpacing.sm,
child: IconButton.filledTonal(
onPressed: () => Navigator.maybePop(context),
icon: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
),
),
Positioned(
left: AppSpacing.md,
right: AppSpacing.md,
bottom: MediaQuery.paddingOf(context).bottom + AppSpacing.md,
child: _StatsPanel(track: track),
),
],
),
);
}
}
class _StatsPanel extends StatelessWidget {
const _StatsPanel({required this.track});
final RunTrack track;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isRun = track.sport == RunSport.running;
return Container(
key: const Key('run_stats_panel'),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: scheme.outline),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
isRun ? Icons.directions_run : Icons.directions_walk,
color: scheme.primary,
semanticLabel: isRun ? 'Run' : 'Walk',
),
const SizedBox(width: AppSpacing.sm),
Text(
formatFullDate(track.startedAt),
style: TextStyle(color: scheme.onSurfaceVariant),
),
const Spacer(),
if (track.source == RunSource.runnerupImport)
Icon(
Icons.history,
color: scheme.onSurfaceVariant,
semanticLabel: 'Imported',
),
],
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
_Stat(Icons.straighten, formatDistanceKm(track.distanceM)),
_Stat(Icons.timer_outlined, formatDuration(track.totalSeconds)),
_Stat(
Icons.speed,
formatPace(
seconds: track.totalSeconds,
distanceM: track.distanceM,
),
),
],
),
],
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat(this.icon, this.value);
final IconData icon;
final String value;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Expanded(
child: Column(
children: [
Icon(icon, size: 20, color: scheme.onSurfaceVariant),
const SizedBox(height: AppSpacing.xs),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
color: scheme.onSurface,
fontSize: AppTextSize.title,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@ -147,12 +147,15 @@ class _Numbers extends StatelessWidget {
small(
Icons.straighten,
const Key('run_distance'),
formatKm(recorder.distanceM),
formatDistanceKm(recorder.distanceM),
),
small(
Icons.speed,
const Key('run_pace'),
formatPace(recorder.pace),
formatPace(
seconds: recorder.seconds,
distanceM: recorder.distanceM,
),
),
],
),

View File

@ -2,9 +2,8 @@
/// settings sheet, plus links to the sync surfaces.
///
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase sync;
/// no Backup section -- see the class doc on [SettingsScreen] for why).
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because
/// the shared package has no GitHub surface at all.
/// no Backup section -- see [SettingsScreen]). "Advanced sync (GitHub)" stays
/// app-local ([GitHubMirrorScreen]): the shared package has no GitHub surface.
library;
import 'dart:async';
@ -26,6 +25,7 @@ import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/data_transfer_buttons.dart';
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
import 'package:workout_app/widgets/runnerup_import_badge.dart';
part 'settings_screen_actions.dart';
part 'settings_screen_deload.dart';

View File

@ -179,6 +179,7 @@ class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget {
title: Text('Settings', style: TextStyle(color: colorScheme.onSurface)),
iconTheme: IconThemeData(color: colorScheme.onSurface),
actions: [
const RunnerUpImportBadge(),
TextButton(
onPressed: loading ? null : onReset,
child: Text(

View File

@ -0,0 +1,74 @@
/// Login for the PC's dufs WebDAV server (`/RunnerUp`, scoped read-write).
///
/// One shared definition so the run uploader and the RunnerUp history
/// importer read the very same secure-storage slots; provisioned once from a
/// `dufs_login.json` that carries `url`, `user` and `password`.
library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Secure-storage key of [DufsCredentials.url].
const String kDufsUrlKey = 'dufs_url';
/// Secure-storage key of [DufsCredentials.user].
const String kDufsUserKey = 'dufs_user';
/// Secure-storage key of [DufsCredentials.password].
const String kDufsPasswordKey = 'dufs_password';
/// Where the PC's dufs is and who to log in as.
@immutable
class DufsCredentials {
/// Creates a login; trailing slashes of [url] are dropped so
/// `'$url/RunnerUp/...'` is always well formed.
DufsCredentials({
required String url,
required this.user,
required this.password,
}) : url = url.replaceFirst(RegExp(r'/+$'), '');
/// Parses a `dufs_login.json` document; null when a field is missing.
static DufsCredentials? fromJson(Map<String, dynamic> json) {
final url = json['url'];
final user = json['user'];
final password = json['password'];
if (url is! String || user is! String || password is! String) return null;
if (url.isEmpty || user.isEmpty || password.isEmpty) return null;
return DufsCredentials(url: url, user: user, password: password);
}
/// The origin, e.g. `https://kuhy-cloud.duckdns.org` (no trailing slash).
final String url;
/// The Basic-auth user.
final String user;
/// The Basic-auth password.
final String password;
/// The `Authorization` header value.
String get authorization =>
'Basic ${base64Encode(utf8.encode('$user:$password'))}';
/// The stored login, or null when any of the three slots is empty.
static Future<DufsCredentials?> load({
FlutterSecureStorage storage = const FlutterSecureStorage(),
}) async {
final url = await storage.read(key: kDufsUrlKey);
final user = await storage.read(key: kDufsUserKey);
final password = await storage.read(key: kDufsPasswordKey);
return fromJson({'url': url, 'user': user, 'password': password});
}
/// Stores this login.
Future<void> save({
FlutterSecureStorage storage = const FlutterSecureStorage(),
}) async {
await storage.write(key: kDufsUrlKey, value: url);
await storage.write(key: kDufsUserKey, value: user);
await storage.write(key: kDufsPasswordKey, value: password);
}
}

View File

@ -0,0 +1,74 @@
/// The OSM tile source and its on-disk cache.
///
/// Tiles come from tile.openstreetmap.org and are kept in the app's cache
/// directory (flutter_map_cache over dio_cache_interceptor) with a one-year
/// max-stale, so an area visited once draws offline later. OSM's tile usage
/// policy needs a real User-Agent: the [TileLayer] sends
/// [osmUserAgentPackage].
library;
import 'dart:async';
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:http_cache_file_store/http_cache_file_store.dart';
import 'package:path_provider/path_provider.dart';
/// The OSM raster tile template.
const osmTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/// Sent as the tile User-Agent, as OSM's tile policy asks.
const osmUserAgentPackage = 'com.kuhy.workout_app';
/// How long a cached tile may be served when the network cannot refresh it.
const tileMaxStale = Duration(days: 365);
/// Opens a [TileProvider]; swapped for a fake in tests.
typedef TileProviderOpener = Future<TileProvider> Function();
/// A provider that caches into [cacheDir] (and serves stale tiles offline).
TileProvider buildCachedTileProvider(String cacheDir, {Dio? dio}) =>
CachedTileProvider(
store: FileCacheStore(cacheDir),
maxStale: tileMaxStale,
dio: dio,
);
Future<String?>? _cacheDir;
/// A disk-cached OSM provider; each map gets its own.
///
/// Only the cache directory is looked up once per process. A provider is
/// never shared: `TileLayer` disposes its provider when the map goes away
/// (a `NetworkTileProvider` closes its HTTP client then), so a shared one
/// would be dead for the second map.
///
/// If the cache directory cannot be had, the map still works over the
/// network without a cache, and the reason is logged once at warning level.
Future<TileProvider> openOsmTileProvider() async {
final dir = await (_cacheDir ??= _resolveCacheDir());
return dir == null ? NetworkTileProvider() : buildCachedTileProvider(dir);
}
/// Forgets the looked-up cache directory (tests only).
@visibleForTesting
void resetOsmTileProvider() => _cacheDir = null;
Future<String?> _resolveCacheDir() async {
try {
final dir = await getApplicationCacheDirectory();
return '${dir.path}/osm_tiles';
} on Object catch (error) {
logMapWarning('map tile cache unavailable, tiles will not persist: $error');
return null;
}
}
/// Logs [message] at warning level (900), loud in `flutter logs` too.
void logMapWarning(String message) {
developer.log(message, name: 'RunMap', level: 900);
debugPrint('RunMap WARNING: $message');
}

View File

@ -11,7 +11,7 @@ import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/foreground_run_client.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_format.dart';
import 'package:workout_app/services/run_notification.dart';
import 'package:workout_app/services/run_recorder.dart';
import 'package:workout_app/services/run_saver.dart';

View File

@ -1,40 +1,40 @@
/// Number formatting for the live run screen and its notification.
/// Number formatting for run tiles and the run detail screen.
///
/// Numbers only (textgate): the digits are the whole display.
/// Numbers only: textgate bans words in the UI, so there are no unit
/// suffixes. The icon next to a value (distance, clock, pace) names it.
library;
/// Distance below which a pace means nothing (GPS noise at the start line).
const double kMinPaceDistanceM = 20;
String _two(int v) => v.toString().padLeft(2, '0');
/// `m:ss`, or `h:mm:ss` from one hour on.
/// Kilometres with two decimals, e.g. `5.20`.
String formatDistanceKm(double distanceM) =>
(distanceM / 1000).toStringAsFixed(2);
/// `m:ss`, or `h:mm:ss` from an hour up; [seconds] is moving time.
String formatDuration(int seconds) {
final s = seconds < 0 ? 0 : seconds;
String two(int v) => v.toString().padLeft(2, '0');
final h = s ~/ 3600;
final m = s % 3600 ~/ 60;
return h > 0 ? '$h:${two(m)}:${two(s % 60)}' : '$m:${two(s % 60)}';
final h = seconds ~/ 3600;
final m = (seconds ~/ 60).remainder(60);
final s = seconds % 60;
return h > 0 ? '$h:${_two(m)}:${_two(s)}' : '$m:${_two(s)}';
}
/// Kilometres with two decimals, e.g. `3.52`.
String formatKm(double metres) => (metres / 1000).toStringAsFixed(2);
/// Average pace as `m:ss` per kilometre, or `-:--` when under 10 m was
/// covered (the quotient is meaningless, and 0 would divide by zero).
String formatPace({required int seconds, required double distanceM}) {
if (distanceM < 10) return '-:--';
final perKm = (seconds / (distanceM / 1000)).round();
return '${perKm ~/ 60}:${_two(perKm % 60)}';
}
/// Average pace in seconds per km, or null while the run is too short.
double? paceSecPerKm(int movingSeconds, double metres) =>
metres < kMinPaceDistanceM ? null : movingSeconds / (metres / 1000);
/// `dd.MM` of [when] in the device's local time.
String formatRunDay(DateTime when) {
final l = when.toLocal();
return '${_two(l.day)}.${_two(l.month)}';
}
/// `m:ss` per km, or `--:--` while there is no pace yet.
String formatPace(double? secPerKm) =>
secPerKm == null || secPerKm.isInfinite || secPerKm.isNaN
? '--:--'
: formatDuration(secPerKm.round());
/// The two notification lines: distance, then time and pace.
({String title, String body}) runNotificationCopy({
required double metres,
required int movingSeconds,
}) => (
title: formatKm(metres),
body:
'${formatDuration(movingSeconds)} '
'${formatPace(paceSecPerKm(movingSeconds, metres))}',
);
/// `yyyy-MM-dd HH:mm` of [when] in the device's local time.
String formatFullDate(DateTime when) {
final l = when.toLocal();
return '${l.year}-${_two(l.month)}-${_two(l.day)} '
'${_two(l.hour)}:${_two(l.minute)}';
}

View File

@ -0,0 +1,59 @@
/// Merges the phone's own runs into the history list without doubling them.
///
/// The PC pushes a `runnerup_verified` calendar entry for every day it
/// credited a RunnerUp run or walk. Once the app records (or imports) the same
/// run itself, that entry says nothing the local run does not, and the local
/// one has the map. This hides the duplicate.
///
/// The key is **day + sport**, not a start time, because that is all the PC
/// entry carries: its record id is `runnerup_verified:<YYYY-MM-DD>` (one slot
/// per day, runs and walks merged), and the payload holds `date`,
/// `distance_km`, `duration_minutes`, an optional `completed_at` (the *end*,
/// often absent) and `activity: walking` for a day of walks. There is no
/// start time to match within a window. Sport keeps a stock-RunnerUp run
/// visible next to an app-recorded walk on the same day.
///
/// When nothing matches, both stay visible: a duplicate is a nuisance, a
/// hidden credited run is a lie.
library;
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/done_today.dart';
/// The `kind` the PC gives its run/walk entries.
const kRunnerUpVerifiedKind = 'runnerup_verified';
String _key(RunSport sport, String day) => '${sport.tcxName}|$day';
/// The `YYYY-MM-DD` part of a PC entry's `date`.
String _entryDay(Map<String, dynamic> payload) {
final date = '${payload['date']}';
return date.length > 10 ? date.substring(0, 10) : date;
}
/// The sport a PC entry stands for: a day of walks says so, anything else is
/// a run.
RunSport _entrySport(Map<String, dynamic> payload) =>
payload['activity'] == 'walking' ? RunSport.walking : RunSport.running;
/// [synced] without the `runnerup_verified` entries that [runs] already
/// cover (same local day and same sport).
///
/// Days are the device's local calendar days, the calendar the PC dates its
/// records by (`dayKey`). Other kinds pass through untouched.
List<Map<String, dynamic>> withoutLocalRuns(
List<Map<String, dynamic>> synced,
Iterable<RunRecord> runs,
) {
final covered = {
for (final r in runs)
_key(r.track.sport, dayKey(r.track.startedAt.toLocal())),
};
return [
for (final p in synced)
if (p['kind'] != kRunnerUpVerifiedKind ||
!covered.contains(_key(_entrySport(p), _entryDay(p))))
p,
];
}

View File

@ -0,0 +1,15 @@
/// The two lines of the ongoing run notification (numbers only, textgate).
library;
import 'package:workout_app/services/run_format.dart';
/// Distance as the title; moving time and average pace as the body.
({String title, String body}) runNotificationCopy({
required double metres,
required int movingSeconds,
}) => (
title: formatDistanceKm(metres),
body:
'${formatDuration(movingSeconds)} '
'${formatPace(seconds: movingSeconds, distanceM: metres)}',
);

View File

@ -9,7 +9,6 @@ import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/gps_filter.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_format.dart';
import 'package:workout_app/services/run_math.dart';
/// Where a recording stands.
@ -97,9 +96,6 @@ class RunRecorder extends ChangeNotifier {
/// Credited distance in metres.
double get distanceM => _filter?.distanceM ?? 0;
/// Average pace in seconds per km, or null while the run is too short.
double? get pace => paceSecPerKm(_moving, distanceM);
/// Picks the sport; only possible while [RunPhase.idle].
bool selectSport(RunSport sport) {
if (_phase != RunPhase.idle) return false;
@ -142,7 +138,7 @@ class RunRecorder extends ChangeNotifier {
if (_phase != RunPhase.recording) return;
_filter!.breakSegment();
_phase = RunPhase.paused;
SandboxLog.event('run paused', {'km': formatKm(distanceM)});
SandboxLog.event('run paused', {'km': distanceM});
notifyListeners();
}
@ -150,7 +146,7 @@ class RunRecorder extends ChangeNotifier {
void resume() {
if (_phase != RunPhase.paused) return;
_phase = RunPhase.recording;
SandboxLog.event('run resumed', {'km': formatKm(distanceM)});
SandboxLog.event('run resumed', {'km': distanceM});
notifyListeners();
}
@ -181,7 +177,7 @@ class RunRecorder extends ChangeNotifier {
);
}
SandboxLog.event('run finished', {
'km': formatKm(distanceM),
'km': distanceM,
'secs': _moving,
'points': points.length,
'saved': track != null,
@ -263,7 +259,7 @@ class RunRecorder extends ChangeNotifier {
'finish the run to save what was captured.',
level: 900,
);
SandboxLog.event('run stream ended', {'km': formatKm(distanceM)});
SandboxLog.event('run stream ended', {'km': distanceM});
}
@override

View File

@ -14,34 +14,11 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:workout_app/services/dufs_credentials.dart';
/// File name the provisioning script pushes.
const String kProvisionFileName = 'dufs_login.json';
/// A dufs login.
@immutable
class RunUploadCredentials {
/// Creates a login; [baseUrl] has no trailing slash.
const RunUploadCredentials({
required this.baseUrl,
required this.user,
required this.password,
});
/// The dufs server, e.g. `https://host`.
final String baseUrl;
/// The dufs user.
final String user;
/// The dufs password.
final String password;
/// The `Authorization` header value.
String get basicAuth =>
'Basic ${base64.encode(utf8.encode('$user:$password'))}';
}
/// What an import attempt found.
enum ProvisionStatus {
/// No file: the normal case on every start after the first.
@ -75,7 +52,8 @@ Future<Directory?> _externalFilesDir() async {
return null;
}
/// Reads and writes the upload login in secure storage.
/// Imports the upload login into secure storage (the slots of
/// [DufsCredentials], shared with the history importer).
class RunCredentialStore {
/// Creates a store; the arguments are test seams.
RunCredentialStore({
@ -83,21 +61,11 @@ class RunCredentialStore {
this._externalDir = _externalFilesDir,
});
static const _urlKey = 'run_upload_url';
static const _userKey = 'run_upload_user';
static const _passwordKey = 'run_upload_password';
final FlutterSecureStorage _secure;
final Future<Directory?> Function() _externalDir;
/// The stored login, or null when none was provisioned yet.
Future<RunUploadCredentials?> read() async {
final url = await _secure.read(key: _urlKey);
final user = await _secure.read(key: _userKey);
final password = await _secure.read(key: _passwordKey);
if (url == null || user == null || password == null) return null;
return RunUploadCredentials(baseUrl: url, user: user, password: password);
}
Future<DufsCredentials?> read() => DufsCredentials.load(storage: _secure);
/// Moves `<external files dir>/dufs_login.json` into secure storage and
/// deletes it. An unusable file stays put and is reported, so a typo in the
@ -123,9 +91,7 @@ class RunCredentialStore {
log('RunCredentialStore: $reason', level: 1000);
return const ProvisionOutcome(ProvisionStatus.invalid, reason);
}
await _secure.write(key: _urlKey, value: login.baseUrl);
await _secure.write(key: _userKey, value: login.user);
await _secure.write(key: _passwordKey, value: login.password);
await login.save(storage: _secure);
await file.delete();
log('RunCredentialStore: imported the run-upload login for ${login.user}.');
return ProvisionOutcome(
@ -134,7 +100,7 @@ class RunCredentialStore {
);
}
RunUploadCredentials? _parse(String text) {
DufsCredentials? _parse(String text) {
final Object? decoded;
try {
decoded = jsonDecode(text);
@ -142,22 +108,10 @@ class RunCredentialStore {
log('RunCredentialStore: $kProvisionFileName is not JSON ($error)');
return null;
}
if (decoded is! Map) return null;
final url = decoded['url'];
final user = decoded['user'];
final password = decoded['password'];
if (url is! String || user is! String || password is! String) return null;
final uri = Uri.tryParse(url.trim());
if (uri == null ||
!uri.hasAuthority ||
!const {'http', 'https'}.contains(uri.scheme)) {
return null;
}
if (user.isEmpty || password.isEmpty) return null;
return RunUploadCredentials(
baseUrl: url.trim().replaceFirst(RegExp(r'/+$'), ''),
user: user,
password: password,
);
if (decoded is! Map<String, dynamic>) return null;
final login = DufsCredentials.fromJson(decoded);
final uri = login == null ? null : Uri.tryParse(login.url);
if (uri == null || !uri.hasAuthority) return null;
return const {'http', 'https'}.contains(uri.scheme) ? login : null;
}
}

View File

@ -14,6 +14,7 @@ import 'package:http/http.dart' as http;
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/dufs_credentials.dart';
import 'package:workout_app/services/run_upload_credentials.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/tcx_codec.dart';
@ -156,7 +157,7 @@ class RunUploader {
/// A sentence naming the failure, or null once the run is uploaded.
Future<String?> _upload(
http.Client client,
RunUploadCredentials login,
DufsCredentials login,
RunRecord record,
) async {
final full = await _db.getRun(record.track.id);
@ -171,7 +172,7 @@ class RunUploader {
);
}
final url = Uri.parse(
'${login.baseUrl}/$kRunInboxFolder/${record.tcxFilename}',
'${login.url}/$kRunInboxFolder/${record.tcxFilename}',
);
final http.Response response;
try {
@ -179,7 +180,7 @@ class RunUploader {
.put(
url,
headers: {
'Authorization': login.basicAuth,
'Authorization': login.authorization,
'Content-Type': 'application/vnd.garmin.tcx+xml',
},
body: tcx,

View File

@ -0,0 +1,122 @@
/// The WebDAV half of the RunnerUp history import: list, fetch, marker PUT.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:workout_app/services/dufs_credentials.dart';
import 'package:xml/xml.dart';
/// Folder on the PC that holds the already-handled RunnerUp exports.
const String kRunnerUpProcessedPath = '/RunnerUp/processed/';
/// The ONE file the app ever writes to the PC for the import: the
/// import-complete marker.
///
/// A dotfile at the top of `/RunnerUp` is invisible to `runnerup_watch.sh`
/// (it skips names starting with '.') and to every `*.tcx` glob, so it can
/// never be credited as a workout. [putImportMarker] is the importer's only
/// PUT and its path is this constant, never built from a file name.
const String kImportMarkerPath = '/RunnerUp/.workout_app_import.json';
/// Per-request ceiling, so a half-open connection cannot hang the import.
const Duration kDavTimeout = Duration(seconds: 20);
/// A request the server did not answer with success.
class DavException implements Exception {
/// Creates the exception.
const DavException(this.what);
/// A sentence naming the request and why it failed.
final String what;
@override
String toString() => what;
}
/// The `.tcx` file names directly inside `/RunnerUp/processed/`.
///
/// One PROPFIND with `Depth: 1`. Throws [DavException] on a non-multistatus
/// answer (401 included) and an `XmlException` on unreadable XML.
Future<List<String>> listProcessedTcx(
http.Client client,
DufsCredentials login,
) async {
final request = http.Request(
'PROPFIND',
Uri.parse('${login.url}$kRunnerUpProcessedPath'),
)..headers.addAll({'Authorization': login.authorization, 'Depth': '1'});
final response = await _send(client, request);
if (response.statusCode != 207) {
throw DavException(
'PROPFIND $kRunnerUpProcessedPath answered ${response.statusCode}',
);
}
return tcxNamesFromMultistatus(utf8.decode(response.bodyBytes));
}
/// The `.tcx` basenames named by the `href`s of a multistatus [xml].
///
/// Hrefs may be absolute paths or full URLs and are percent-encoded; the
/// collection itself (a trailing slash) has no name and is skipped.
List<String> tcxNamesFromMultistatus(String xml) {
final names = <String>{};
for (final href in XmlDocument.parse(
xml,
).descendantElements.where((e) => e.localName == 'href')) {
final path = Uri.decodeFull(Uri.parse(href.innerText.trim()).path);
final name = path.substring(path.lastIndexOf('/') + 1);
if (name.toLowerCase().endsWith('.tcx') && !name.startsWith('.')) {
names.add(name);
}
}
return names.toList()..sort();
}
/// Downloads one export from `/RunnerUp/processed/` as raw bytes (the caller
/// decides what a non-UTF-8 file means).
Future<Uint8List> fetchProcessedTcx(
http.Client client,
DufsCredentials login,
String name,
) async {
final request = http.Request(
'GET',
Uri.parse(
'${login.url}$kRunnerUpProcessedPath${Uri.encodeComponent(name)}',
),
)..headers['Authorization'] = login.authorization;
final response = await _send(client, request);
if (response.statusCode != 200) {
throw DavException('GET $name answered ${response.statusCode}');
}
return response.bodyBytes;
}
/// PUTs [body] to [kImportMarkerPath]; throws [DavException] unless 2xx.
Future<void> putImportMarker(
http.Client client,
DufsCredentials login,
String body,
) async {
final request =
http.Request('PUT', Uri.parse('${login.url}$kImportMarkerPath'))
..headers.addAll({
'Authorization': login.authorization,
'Content-Type': 'application/json',
})
..body = body;
final response = await _send(client, request);
if (response.statusCode < 200 || response.statusCode > 299) {
throw DavException(
'PUT $kImportMarkerPath answered ${response.statusCode}',
);
}
}
Future<http.Response> _send(http.Client client, http.Request request) async {
final streamed = await client.send(request).timeout(kDavTimeout);
return await http.Response.fromStream(streamed);
}

View File

@ -0,0 +1,79 @@
/// What the RunnerUp import remembers and reports: prefs keys, the live
/// status the badge shows, and the per-pass tally.
library;
import 'package:flutter/foundation.dart';
/// Where RunnerUp itself leaves exports on the phone.
const String kRunnerUpLocalDir = '/sdcard/Documents/RunnerUp';
/// `shared_preferences` key: every remote export is in the database.
const String kImportDoneKey = 'runnerup_import_done';
/// `shared_preferences` key: the marker JSON, kept until the PC has it.
const String kImportMarkerKey = 'runnerup_import_marker';
/// What the badge shows.
enum RunnerUpImportStatus {
/// Not started, or not applicable here (sandbox, desktop).
idle,
/// Importing right now.
running,
/// Imported and the PC has been told.
done,
/// Something failed; the next app start retries.
failed,
}
/// The live status; the badge listens to it.
final ValueNotifier<RunnerUpImportStatus> runnerUpImportStatus = ValueNotifier(
RunnerUpImportStatus.idle,
);
/// What one [RunnerUpImporter.runIfNeeded] pass did.
typedef RunnerUpImportReport = ({
int imported,
int duplicates,
int skipped,
List<String> failures,
});
/// The report of a pass that did nothing.
const RunnerUpImportReport kNothingImported = (
imported: 0,
duplicates: 0,
skipped: 0,
failures: [],
);
/// Counts one import pass and logs each failure as it happens.
class ImportTally {
/// Runs stored this pass.
int imported = 0;
/// Exports already stored (same hash or id).
int duplicates = 0;
/// Files that are no usable run, skipped for good.
int skipped = 0;
/// Why the pass is incomplete; empty means it can finish.
final List<String> failures = [];
/// Records [reason] and logs it as a warning.
void fail(String reason) {
failures.add(reason);
debugPrint('WorkoutApp: RunnerUp import WARNING: $reason');
}
/// The tally as a report.
RunnerUpImportReport get report => (
imported: imported,
duplicates: duplicates,
skipped: skipped,
failures: failures,
);
}

View File

@ -0,0 +1,255 @@
/// One-time import of the RunnerUp history into the app's `runs` table.
///
/// Imported runs are display-only: [RunSource.runnerupImport] with
/// [RunUploadState.never]. Nothing here ever PUTs a `.tcx` anywhere; the only
/// write to the PC is the dotfile marker (see [kImportMarkerPath]) that tells
/// the PC the import finished.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/dufs_credentials.dart';
import 'package:workout_app/services/runnerup_dav.dart';
import 'package:workout_app/services/runnerup_import_state.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/tcx_codec.dart';
export 'package:workout_app/services/runnerup_import_state.dart';
/// Imports the RunnerUp history once, on app start.
class RunnerUpImporter {
/// Creates an importer; every collaborator can be replaced in tests.
RunnerUpImporter({
http.Client? client,
Future<DufsCredentials?> Function()? loadLogin,
Future<bool> Function()? canReadStorage,
this.localDir = kRunnerUpLocalDir,
bool? isAndroid,
DateTime Function()? now,
}) : _client = client ?? http.Client(),
_loadLogin = loadLogin ?? DufsCredentials.load,
_canReadStorage =
canReadStorage ?? BackupService.instance.hasStoragePermission,
_isAndroid = isAndroid ?? Platform.isAndroid,
_now = now ?? DateTime.now;
final http.Client _client;
final Future<DufsCredentials?> Function() _loadLogin;
final Future<bool> Function() _canReadStorage;
final bool _isAndroid;
final DateTime Function() _now;
/// Where RunnerUp's own exports are looked for (when storage is readable).
final String localDir;
/// Runs the import unless it already completed, this is the sandbox, or
/// this is not the phone (a desktop run would write the marker before the
/// phone ever imported).
Future<RunnerUpImportReport> runIfNeeded() async {
if (Sandbox.enabled) {
SandboxLog.event('runnerup import skipped: sandbox never imports');
return kNothingImported;
}
if (!_isAndroid) {
debugPrint('WorkoutApp: RunnerUp import skipped: not the phone.');
return kNothingImported;
}
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(kImportDoneKey) ?? false) {
final marker = prefs.getString(kImportMarkerKey);
if (marker == null) {
runnerUpImportStatus.value = RunnerUpImportStatus.done;
return kNothingImported;
}
return (
imported: 0,
duplicates: 0,
skipped: 0,
failures: await _deliver(prefs, marker),
);
}
runnerUpImportStatus.value = RunnerUpImportStatus.running;
final report = await _importAll(prefs);
runnerUpImportStatus.value = report.failures.isEmpty
? RunnerUpImportStatus.done
: RunnerUpImportStatus.failed;
return report;
}
/// [runIfNeeded] that cannot leave the badge spinning: anything thrown
/// outside the per-file handling (a platform error, a prefs write) becomes
/// a logged failure and the `failed` state, retried next start.
Future<RunnerUpImportReport> runGuarded() async {
try {
return await runIfNeeded();
} on Object catch (error) {
debugPrint('WorkoutApp: RunnerUp import WARNING: aborted: $error');
runnerUpImportStatus.value = RunnerUpImportStatus.failed;
return (
imported: 0,
duplicates: 0,
skipped: 0,
failures: ['aborted: $error'],
);
}
}
Future<RunnerUpImportReport> _importAll(SharedPreferences prefs) async {
final tally = ImportTally();
final login = await _loadLogin();
final names = <String>[];
if (login == null) {
tally.fail('no dufs login in secure storage, so nothing was fetched');
} else {
names.addAll(await _importRemote(login, tally));
}
await _importLocal(tally);
if (tally.failures.isEmpty && login != null) {
final marker = jsonEncode({
'completed_at': _now().toUtc().toIso8601String(),
'count': names.length,
'tcx_names': names,
});
await prefs.setString(kImportMarkerKey, marker);
await prefs.setBool(kImportDoneKey, true);
tally.failures.addAll(await _deliver(prefs, marker));
}
return tally.report;
}
/// Fetches and stores every remote export; returns the names now accounted
/// for (stored, or already stored). A file that is not a usable run is
/// skipped for good and left out; a network failure is a failure.
Future<List<String>> _importRemote(
DufsCredentials login,
ImportTally tally,
) async {
final List<String> remote;
try {
remote = await listProcessedTcx(_client, login);
} on Exception catch (error) {
debugPrint('WorkoutApp: RunnerUp import WARNING: listing failed: $error');
tally.failures.add('listing the PC history failed: $error');
return const [];
}
final accounted = <String>[];
final seen = <String>{};
for (final name in remote) {
try {
final bytes = await fetchProcessedTcx(_client, login, name);
final source = await _ingest(name, bytes, tally);
// The marker lists each distinct RunnerUp run once (the phone's
// `Pixel_6a_` twin of a file is the same run) and never an
// app-authored file.
if (source == RunSource.runnerupImport &&
seen.add(name.replaceFirst('Pixel_6a_', ''))) {
accounted.add(name);
}
} on Exception catch (error) {
debugPrint('WorkoutApp: RunnerUp import WARNING: $name: $error');
tally.failures.add('$name: $error');
}
await Future<void>.delayed(Duration.zero);
}
return accounted;
}
/// Best effort: only if storage is already readable, and never a failure.
Future<void> _importLocal(ImportTally tally) async {
if (!await _canReadStorage()) {
debugPrint(
'WorkoutApp: RunnerUp import: storage not readable, skipping '
'$localDir (the PC copy is the source).',
);
return;
}
try {
final dir = Directory(localDir);
if (!dir.existsSync()) return;
for (final file in dir.listSync().whereType<File>().where(
(f) => f.path.toLowerCase().endsWith('.tcx'),
)) {
final name = file.path.substring(file.path.lastIndexOf('/') + 1);
await _ingest(name, await file.readAsBytes(), tally);
}
} on Exception catch (error) {
debugPrint(
'WorkoutApp: RunnerUp import WARNING: reading $localDir failed '
'($error); the PC copy still completes the import.',
);
}
}
/// Stores one export; returns what its file says made it (before it is
/// forced to history), or null when it is not a usable run.
Future<RunSource?> _ingest(
String name,
Uint8List bytes,
ImportTally tally,
) async {
final String xml;
final RunTrack decoded;
try {
xml = utf8.decode(bytes);
decoded = decodeTcx(xml, fileName: name);
} on FormatException catch (error) {
tally.skipped++;
debugPrint(
'WorkoutApp: RunnerUp import WARNING: $name is not a usable run '
'($error); skipped for good.',
);
return null;
}
// Forced, whatever the file says: an app-made TCX that came back through
// processed/ must not be re-uploaded, so it is history, never pending.
final track = RunTrack(
id: decoded.id,
sport: decoded.sport,
startedAt: decoded.startedAt,
points: decoded.points,
totalSeconds: decoded.totalSeconds,
distanceM: decoded.distanceM,
source: RunSource.runnerupImport,
);
final added = await StorageService.instance.insertRun(
track,
tcxFilename: name,
tcxSha256: tcxSha256(xml),
uploadState: RunUploadState.never,
);
added ? tally.imported++ : tally.duplicates++;
return decoded.source;
}
/// PUTs the marker; returns the failures (empty once the PC has it).
Future<List<String>> _deliver(SharedPreferences prefs, String marker) async {
final login = await _loadLogin();
String? failure;
if (login == null) {
failure = 'no dufs login in secure storage, so the PC was not told';
} else {
try {
await putImportMarker(_client, login, marker);
} on Exception catch (error) {
failure = 'telling the PC failed: $error';
}
}
if (failure != null) {
debugPrint('WorkoutApp: RunnerUp import WARNING: $failure');
runnerUpImportStatus.value = RunnerUpImportStatus.failed;
return [failure];
}
await prefs.remove(kImportMarkerKey);
runnerUpImportStatus.value = RunnerUpImportStatus.done;
return const [];
}
}

View File

@ -34,6 +34,7 @@ part 'storage_service_export.dart';
part 'storage_service_import.dart';
part 'storage_service_pc_link.dart';
part 'storage_service_runs.dart';
part 'storage_service_runs_transfer.dart';
part 'storage_service_sandbox.dart';
part 'storage_service_schema.dart';
part 'storage_service_sessions.dart';

View File

@ -22,16 +22,23 @@ extension StorageServiceBackup on StorageService {
final historyRows = await _db.query('workout_history');
final settingsRows = await _db.query('settings');
final eventRows = await _db.query('progression_events');
final runRows = await _runsForTransfer(appOnly: true);
if (historyRows.isEmpty) {
final existing = await BackupService.instance.readBackup();
final existingHistory =
(existing?['workout_history'] as List?) ?? const [];
if (existingHistory.isNotEmpty) {
final existingRuns = (existing?['runs'] as List?) ?? const [];
// A recorded run must not lift the history guard (a seeded DB that
// then records a run would erase months of sessions); only a DB with
// neither is also barred from erasing a runs-only backup.
if (existingHistory.isNotEmpty ||
(runRows.isEmpty && existingRuns.isNotEmpty)) {
debugPrint(
'WorkoutApp: REFUSING to overwrite the backup — this DB has no '
'workout history but the backup holds ${existingHistory.length} '
'session(s). A restore almost certainly failed; keeping the backup '
'workout history or runs but the backup holds '
'${existingHistory.length} session(s) and ${existingRuns.length} '
'run(s). A restore almost certainly failed; keeping the backup '
'so the data stays recoverable.',
);
return;
@ -43,6 +50,7 @@ extension StorageServiceBackup on StorageService {
'workout_history': historyRows,
'settings': settingsRows,
'progression_events': eventRows,
'runs': runRows,
});
}
@ -173,6 +181,8 @@ extension StorageServiceBackup on StorageService {
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// Absent from older backups; null merges as nothing.
await _mergeRuns(txn, backup['runs']);
});
}
}

View File

@ -7,8 +7,9 @@ part of 'storage_service.dart';
const kExportFormat = 'workout_app-export';
/// The format written now. Version 2 adds `updated_at` to `exercise_state` and
/// `settings` rows plus `manual_workouts`; version 1 files still import.
const kExportVersion = 2;
/// `settings` rows plus `manual_workouts`; version 3 adds `runs` (with their
/// points); version 1 and 2 files still import.
const kExportVersion = 3;
const _exportTables = [
'workout_history',
@ -47,6 +48,7 @@ extension StorageServiceExport on StorageService {
for (final row in settings)
if (!_isPerInstallSetting(row['key']! as String)) row,
],
'runs': await _runsForTransfer(appOnly: false),
'manual_workouts': manual == null
? null
: [

View File

@ -24,7 +24,7 @@ extension StorageServiceImport on StorageService {
/// [ImportReport.manualSkipped] says so. Idempotent.
Future<ImportReport> importDataReport(Map<String, dynamic> data) async {
if (!_exportTables.any((t) => data[t] is List)) {
if (data['manual_workouts'] is! List) {
if (data['manual_workouts'] is! List && data['runs'] is! List) {
throw const FormatException('not a workout_app export');
}
}
@ -36,6 +36,7 @@ extension StorageServiceImport on StorageService {
for (final table in _exportTables) {
rows += await _mergeTable(txn, table, data[table], fileWins, stamped);
}
rows += await _mergeRuns(txn, data['runs']);
});
if (rows > 0) unawaited(_backupNow());
final manual = await _importManual(data['manual_workouts']);

View File

@ -61,7 +61,7 @@ extension StorageServiceRuns on StorageService {
'an imported run must be RunUploadState.never',
);
}
return await _db.transaction((txn) async {
final added = await _db.transaction((txn) async {
final clash = await txn.query(
'runs',
columns: ['id'],
@ -95,6 +95,9 @@ extension StorageServiceRuns on StorageService {
await batch.commit(noResult: true);
return true;
});
// A recorded run exists nowhere else yet; the RunnerUp history does (PC).
if (added && track.source == RunSource.app) unawaited(_backupNow());
return added;
}
/// Whether a run with this TCX hash is stored.

View File

@ -0,0 +1,137 @@
// Runs in backup.json and the user export: the `runs` / `run_points` half of
// both, written and merged by one code path.
//
// A `part` of StorageService (it needs `_db`). The generic table merge cannot
// be reused: it strips every `id` column and falls back to a progression
// event key, which would store runs with NULL ids.
part of 'storage_service.dart';
/// Runs as portable rows.
extension StorageServiceRunsTransfer on StorageService {
/// Every run as a `runs` row plus `points`: ONE compact JSON string of
/// `[time_ms, lat, lon, alt, distance_m]` rows. A string, not nested lists,
/// so pretty-printing the document does not put every fix on its own line
/// (a year of runs would otherwise be tens of MB, re-encoded on every
/// backup).
///
/// [appOnly] leaves the RunnerUp history out: it is reproducible from the
/// PC, so the automatic backup carries only what exists nowhere else.
Future<List<Map<String, Object?>>> _runsForTransfer({
required bool appOnly,
}) async {
final rows = await _db.query(
'runs',
where: appOnly ? "source = 'app'" : null,
orderBy: 'started_at, id',
);
return [
for (final row in rows)
{...row, 'points': await _encodePoints(row['id']! as String)},
];
}
Future<String> _encodePoints(String runId) async {
final points = await _db.query(
'run_points',
where: 'run_id = ?',
whereArgs: [runId],
orderBy: 'seq',
);
return jsonEncode([
for (final p in points)
[p['time_ms'], p['lat'], p['lon'], p['alt'], p['distance_m']],
]);
}
/// Merges transferred [raw] runs into the database and returns how many
/// were new. Idempotent: a run whose id OR TCX hash is already stored is
/// left alone (and so are its points); a malformed entry is counted and
/// skipped, never fatal. An imported run is stored as `never` whatever the
/// file claims, since the table's CHECK would reject anything else.
Future<int> _mergeRuns(Transaction txn, Object? raw) async {
if (raw is! List) {
if (raw != null) _warnSkipped('runs', 1);
return 0;
}
final known = await txn.query('runs', columns: ['id', 'tcx_sha256']);
final ids = {for (final r in known) r['id']! as String};
final shas = {for (final r in known) r['tcx_sha256']! as String};
var written = 0;
var malformed = 0;
for (final entry in raw) {
final parsed = entry is Map<String, dynamic> ? _parseRun(entry) : null;
if (parsed == null) {
malformed++;
continue;
}
final (row, points) = parsed;
final id = row['id']! as String;
final sha = row['tcx_sha256']! as String;
if (ids.contains(id) || shas.contains(sha)) continue;
ids.add(id);
shas.add(sha);
await txn.insert('runs', row);
final batch = txn.batch();
for (final (seq, p) in points.indexed) {
batch.insert('run_points', {
'run_id': row['id'],
'seq': seq,
'time_ms': p[0],
'lat': p[1],
'lon': p[2],
'alt': p[3],
'distance_m': p[4],
});
}
await batch.commit(noResult: true);
written++;
}
if (malformed > 0) _warnSkipped('runs', malformed);
return written;
}
/// The `runs` row and decoded points of [e], or null when it is malformed.
(Map<String, Object?>, List<List<Object?>>)? _parseRun(
Map<String, dynamic> e,
) {
try {
final source = RunSource.fromStorageKey(e['source'] as String);
final state = source == RunSource.runnerupImport
? RunUploadState.never
: RunUploadState.fromStorageKey(e['upload_state'] as String);
final points = [
for (final p in jsonDecode(e['points'] as String) as List<dynamic>)
[
(p as List<dynamic>)[0] as int,
(p[1] as num).toDouble(),
(p[2] as num).toDouble(),
(p[3] as num?)?.toDouble(),
(p[4] as num).toDouble(),
],
];
final sport = RunSport.fromTcxName(e['sport'] as String)!.tcxName;
final id = e['id'] as String;
final sha = e['tcx_sha256'] as String;
if (id.isEmpty || sha.isEmpty) return null;
return (
{
'id': id,
'sport': sport,
'started_at': e['started_at'] as int,
'total_seconds': e['total_seconds'] as int,
'distance_m': (e['distance_m'] as num).toDouble(),
'source': source.storageKey,
'upload_state': state.storageKey,
'tcx_filename': e['tcx_filename'] as String,
'tcx_sha256': sha,
},
points,
);
} on Object catch (error) {
// Wrong shape, unknown enum key, or a points string that is not JSON;
// the caller also logs the total via _warnSkipped.
debugPrint('WorkoutApp: malformed run entry skipped: $error');
return null;
}
}
}

View File

@ -0,0 +1,204 @@
/// The map of a run: route polyline, start/end markers, live position.
///
/// Two constructors, both drop-ins (no other required argument):
///
/// * `RunMap.route(points: track.points)` -- a finished run. The camera
/// fits the whole route ([fitPadding] keeps it clear of overlays).
/// * `RunMap.live(trackListenable: notifier)` -- the live screen. Pass a
/// `ValueListenable<RunTrack?>` (a `ValueNotifier<RunTrack?>`, or the
/// recorder's own, anything that holds the current track); the route
/// redraws on every change and, in follow mode (the default), the camera
/// keeps the last point centred. Dragging the map ends follow mode; the
/// recentre button (icon only) turns it back on. The listenable must stay
/// the same object for the widget's lifetime (give it a new `Key` to swap).
///
/// Tiles are OSM raster tiles from tile.openstreetmap.org, cached on disk
/// (`map_tile_cache.dart`) so revisited areas draw offline. The OSM credit is
/// a collapsed info icon; its text shows only when tapped. When no tile
/// loads (offline, first visit) the route still draws on the plain
/// background and ONE warning-level line is logged per map.
///
/// Tests inject `tileProviderOpener` with a fake provider: nothing here
/// touches the network by itself.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/run_map_layers.dart';
/// Where an empty map starts: Poland, zoomed out, until a first fix.
const _fallbackCenter = LatLng(52, 19);
const _fallbackZoom = 5.0;
/// The zoom a one-point track or a first live fix gets.
const _closeZoom = 17.0;
/// A map of a run; see the library comment for the two constructors.
class RunMap extends StatefulWidget {
/// A finished run: [points] drawn and fitted.
const RunMap.route({
required List<RunPoint> this.points,
super.key,
this.fitPadding = const EdgeInsets.all(40),
this.tileProviderOpener = openOsmTileProvider,
}) : trackListenable = null;
/// A live run: follows the last point of the track in [trackListenable].
const RunMap.live({
required ValueListenable<RunTrack?> this.trackListenable,
super.key,
this.tileProviderOpener = openOsmTileProvider,
}) : points = null,
fitPadding = const EdgeInsets.all(40);
/// The route in detail mode; null in live mode.
final List<RunPoint>? points;
/// The current track in live mode; null in detail mode.
final ValueListenable<RunTrack?>? trackListenable;
/// Space kept free around the fitted route (detail mode).
final EdgeInsets fitPadding;
/// Where the tile provider comes from; tests pass a fake.
final TileProviderOpener tileProviderOpener;
@override
State<RunMap> createState() => _RunMapState();
}
class _RunMapState extends State<RunMap> {
final _controller = MapController();
TileProvider? _tiles;
bool _ready = false;
bool _follow = true;
bool _warned = false;
bool get _live => widget.trackListenable != null;
List<RunPoint> get _points => _live
? widget.trackListenable!.value?.points ?? const []
: widget.points!;
@override
void initState() {
super.initState();
widget.trackListenable?.addListener(_followLast);
unawaited(
widget.tileProviderOpener().then((p) {
if (mounted) setState(() => _tiles = p);
}),
);
}
@override
void dispose() {
widget.trackListenable?.removeListener(_followLast);
_controller.dispose();
super.dispose();
}
void _followLast() {
final coords = toLatLngs(_points);
if (!_ready || !_follow || coords.isEmpty) return;
final zoom = _controller.camera.zoom;
_controller.move(coords.last, zoom < 12 ? _closeZoom : zoom);
}
void _warnOnce(Object error) {
if (_warned) return;
_warned = true;
logMapWarning(
'map tiles failed to load ($error); drawing the route on a plain '
'background',
);
}
MapOptions _options(List<LatLng> coords, ColorScheme scheme) {
final fit = !_live && coords.length > 1
? CameraFit.coordinates(
coordinates: coords,
padding: widget.fitPadding,
maxZoom: _closeZoom,
)
: null;
return MapOptions(
initialCenter: coords.isEmpty ? _fallbackCenter : coords.last,
initialZoom: coords.isEmpty ? _fallbackZoom : _closeZoom,
initialCameraFit: fit,
backgroundColor: scheme.surfaceContainerHigh,
onMapReady: () {
_ready = true;
_followLast();
},
onPositionChanged: (camera, byGesture) {
if (_live && byGesture && _follow) setState(() => _follow = false);
},
);
}
Widget _map(BuildContext context, List<RunPoint> points) {
final scheme = Theme.of(context).colorScheme;
final coords = toLatLngs(points);
return FlutterMap(
mapController: _controller,
options: _options(coords, scheme),
children: [
if (_tiles != null)
TileLayer(
urlTemplate: osmTileUrl,
userAgentPackageName: osmUserAgentPackage,
tileProvider: _tiles,
errorTileCallback: (tile, error, stack) => _warnOnce(error),
),
if (coords.length > 1) routeLayer(coords, scheme),
if (coords.isNotEmpty)
endpointLayer(
coords,
live: _live,
scheme: scheme,
status: Theme.of(context).extension<AppStatusColors>()!,
),
attributionButton(scheme),
],
);
}
@override
Widget build(BuildContext context) {
final listenable = widget.trackListenable;
if (listenable == null) return _map(context, widget.points!);
return Stack(
fit: StackFit.expand,
children: [
ValueListenableBuilder<RunTrack?>(
valueListenable: listenable,
builder: (context, track, _) =>
_map(context, track?.points ?? const []),
),
if (!_follow)
Positioned(
left: AppSpacing.sm,
bottom: AppSpacing.sm,
child: IconButton.filled(
onPressed: () {
setState(() => _follow = true);
_followLast();
},
icon: const Icon(
Icons.my_location,
semanticLabel: 'Follow position',
),
),
),
],
);
}
}

View File

@ -0,0 +1,82 @@
/// The route, endpoint markers and attribution button drawn by `RunMap`.
library;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/ui/theme.dart';
/// [points] as map coordinates.
List<LatLng> toLatLngs(List<RunPoint> points) => [
for (final p in points) LatLng(p.latitude, p.longitude),
];
/// The route as one polyline; drawn even when no tile ever loads.
PolylineLayer routeLayer(List<LatLng> coords, ColorScheme scheme) =>
PolylineLayer(
polylines: [
Polyline(
points: coords,
strokeWidth: 5,
color: scheme.primary,
borderStrokeWidth: 2,
borderColor: scheme.surface,
),
],
);
Marker _dot(LatLng at, Color fill, IconData icon, Color ink) => Marker(
point: at,
child: DecoratedBox(
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
border: Border.all(color: ink, width: 2),
),
child: Icon(icon, size: 16, color: ink),
),
);
/// Start marker (green play), and either the live position (accent dot) or
/// the finish flag (red); no end marker for a one-point track.
MarkerLayer endpointLayer(
List<LatLng> coords, {
required bool live,
required ColorScheme scheme,
required AppStatusColors status,
}) {
final start = _dot(
coords.first,
status.success,
Icons.play_arrow,
scheme.surface,
);
final last = coords.last;
return MarkerLayer(
markers: [
start,
if (live)
_dot(last, scheme.primary, Icons.navigation, scheme.surface)
else if (coords.length > 1)
_dot(last, scheme.error, Icons.flag, scheme.surface),
],
);
}
/// The collapsed, icon-only OSM credit; the (c) text shows only when tapped.
RichAttributionWidget attributionButton(ColorScheme scheme) =>
RichAttributionWidget(
showFlutterMapAttribution: false,
attributions: const [TextSourceAttribution('OpenStreetMap contributors')],
popupBackgroundColor: scheme.surfaceContainerHigh,
openButton: (context, open) => IconButton(
onPressed: open,
icon: Icon(
Icons.info_outline,
size: 20,
color: scheme.onSurface,
semanticLabel: 'Map attribution',
),
),
);

View File

@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:workout_app/services/runnerup_importer.dart';
/// The RunnerUp history import's state as a glyph, never as words: a small
/// spinner while it runs, a warning glyph when it failed (the next start
/// retries), nothing when it is idle or finished.
class RunnerUpImportBadge extends StatelessWidget {
/// Creates the badge; [status] defaults to the importer's live status.
const RunnerUpImportBadge({super.key, this.status});
/// Overrides [runnerUpImportStatus] (tests).
final ValueNotifier<RunnerUpImportStatus>? status;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<RunnerUpImportStatus>(
valueListenable: status ?? runnerUpImportStatus,
builder: (context, state, _) => switch (state) {
RunnerUpImportStatus.running => const Padding(
padding: EdgeInsets.all(14),
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
RunnerUpImportStatus.failed => Padding(
padding: const EdgeInsets.all(14),
child: Icon(
Icons.sync_problem,
color: Theme.of(context).colorScheme.error,
),
),
RunnerUpImportStatus.idle ||
RunnerUpImportStatus.done => const SizedBox.shrink(),
},
);
}
}

View File

@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:workout_app/screens/history_screen.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
@ -35,12 +36,14 @@ Widget wrapHistory({
http.Client? httpClient,
Future<FirebaseRestClient?> Function()? firebaseFactory,
DateTime Function()? clock,
TileProviderOpener? tileProviderOpener,
}) => MaterialApp(
theme: buildAppTheme(),
home: HistoryScreen(
httpClient: httpClient,
firebaseFactory: firebaseFactory ?? () async => null,
clock: clock,
tileProviderOpener: tileProviderOpener ?? openOsmTileProvider,
),
);

View File

@ -0,0 +1,157 @@
// Runs in the history: the overview strip, the one-entry-per-run merge with
// the PC's runnerup_verified rows, the calendar, and opening the detail.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/screens/run_detail_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/tcx_codec.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/widgets/calendar_widget.dart';
import '../fake_secure_storage.dart';
import '../widgets/_run_map_fixtures.dart';
import '_history_test_fixtures.dart';
// historySyncMock() holds a PC run and a PC manual workout, both dated this.
final _pcDay = DateTime(2026, 7, 13, 12);
Future<void> _seedRun(
WidgetTester tester,
DateTime startedAt, {
RunSport sport = RunSport.running,
}) async {
final track = buildTrack(
startedAt: startedAt.toUtc(),
sport: sport,
count: 3,
);
await tester.runAsync(
() => StorageService.instance.insertRun(
track,
tcxFilename: 'run_${track.id}.tcx',
tcxSha256: tcxSha256(encodeTcx(track)),
uploadState: RunUploadState.pending,
),
);
}
Future<void> _pump(WidgetTester tester, {bool withPc = false}) async {
if (withPc) installFakeSecureStorage(initial: {'sync.token': 'tok'});
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.625;
addTearDown(tester.view.reset);
await pumpHistory(
tester,
wrapHistory(
httpClient: withPc ? historySyncMock() : null,
tileProviderOpener: fakeTiles(),
),
);
}
Future<void> _scrollToSynced(WidgetTester tester) => tester.scrollUntilVisible(
find.text('SYNCED FROM PC'),
200,
scrollable: find.byType(Scrollable).first,
);
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
installFakeSecureStorage();
});
testWidgets('runs alone are history: no empty state, a tile per run', (
tester,
) async {
await _seedRun(tester, DateTime(2026, 7, 13, 12));
await _seedRun(tester, DateTime(2026, 7, 14, 12), sport: RunSport.walking);
await _pump(tester);
expect(find.text('No workouts yet.'), findsNothing);
expect(find.byKey(const Key('runs_strip')), findsOneWidget);
expect(find.text('5.20'), findsNWidgets(2));
expect(find.text('32:10'), findsNWidgets(2));
expect(find.text('14.07'), findsOneWidget);
expect(find.byIcon(Icons.directions_walk), findsOneWidget);
});
testWidgets('no runs: no strip', (tester) async {
await _pump(tester);
expect(find.byKey(const Key('runs_strip')), findsNothing);
});
testWidgets('the local run replaces the PC entry of its day, once', (
tester,
) async {
await _seedRun(tester, _pcDay);
await _pump(tester, withPc: true);
await _scrollToSynced(tester);
// The PC's manual workout of that day stays; its run entry is gone.
expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget);
expect(find.textContaining('2026-07-13 · Run'), findsNothing);
expect(find.text('5.20'), findsOneWidget);
});
testWidgets('a run on another day leaves the PC entry visible', (
tester,
) async {
await _seedRun(tester, DateTime(2026, 7, 20, 12));
await _pump(tester, withPc: true);
await _scrollToSynced(tester);
expect(find.textContaining('2026-07-13 · Run'), findsOneWidget);
expect(find.text('5.20'), findsOneWidget);
});
testWidgets('a run marks its day on the calendar', (tester) async {
await _seedRun(tester, DateTime(2026, 7, 13, 12));
await _pump(tester);
final calendar = tester.widget<WorkoutCalendar>(
find.byType(WorkoutCalendar),
);
expect(calendar.workoutDates, contains('2026-07-13'));
});
testWidgets('tapping a run opens its detail, back returns', (tester) async {
await _seedRun(tester, DateTime(2026, 7, 13, 12));
await _pump(tester);
await tester.tap(find.text('5.20'));
await tester.pumpAndSettle();
// getRun is real sqflite: let it finish on the real event loop.
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pumpAndSettle();
expect(find.byType(RunDetailScreen), findsOneWidget);
expect(find.text('2026-07-13 12:00'), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.byType(RunDetailScreen), findsNothing);
expect(find.byKey(const Key('runs_strip')), findsOneWidget);
});
testWidgets('a run deleted before the tap lands opens nothing', (
tester,
) async {
await _seedRun(tester, DateTime(2026, 7, 13, 12));
await _pump(tester);
await tester.runAsync(
() => StorageService.instance.deleteRun(
RunTrack.idFor(DateTime(2026, 7, 13, 12).toUtc()),
),
);
await tester.tap(find.text('5.20'));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pumpAndSettle();
expect(find.byType(RunDetailScreen), findsNothing);
});
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/screens/run_detail_screen.dart';
import 'package:workout_app/widgets/run_map.dart';
import '../widgets/_run_map_fixtures.dart';
// Pixel 6a, physical px (adb dumpsys window), per build-mobile-app R7.
const _phonePhysical = Size(1080, 2400);
const _phoneDpr = 2.625;
Future<void> _pump(WidgetTester tester, RunTrack track) async {
await tester.pumpWidget(
themed(RunDetailScreen(track: track, tileProviderOpener: fakeTiles())),
);
await tester.pump();
await tester.pump();
}
void _phone(WidgetTester tester) {
tester.view.physicalSize = _phonePhysical;
tester.view.devicePixelRatio = _phoneDpr;
tester.view.padding = const FakeViewPadding(top: 132, bottom: 63);
addTearDown(tester.view.reset);
}
void main() {
setUpAll(loadRoboto);
testWidgets('shows the route map and the four numbers', (tester) async {
_phone(tester);
final track = buildTrack(startedAt: DateTime(2026, 11, 10, 6, 30).toUtc());
await _pump(tester, track);
expect(find.byType(RunMap), findsOneWidget);
expect(find.text('5.20'), findsOneWidget);
expect(find.text('32:10'), findsOneWidget);
expect(find.text('6:11'), findsOneWidget);
expect(find.text('2026-11-10 06:30'), findsOneWidget);
expect(find.byIcon(Icons.directions_run), findsOneWidget);
expect(find.byIcon(Icons.history), findsNothing);
});
testWidgets('a walk gets the walking icon, an import the history mark', (
tester,
) async {
_phone(tester);
await _pump(
tester,
buildTrack(sport: RunSport.walking, source: RunSource.runnerupImport),
);
expect(find.byIcon(Icons.directions_walk), findsOneWidget);
expect(find.byIcon(Icons.directions_run), findsNothing);
expect(find.byIcon(Icons.history), findsOneWidget);
});
testWidgets('no words: every visible text is digits and separators', (
tester,
) async {
_phone(tester);
await _pump(tester, buildTrack(totalSeconds: 3725));
final numeric = RegExp(r'^[0-9:.\- ]+$');
final words = tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data ?? '')
// The OSM credit is mandatory; it is fully transparent until the
// collapsed info icon is tapped (see run_map_test.dart).
.where((t) => !numeric.hasMatch(t) && !t.contains('OpenStreetMap'));
expect(words, isEmpty);
expect(find.text('1:02:05'), findsOneWidget);
});
testWidgets('a zero-distance run has a dash pace, not a crash', (
tester,
) async {
_phone(tester);
await _pump(tester, buildTrack(count: 1, distanceM: 0, totalSeconds: 0));
expect(find.text('-:--'), findsOneWidget);
expect(find.text('0.00'), findsOneWidget);
});
testWidgets('the back button leaves the screen', (tester) async {
_phone(tester);
final track = buildTrack();
await tester.pumpWidget(
themed(
Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => RunDetailScreen(
track: track,
tileProviderOpener: fakeTiles(),
),
),
),
child: const Icon(Icons.add),
),
),
),
);
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.byType(RunDetailScreen), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.byType(RunDetailScreen), findsNothing);
});
testWidgets('Pixel 6a insets: one screen, nothing scrolls', (tester) async {
_phone(tester);
await _pump(tester, buildTrack(totalSeconds: 3725));
final scrollables = tester.stateList<ScrollableState>(
find.byType(Scrollable),
);
for (final s in scrollables) {
expect(s.position.maxScrollExtent, 0);
}
// With no list on the screen the loop above is vacuously true, so also
// pin the geometry: panel above the nav bar, back button below the
// status bar.
final viewport = _phonePhysical / _phoneDpr;
final panel = tester.getRect(find.byKey(const Key('run_stats_panel')));
expect(panel.bottom, lessThanOrEqualTo(viewport.height - 63 / _phoneDpr));
expect(panel.left, greaterThanOrEqualTo(0));
expect(panel.right, lessThanOrEqualTo(viewport.width));
final back = tester.getRect(find.byIcon(Icons.arrow_back));
expect(back.top, greaterThanOrEqualTo(132 / _phoneDpr));
expect(tester.takeException(), isNull);
});
}

View File

@ -45,7 +45,7 @@ void main() {
expect(find.byType(RunMapSlot), findsOneWidget);
expect(_text('run_time'), '0:00');
expect(_text('run_distance'), '0.00');
expect(_text('run_pace'), '--:--');
expect(_text('run_pace'), '-:--');
expect(key('run_upload_badge'), findsOneWidget);
expect(h.uploader.passes, 1);
});

View File

@ -0,0 +1,118 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/dufs_credentials.dart';
import 'package:workout_app/services/runnerup_importer.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/tcx_codec.dart';
const String kFixtureDir = '../../screen_locker/tests/fixtures/run_tcx';
/// The three golden exports, by file name.
Map<String, Uint8List> fixtureTcx() => {
for (final f in Directory(kFixtureDir).listSync().whereType<File>())
if (f.path.endsWith('.tcx')) f.uri.pathSegments.last: f.readAsBytesSync(),
};
final DufsCredentials kLogin = DufsCredentials(
url: 'https://dufs.test/',
user: 'runnerup',
password: 'pw',
);
Future<DufsCredentials?> fakeLogin() async => kLogin;
/// A dufs stand-in that records every request; nothing touches a network.
class FakeDufs {
FakeDufs(this.files);
/// `processed/` contents by name.
final Map<String, Uint8List> files;
/// `METHOD /path` of every request, in order.
final List<String> requests = [];
/// The marker bodies PUT so far.
final List<String> markers = [];
int listStatus = 207;
int markerStatus = 201;
Set<String> failingGets = {};
bool unreachable = false;
String? depth;
late final MockClient client = MockClient((request) async {
requests.add('${request.method} ${request.url.path}');
if (unreachable) throw const SocketException('network is down');
if (request.headers['Authorization'] != kLogin.authorization) {
return http.Response('unauthorized', 401);
}
if (request.method == 'PROPFIND') {
depth = request.headers['Depth'];
return http.Response(
listStatus == 207 ? multistatus(files.keys) : 'denied',
listStatus,
);
}
if (request.method == 'PUT') {
markers.add(request.body);
return http.Response('', markerStatus);
}
final name = Uri.decodeComponent(request.url.pathSegments.last);
if (failingGets.contains(name)) return http.Response('boom', 500);
return http.Response.bytes(files[name]!, 200);
});
Iterable<String> get puts => requests.where((r) => r.startsWith('PUT '));
Iterable<String> get gets => requests.where((r) => r.startsWith('GET '));
}
/// A dufs-shaped multistatus: the collection first, then each file.
String multistatus(Iterable<String> names) =>
'''
<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response><D:href>/RunnerUp/processed/</D:href><D:propstat><D:prop>
<D:resourcetype><D:collection/></D:resourcetype></D:prop>
<D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
${names.map((n) => '<D:response><D:href>/RunnerUp/processed/${Uri.encodeComponent(n)}</D:href><D:propstat><D:prop><D:resourcetype></D:resourcetype></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>').join('\n')}
<D:response><D:href>/RunnerUp/processed/same.gpx</D:href></D:response>
</D:multistatus>''';
Uint8List utf8Bytes(String s) => Uint8List.fromList(utf8.encode(s));
final DateTime kNow = DateTime.utc(2026, 11, 10, 8);
RunnerUpImporter makeImporter(
FakeDufs dufs, {
Future<bool> Function()? canRead,
String localDir = '/nonexistent/RunnerUp',
bool android = true,
}) => RunnerUpImporter(
client: dufs.client,
loadLogin: fakeLogin,
canReadStorage: canRead ?? () async => false,
localDir: localDir,
isAndroid: android,
now: () => kNow,
);
Future<List<RunRecord>> storedRuns() => StorageService.instance.listRuns();
/// What the marker must list for [files]: every RunnerUp-authored run once
/// (a `Pixel_6a_` twin is the same run), never an app-authored file.
List<String> expectedMarkerNames(Map<String, Uint8List> files) {
final seen = <String>{};
return [
for (final name in files.keys.toList()..sort())
if (decodeTcx(utf8.decode(files[name]!), fileName: name).source ==
RunSource.runnerupImport &&
seen.add(name.replaceFirst('Pixel_6a_', '')))
name,
];
}

View File

@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import '../fake_path_provider.dart';
import '../widgets/_run_map_fixtures.dart';
/// A platform with no cache directory (getApplicationCacheDirectory throws).
class _NoCacheDir extends PathProviderPlatform {
@override
Future<String?> getApplicationCachePath() async => null;
}
void main() {
setUp(resetOsmTileProvider);
test('the OSM source and policy constants', () {
expect(osmTileUrl, 'https://tile.openstreetmap.org/{z}/{x}/{y}.png');
expect(osmUserAgentPackage, 'com.kuhy.workout_app');
expect(tileMaxStale, const Duration(days: 365));
});
test('buildCachedTileProvider caches on disk under the given dir', () {
final dir = Directory.systemTemp.createTempSync('tile_cache_test_');
addTearDown(() => dir.deleteSync(recursive: true));
final provider = buildCachedTileProvider('${dir.path}/osm_tiles');
expect(provider, isA<CachedTileProvider>());
expect(provider.supportsCancelLoading, isTrue);
});
test('every open is a fresh cached provider (TileLayer disposes it)', () async {
final dir = installFakePathProvider();
addTearDown(() => dir.deleteSync(recursive: true));
final first = await openOsmTileProvider();
final second = await openOsmTileProvider();
expect(first, isA<CachedTileProvider>());
expect(second, isA<CachedTileProvider>());
expect(identical(second, first), isFalse);
});
test('without a cache dir: network provider and a warning', () async {
PathProviderPlatform.instance = _NoCacheDir();
late TileProvider provider;
final lines = await capturingDebugPrint(() async {
provider = await openOsmTileProvider();
});
expect(provider, isA<NetworkTileProvider>());
// Looked up once: the second map falls back without a second warning.
expect(await openOsmTileProvider(), isA<NetworkTileProvider>());
expect(
lines.where((l) => l.contains('WARNING') && l.contains('persist')),
hasLength(1),
);
});
}

View File

@ -73,7 +73,7 @@ void main() {
expect(service.starts, [true]);
expect(controller.serviceState, RunServiceState.running);
expect(service.calls, ['has', 'start']);
expect(service.drawn.single, '0.00|0:00 --:--');
expect(service.drawn.single, '0.00|0:00 -:--');
expect(controller.active, isTrue);
controller.dispose();
});

View File

@ -2,35 +2,30 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/run_format.dart';
void main() {
test('durations are m:ss, then h:mm:ss, never negative', () {
test('distance is km with two decimals', () {
expect(formatDistanceKm(5200), '5.20');
expect(formatDistanceKm(0), '0.00');
expect(formatDistanceKm(12345.6), '12.35');
});
test('duration is m:ss under an hour, h:mm:ss from it', () {
expect(formatDuration(0), '0:00');
expect(formatDuration(723), '12:03');
expect(formatDuration(65), '1:05');
expect(formatDuration(3599), '59:59');
expect(formatDuration(3600), '1:00:00');
expect(formatDuration(3725), '1:02:05');
expect(formatDuration(-4), '0:00');
});
test('distance is kilometres with two decimals', () {
expect(formatKm(0), '0.00');
expect(formatKm(3524), '3.52');
test('pace is m:ss per km, and a dash when nothing was covered', () {
expect(formatPace(seconds: 1800, distanceM: 5000), '6:00');
expect(formatPace(seconds: 1930, distanceM: 5200), '6:11');
expect(formatPace(seconds: 100, distanceM: 9.9), '-:--');
expect(formatPace(seconds: 100, distanceM: 0), '-:--');
});
test('pace needs 20 m and is m:ss per km', () {
expect(paceSecPerKm(10, 19.9), isNull);
expect(paceSecPerKm(312, 1000), 312);
expect(formatPace(312), '5:12');
expect(formatPace(null), '--:--');
expect(formatPace(double.infinity), '--:--');
expect(formatPace(double.nan), '--:--');
});
test('the notification is numbers only', () {
final copy = runNotificationCopy(metres: 3524, movingSeconds: 723);
expect(copy.title, '3.52');
expect(copy.body, '12:03 3:25');
expect(
runNotificationCopy(metres: 0, movingSeconds: 0).body,
'0:00 --:--',
);
test('dates are shown in local time', () {
final when = DateTime(2026, 11, 3, 7, 5);
expect(formatRunDay(when.toUtc()), '03.11');
expect(formatFullDate(when.toUtc()), '2026-11-03 07:05');
});
}

View File

@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/run_history_merge.dart';
RunRecord _run(
DateTime startedAt, {
RunSport sport = RunSport.running,
RunSource source = RunSource.app,
}) => RunRecord(
track: RunTrack.started(
sport: sport,
startedAt: startedAt,
points: const [],
totalSeconds: 1800,
distanceM: 5000,
source: source,
),
uploadState: RunUploadState.never,
tcxFilename: 'x.tcx',
tcxSha256: 'x',
);
Map<String, dynamic> _pc(
String date, {
String kind = kRunnerUpVerifiedKind,
String? activity,
}) => {
'kind': kind,
'date': date,
'distance_km': 5.0,
if (activity != null) 'activity': activity,
};
void main() {
// Local noon: the same calendar day in every time zone the suite may run in.
final noon = DateTime(2026, 11, 9, 12);
test('a local run hides the PC run entry of its day', () {
final out = withoutLocalRuns([_pc('2026-11-09')], [_run(noon)]);
expect(out, isEmpty);
});
test('another day, or no local run, keeps the PC entry', () {
final entries = [_pc('2026-11-08'), _pc('2026-11-09')];
expect(withoutLocalRuns(entries, [_run(noon)]), [_pc('2026-11-08')]);
expect(withoutLocalRuns(entries, const []), entries);
});
test('an imported run covers the day too', () {
final out = withoutLocalRuns([
_pc('2026-11-09'),
], [_run(noon, source: RunSource.runnerupImport)]);
expect(out, isEmpty);
});
test('sport must match: a walk does not hide a run, nor a run a walk', () {
final walk = _run(noon, sport: RunSport.walking);
final pcRun = _pc('2026-11-09');
final pcWalk = _pc('2026-11-09', activity: 'walking');
expect(withoutLocalRuns([pcRun], [walk]), [pcRun]);
expect(withoutLocalRuns([pcWalk], [walk]), isEmpty);
expect(withoutLocalRuns([pcWalk], [_run(noon)]), [pcWalk]);
});
test('other kinds are never hidden', () {
final manual = _pc('2026-11-09', kind: 'manual_workout');
expect(withoutLocalRuns([manual], [_run(noon)]), [manual]);
});
test('a full timestamp date still matches on its day', () {
final out = withoutLocalRuns([_pc('2026-11-09T06:30:00')], [_run(noon)]);
expect(out, isEmpty);
});
test('the day is the local one, not the UTC one', () {
// 00:30 local on the 9th is the 8th in UTC for any zone east of UTC.
final early = DateTime(2026, 11, 9, 0, 30);
expect(withoutLocalRuns([_pc('2026-11-09')], [_run(early)]), isEmpty);
});
}

View File

@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/run_notification.dart';
void main() {
test('the notification is distance, then time and pace, digits only', () {
final copy = runNotificationCopy(metres: 3524, movingSeconds: 723);
expect(copy.title, '3.52');
expect(copy.body, '12:03 3:25');
});
test('before a pace exists the body shows a dash', () {
final copy = runNotificationCopy(metres: 0, movingSeconds: 0);
expect(copy.title, '0.00');
expect(copy.body, '0:00 -:--');
});
}

View File

@ -25,7 +25,6 @@ void main() {
expect(recorder.livePoints.value, hasLength(340));
expect(recorder.distanceM, closeTo(2034, 5));
expect(recorder.seconds, 678);
expect(recorder.pace, closeTo(333, 3));
final track = (await recorder.finish())!;
expect(recorder.phase, RunPhase.finished);
@ -276,7 +275,6 @@ void main() {
final recorder = _recorder(ManualPositionSource());
expect(await recorder.finish(), isNull);
expect(recorder.distanceM, 0);
expect(recorder.pace, isNull);
recorder.dispose();
});
});

View File

@ -9,6 +9,7 @@ import 'package:http/testing.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/dufs_credentials.dart';
import 'package:workout_app/services/run_saver.dart';
import 'package:workout_app/services/run_upload_credentials.dart';
import 'package:workout_app/services/run_uploader.dart';
@ -18,8 +19,8 @@ import 'package:workout_app/services/tcx_codec.dart';
import '../fake_secure_storage.dart';
import '../run_test_fixtures.dart';
const _login = RunUploadCredentials(
baseUrl: 'https://cloud.example',
final _login = DufsCredentials(
url: 'https://cloud.example',
user: 'workout_app',
password: 'pw',
);
@ -28,12 +29,12 @@ const _login = RunUploadCredentials(
class _FixedStore implements RunCredentialStore {
_FixedStore(this._login, {this.onRead, this.throwing = false});
final RunUploadCredentials? _login;
final DufsCredentials? _login;
final Future<void> Function()? onRead;
final bool throwing;
@override
Future<RunUploadCredentials?> read() async {
Future<DufsCredentials?> read() async {
await onRead?.call();
if (throwing) throw PlatformException(code: 'keystore');
return _login;
@ -302,11 +303,7 @@ void main() {
await _save('2026-11-09T12:00:00Z');
final up = RunUploader(
credentials: _FixedStore(
const RunUploadCredentials(
baseUrl: 'http://127.0.0.1:1',
user: 'u',
password: 'p',
),
DufsCredentials(url: 'http://127.0.0.1:1', user: 'u', password: 'p'),
),
);
final status = await up.uploadPending();
@ -343,11 +340,11 @@ void main() {
expect(outcome.status, ProvisionStatus.imported);
expect(file().existsSync(), isFalse);
final login = (await s.read())!;
expect(login.baseUrl, 'https://cloud.example');
expect(login.url, 'https://cloud.example');
expect(login.user, 'workout_app');
expect(login.password, 'secret');
expect(
login.basicAuth,
login.authorization,
'Basic ${base64.encode(utf8.encode('workout_app:secret'))}',
);
});

View File

@ -0,0 +1,189 @@
@TestOn('vm')
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/runnerup_importer.dart';
import 'package:workout_app/services/storage_service.dart';
import '_runnerup_fake_dufs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeDufs dufs;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
SharedPreferences.setMockInitialValues({});
Sandbox.enabled = false;
runnerUpImportStatus.value = RunnerUpImportStatus.idle;
StorageService.resetForTesting();
await StorageService.init();
dufs = FakeDufs(fixtureTcx());
});
tearDown(() => Sandbox.enabled = false);
test(
'401 leaves it incomplete and failed, then a fix completes it',
() async {
dufs.listStatus = 401;
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures.single, contains('401'));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
expect(dufs.puts, isEmpty);
expect(await storedRuns(), isEmpty);
dufs.listStatus = 207;
final retry = await makeImporter(dufs).runIfNeeded();
expect(retry.failures, isEmpty);
expect(retry.imported, 3);
expect(dufs.puts, hasLength(1));
},
);
test(
'one failed download keeps it incomplete; the rest are stored',
() async {
final bad = dufs.files.keys.first;
dufs.failingGets = {bad};
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures.single, contains(bad));
expect(report.imported, 2);
expect(dufs.puts, isEmpty);
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
dufs.failingGets = {};
final retry = await makeImporter(dufs).runIfNeeded();
expect(retry.failures, isEmpty);
expect(retry.imported, 1);
expect(retry.duplicates, 2);
expect(dufs.puts, hasLength(1));
expect(await storedRuns(), hasLength(3));
},
);
test('an unreachable PC is a failure, not a crash', () async {
dufs.unreachable = true;
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures.single, contains('network is down'));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
});
test('a file that is no usable run is skipped for good', () async {
dufs.files['junk_Running.tcx'] = utf8Bytes('not xml');
dufs.files['bytes_Running.tcx'] = Uint8List.fromList([0xff, 0xfe, 0xfd]);
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures, isEmpty);
expect(report.skipped, 2);
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
expect(marker['count'], expectedMarkerNames(fixtureTcx()).length);
expect(marker['tcx_names'], isNot(contains('junk_Running.tcx')));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
});
test(
'a failed marker PUT keeps the import and retries only the PUT',
() async {
dufs.markerStatus = 500;
final first = await makeImporter(dufs).runIfNeeded();
expect(first.failures.single, contains('500'));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
expect(await storedRuns(), hasLength(3));
dufs.markerStatus = 201;
dufs.requests.clear();
final retry = await makeImporter(dufs).runIfNeeded();
expect(retry.failures, isEmpty);
expect(dufs.requests, ['PUT /RunnerUp/.workout_app_import.json']);
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
dufs.requests.clear();
await makeImporter(dufs).runIfNeeded();
expect(
dufs.requests,
isEmpty,
reason: 'marker delivered: truly finished',
);
},
);
test('without a dufs login it fails loudly and fetches nothing', () async {
final importer = RunnerUpImporter(
client: dufs.client,
loadLogin: () async => null,
canReadStorage: () async => false,
isAndroid: true,
);
final report = await importer.runIfNeeded();
expect(report.failures.single, contains('no dufs login'));
expect(dufs.requests, isEmpty);
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
});
test('the login vanishing before the marker PUT is a failure too', () async {
var calls = 0;
final importer = RunnerUpImporter(
client: dufs.client,
loadLogin: () async => calls++ == 0 ? kLogin : null,
canReadStorage: () async => false,
isAndroid: true,
);
final report = await importer.runIfNeeded();
expect(report.failures.single, contains('PC was not told'));
expect(dufs.puts, isEmpty);
});
test(
'a throw outside the per-file handling fails loudly, not forever',
() async {
final importer = RunnerUpImporter(
client: dufs.client,
loadLogin: fakeLogin,
canReadStorage: () async => throw StateError('platform down'),
isAndroid: true,
);
final report = await importer.runGuarded();
expect(report.failures.single, contains('platform down'));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.failed);
},
);
test('the sandbox never imports', () async {
Sandbox.enabled = true;
final report = await makeImporter(dufs).runIfNeeded();
await SandboxLog.flush();
expect(report.imported, 0);
expect(dufs.requests, isEmpty);
expect(await storedRuns(), isEmpty);
expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle);
});
test('off the phone it does nothing (no early marker)', () async {
await makeImporter(dufs, android: false).runIfNeeded();
expect(dufs.requests, isEmpty);
expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle);
});
test('with every default it is inert on the desktop test host', () async {
final report = await RunnerUpImporter().runIfNeeded();
expect(report.imported, 0);
expect(runnerUpImportStatus.value, RunnerUpImportStatus.idle);
});
}

View File

@ -0,0 +1,147 @@
@TestOn('vm')
library;
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/runnerup_dav.dart';
import 'package:workout_app/services/runnerup_importer.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/tcx_codec.dart';
import '_runnerup_fake_dufs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeDufs dufs;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
SharedPreferences.setMockInitialValues({});
Sandbox.enabled = false;
runnerUpImportStatus.value = RunnerUpImportStatus.idle;
StorageService.resetForTesting();
await StorageService.init();
dufs = FakeDufs(fixtureTcx());
});
tearDown(() => Sandbox.enabled = false);
test(
'imports the PC history as display-only runs, then tells the PC',
() async {
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures, isEmpty);
expect(report.imported, 3);
expect(dufs.depth, '1');
final runs = await storedRuns();
expect(runs, hasLength(3));
for (final run in runs) {
expect(run.track.source, RunSource.runnerupImport);
expect(run.uploadState, RunUploadState.never);
}
// The walk is identified by its file name alone.
expect(runs.map((r) => r.track.sport), contains(RunSport.walking));
// The multi-lap RunnerUp file is summed like the PC sums it.
final multi = runs.firstWhere((r) => r.tcxFilename.contains('08-12'));
expect(multi.track.distanceM, greaterThan(0));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
expect(dufs.puts, ['PUT /RunnerUp/.workout_app_import.json']);
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
expect(marker['completed_at'], '2026-11-10T08:00:00.000Z');
final names = expectedMarkerNames(dufs.files);
expect(names, isNotEmpty);
expect(marker['count'], names.length);
expect(marker['tcx_names'], names);
},
);
test(
'an app-made TCX that came back is still history, never pending',
() async {
final name = dufs.files.keys.firstWhere((n) => n.contains('17-30-00'));
expect(
decodeTcx(utf8.decode(dufs.files[name]!), fileName: name).source,
RunSource.app,
reason:
'fixture must be an app-made file for this test to mean anything',
);
await makeImporter(dufs).runIfNeeded();
final stored = (await storedRuns()).firstWhere(
(r) => r.tcxFilename == name,
);
expect(stored.track.source, RunSource.runnerupImport);
expect(stored.uploadState, RunUploadState.never);
},
);
test(
'twins are one run: stored once, listed once, app files not at all',
() async {
final original = dufs.files.keys.firstWhere((n) => n.contains('08-12'));
final twin = original.replaceFirst('RunnerUp_', 'RunnerUp_Pixel_6a_');
// Same <Id>, different bytes: the phone's own copy of the export.
dufs.files[twin] = utf8Bytes('${utf8.decode(dufs.files[original]!)}\n');
final report = await makeImporter(dufs).runIfNeeded();
expect(report.failures, isEmpty);
expect(report.duplicates, 1);
expect(await storedRuns(), hasLength(3));
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
final names = (marker['tcx_names'] as List<dynamic>).cast<String>();
expect(names.where((n) => n.contains('08-12')), [original]);
expect(marker['count'], names.length);
expect(names.any((n) => n.contains('17-30-00')), isFalse);
},
);
test('never PUTs a tcx anywhere; the only write is the dotfile', () async {
await makeImporter(dufs).runIfNeeded();
expect(dufs.puts, hasLength(1));
expect(dufs.puts.single, startsWith('PUT /RunnerUp/.'));
expect(dufs.puts.single, isNot(contains('.tcx')));
});
test('a rerun is a no-op once complete: no requests at all', () async {
await makeImporter(dufs).runIfNeeded();
final before = dufs.requests.length;
final report = await makeImporter(dufs).runIfNeeded();
expect(report.imported, 0);
expect(dufs.requests, hasLength(before));
expect(runnerUpImportStatus.value, RunnerUpImportStatus.done);
expect(await storedRuns(), hasLength(3));
});
test(
'forgetting completion re-imports nothing new (dedupe by hash)',
() async {
await makeImporter(dufs).runIfNeeded();
SharedPreferences.setMockInitialValues({});
final report = await makeImporter(dufs).runIfNeeded();
expect(report.imported, 0);
expect(report.duplicates, 3);
expect(await storedRuns(), hasLength(3));
},
);
test('the marker path is a dotfile at the top of /RunnerUp', () {
expect(kImportMarkerPath, '/RunnerUp/.workout_app_import.json');
expect(kImportMarkerPath.split('/').last, startsWith('.'));
});
}

View File

@ -0,0 +1,167 @@
@TestOn('vm')
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/services/dufs_credentials.dart';
import 'package:workout_app/services/runnerup_dav.dart';
import 'package:workout_app/services/runnerup_importer.dart';
import 'package:workout_app/services/storage_service.dart';
import '../fake_secure_storage.dart';
import '_runnerup_fake_dufs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
group('the phone folder scan', () {
late Directory dir;
late FakeDufs dufs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
StorageService.resetForTesting();
await StorageService.init();
dufs = FakeDufs(fixtureTcx());
dir = Directory.systemTemp.createTempSync('runnerup_local');
addTearDown(() => dir.deleteSync(recursive: true));
});
RunnerUpImporter importer({bool canRead = true}) => RunnerUpImporter(
client: dufs.client,
loadLogin: fakeLogin,
canReadStorage: () async => canRead,
localDir: dir.path,
isAndroid: true,
);
test('adds a phone-only export, dedupes the one the PC has', () async {
final shared = dufs.files.entries.first;
File('${dir.path}/${shared.key}').writeAsBytesSync(shared.value);
final onlyHere = utf8
.decode(dufs.files.entries.last.value)
.replaceAll('2026-11-09T', '2026-11-08T');
File(
'${dir.path}/RunnerUp_2026-11-08-17-30-00_Running.tcx',
).writeAsStringSync(onlyHere);
File('${dir.path}/notes.txt').writeAsStringSync('ignored');
final report = await importer().runIfNeeded();
expect(report.failures, isEmpty);
expect(report.imported, 4);
expect(report.duplicates, 1);
final stored = await StorageService.instance.listRuns();
expect(stored, hasLength(4));
// The phone-only file is history but is not offered to the PC's list.
final marker = jsonDecode(dufs.markers.single) as Map<String, dynamic>;
expect(marker['count'], expectedMarkerNames(dufs.files).length);
});
test('an unreadable file warns but never blocks completion', () async {
final locked = File('${dir.path}/locked_Running.tcx')
..writeAsStringSync('x');
Process.runSync('chmod', ['000', locked.path]);
final report = await importer().runIfNeeded();
expect(report.failures, isEmpty);
expect(dufs.puts, hasLength(1));
});
test('a missing folder is fine', () async {
final report = await RunnerUpImporter(
client: dufs.client,
loadLogin: fakeLogin,
canReadStorage: () async => true,
localDir: '${dir.path}/missing',
isAndroid: true,
).runIfNeeded();
expect(report.failures, isEmpty);
expect(report.imported, 3);
});
});
group('multistatus parsing', () {
test('keeps tcx names only, decoded, once each', () {
const xml = '''
<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:">
<D:response><D:href>/RunnerUp/processed/</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/a%20b_Running.tcx</D:href></D:response>
<D:response><D:href>https://h.test/RunnerUp/processed/C_Walking.TCX</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/C_Walking.TCX</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/a.gpx</D:href></D:response>
<D:response><D:href>/RunnerUp/processed/.hidden.tcx</D:href></D:response>
</D:multistatus>''';
expect(tcxNamesFromMultistatus(xml), [
'C_Walking.TCX',
'a b_Running.tcx',
]);
});
test('a 207 that is not XML is an error the caller can report', () {
expect(() => tcxNamesFromMultistatus('<<<'), throwsA(isA<Exception>()));
});
test('DavException reads as its sentence', () {
expect(
const DavException('GET x answered 500').toString(),
contains('500'),
);
});
});
group('DufsCredentials', () {
test('round-trips through secure storage under the shared keys', () async {
installFakeSecureStorage();
expect(await DufsCredentials.load(), isNull);
await DufsCredentials(
url: 'https://h.test///',
user: 'u',
password: 'p',
).save();
final back = await DufsCredentials.load();
expect(back!.url, 'https://h.test');
expect(back.user, 'u');
expect(back.password, 'p');
expect(back.authorization, 'Basic ${base64Encode(utf8.encode('u:p'))}');
expect(
[kDufsUrlKey, kDufsUserKey, kDufsPasswordKey],
['dufs_url', 'dufs_user', 'dufs_password'],
);
});
test('a half-provisioned login is no login', () async {
installFakeSecureStorage(initial: {kDufsUrlKey: 'https://h.test'});
expect(await DufsCredentials.load(), isNull);
});
test('dufs_login.json needs all three non-empty strings', () {
expect(
DufsCredentials.fromJson({'url': 'u', 'user': 'a', 'password': 'b'}),
isNotNull,
);
expect(DufsCredentials.fromJson({'url': 'u', 'user': 'a'}), isNull);
expect(
DufsCredentials.fromJson({'url': 'u', 'user': '', 'password': 'b'}),
isNull,
);
expect(
DufsCredentials.fromJson({'url': 1, 'user': 'a', 'password': 'b'}),
isNull,
);
});
});
}

View File

@ -50,9 +50,10 @@ void main() {
return await _row('exercise_state', _name);
}
test('export is version 2 and rows carry updated_at', () async {
test('export is the current version (3) and rows carry updated_at', () async {
final data = await _svc.exportData();
expect(data['version'], 2);
expect(data['version'], kExportVersion);
expect(kExportVersion, 3);
expect(
(await _row('exercise_state', _name)).containsKey('updated_at'),
true,

View File

@ -169,6 +169,9 @@ void main() {
await StorageService.init();
final t = _track();
await _insert(t);
// A recorded run triggers an unawaited backup; let it finish before the
// tables are dropped under it.
await Future<void>.delayed(const Duration(milliseconds: 100));
StorageService.resetForTesting();
final raw = await databaseFactory.openDatabase(path);

View File

@ -0,0 +1,270 @@
@TestOn('vm')
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/tcx_codec.dart';
import '_runnerup_fake_dufs.dart';
StorageService get _svc => StorageService.instance;
/// The three fixtures, decoded: two app-made, one RunnerUp export.
List<(String, String, RunTrack)> _tracks() => [
for (final e in fixtureTcx().entries)
(
e.key,
utf8.decode(e.value),
decodeTcx(utf8.decode(e.value), fileName: e.key),
),
];
Future<void> _insertAll() async {
for (final (name, xml, track) in _tracks()) {
final imported = track.source == RunSource.runnerupImport;
await _svc.insertRun(
track,
tcxFilename: name,
tcxSha256: tcxSha256(xml),
uploadState: imported ? RunUploadState.never : RunUploadState.pending,
);
}
}
Future<void> _freshInstall() async {
StorageService.resetForTesting();
await StorageService.init();
}
Future<Map<String, dynamic>> _backupOnceItHolds(int runs) async {
for (var i = 0; i < 150; i++) {
final b = await BackupService.instance.readBackup();
if (b != null && ((b['runs'] as List?)?.length ?? 0) >= runs) return b;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
fail('backup.json never held $runs run(s)');
}
void main() {
late Directory tmp;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tmp = Directory.systemTemp.createTempSync('runs_transfer');
BackupService.baseDir = tmp.path;
await _freshInstall();
});
tearDown(() {
BackupService.baseDir = kBackupDir;
tmp.deleteSync(recursive: true);
});
group('backup.json', () {
test(
'wipe the DB, restore: recorded runs and their points return',
() async {
await _insertAll();
final backup = await _backupOnceItHolds(2);
final saved = {
for (final r in await _svc.listRuns())
if (r.track.source == RunSource.app)
r.track.id: await _svc.getRun(r.track.id),
};
expect(saved, hasLength(2));
// RunnerUp history is reproducible from the PC: not duplicated here.
expect(
(backup['runs'] as List).map((r) => (r as Map)['source']).toSet(),
{'app'},
);
final text = const JsonEncoder.withIndent(' ').convert(backup);
expect(
text.split('\n').length,
lessThan(200),
reason: 'points must stay one string per run, not one line per fix',
);
await _freshInstall(); // the reinstall
expect(await _svc.listRuns(), isEmpty);
await _svc.restoreFromBackupIfNeeded();
final restored = await _svc.listRuns();
expect(restored.map((r) => r.track.id).toSet(), saved.keys.toSet());
for (final run in saved.values) {
final back = (await _svc.getRun(run!.track.id))!;
expect(back.track.points, run.track.points);
expect(back.track.points, isNotEmpty);
expect(back.uploadState, run.uploadState);
expect(back.tcxSha256, run.tcxSha256);
expect(back.track.totalSeconds, run.track.totalSeconds);
expect(back.track.distanceM, run.track.distanceM);
}
},
);
test('restoring twice stores nothing twice', () async {
await _insertAll();
await _backupOnceItHolds(2);
await _freshInstall();
await _svc.restoreFromBackupIfNeeded();
final once = await _svc.listRuns();
await _svc.restoreFromBackupIfNeeded();
expect(await _svc.listRuns(), hasLength(once.length));
});
test('a run-less fresh DB cannot overwrite a runs-only backup', () async {
await _insertAll();
final before = await _backupOnceItHolds(2);
await _freshInstall();
await _svc.setLastWorkoutType('A'); // triggers _backupNow
await Future<void>.delayed(const Duration(milliseconds: 100));
final after = await BackupService.instance.readBackup();
expect(
after!['runs'] as List,
hasLength((before['runs'] as List).length),
);
});
});
test(
'a run on a history-less DB cannot clobber a backup with history',
() async {
await BackupService.instance.export({
'exercise_state': <Object?>[],
'workout_history': [
{
'date': '2026-07-17',
'workout_type': 'B',
'duration_seconds': 6465,
'succeeded': 1,
'json': '{}',
},
],
'settings': <Object?>[],
});
final (name, xml, track) = _tracks().firstWhere(
(t) => t.$3.source == RunSource.app,
);
await _svc.insertRun(
track,
tcxFilename: name,
tcxSha256: tcxSha256(xml),
uploadState: RunUploadState.pending,
);
await Future<void>.delayed(const Duration(milliseconds: 150));
final after = await BackupService.instance.readBackup();
expect(after!['workout_history'], hasLength(1));
},
);
group('user export / merge-import', () {
test(
'round trip keeps every run and point; a re-import adds nothing',
() async {
await _insertAll();
final originals = {
for (final r in await _svc.listRuns())
r.track.id: (await _svc.getRun(r.track.id))!,
};
final data =
jsonDecode(jsonEncode(await _svc.exportData()))
as Map<String, dynamic>;
expect(data['version'], kExportVersion);
expect(data['runs'], hasLength(3));
await _freshInstall();
final report = await _svc.importDataReport(data);
expect(report.rows, greaterThanOrEqualTo(3));
for (final run in originals.values) {
final back = (await _svc.getRun(run.track.id))!;
expect(back.track.points, run.track.points);
expect(back.track.source, run.track.source);
expect(back.uploadState, run.uploadState);
}
final again = await _svc.importDataReport(data);
expect(again.rows, 0);
expect(await _svc.listRuns(), hasLength(3));
},
);
test('an old export without runs still imports', () async {
final report = await _svc.importDataReport({
'format': kExportFormat,
'version': 2,
'settings': <Object?>[],
});
expect(report.rows, 0);
expect(await _svc.listRuns(), isEmpty);
});
test(
'a runs entry that is not a list is skipped, the rest merges',
() async {
final report = await _svc.importDataReport({
'settings': <Object?>[],
'runs': 5,
});
expect(report.rows, 0);
},
);
test('an export holding only runs is accepted; junk is not', () async {
await _insertAll();
final runs = (await _svc.exportData())['runs']! as List<dynamic>;
await _freshInstall();
expect((await _svc.importDataReport({'runs': runs})).rows, 3);
await expectLater(
_svc.importDataReport({'runs': 'nope'}),
throwsFormatException,
);
});
test(
'bad entries are skipped, good ones kept, imports stay never',
() async {
await _insertAll();
final good = ((await _svc.exportData())['runs']! as List<dynamic>)
.cast<Map<String, Object?>>();
await _freshInstall();
final forged = {
...good.first,
'source': 'runnerup_import',
'upload_state': 'pending',
};
final clash = {...good[1], 'id': 'other-id'}; // same hash, new id
final badPoints = {...good[2], 'points': 'not json'};
final badSport = {...good[2], 'id': 'z', 'sport': 'Cycling'};
final noId = {...good[2], 'id': ''};
final report = await _svc.importDataReport({
'runs': [forged, good[1], clash, badPoints, badSport, noId, 'str', 7],
});
expect(report.rows, 2);
final stored = await _svc.listRuns();
expect(stored, hasLength(2));
final coerced = stored.firstWhere((r) => r.track.id == forged['id']);
expect(coerced.track.source, RunSource.runnerupImport);
expect(coerced.uploadState, RunUploadState.never);
},
);
});
}

View File

@ -0,0 +1,119 @@
// Shared fixtures for the run map / run detail / history-runs tests.
//
// The map never gets a real tile provider here: [FakeTileProvider] serves a
// 1x1 transparent PNG (or an image that fails to load, to play "offline"),
// so no test can reach tile.openstreetmap.org.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/ui/theme.dart';
final _transparentPng = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA'
'60e6kgAAAABJRU5ErkJggg==',
);
/// An image that always fails, as a tile does with no network and no cache.
class FailingImage extends ImageProvider<FailingImage> {
/// Creates the failing image.
const FailingImage();
@override
Future<FailingImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture(this);
@override
ImageStreamCompleter loadImage(
FailingImage key,
ImageDecoderCallback decode,
) => OneFrameImageStreamCompleter(
Future<ImageInfo>.error(StateError('no network')),
);
}
/// A [TileProvider] that never touches the network.
class FakeTileProvider extends TileProvider {
/// Serves transparent tiles, or failing ones when [offline].
FakeTileProvider({this.offline = false});
/// Whether every tile fails to load.
final bool offline;
@override
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) =>
offline ? const FailingImage() : MemoryImage(_transparentPng);
}
/// Opener returning a fresh [FakeTileProvider].
Future<TileProvider> Function() fakeTiles({bool offline = false}) =>
() async => FakeTileProvider(offline: offline);
/// A track of [count] fixes heading north-east from Warsaw.
RunTrack buildTrack({
int count = 5,
RunSport sport = RunSport.running,
RunSource source = RunSource.app,
DateTime? startedAt,
double distanceM = 5200,
int totalSeconds = 1930,
}) {
final start = startedAt ?? DateTime.utc(2026, 11, 10, 6, 30);
return RunTrack.started(
sport: sport,
startedAt: start,
totalSeconds: totalSeconds,
distanceM: distanceM,
source: source,
points: [
for (var i = 0; i < count; i++)
RunPoint(
time: start.add(Duration(seconds: i * 10)),
latitude: 52.2297 + i * 0.001,
longitude: 21.0122 + i * 0.0015,
distanceM: i * 100.0,
),
],
);
}
/// Wraps [child] in the app theme, filling a phone-sized surface.
Widget themed(Widget child) =>
MaterialApp(theme: buildAppTheme(), home: child);
/// Runs [body] and returns every `debugPrint` line it wrote.
///
/// Restored before returning: the test binding fails a test that leaves
/// `debugPrint` swapped, and it checks that before any tearDown runs.
Future<List<String>> capturingDebugPrint(Future<void> Function() body) async {
final lines = <String>[];
final original = debugPrint;
debugPrint = (message, {wrapWidth}) => lines.add(message ?? '');
try {
await body();
} finally {
debugPrint = original;
}
return lines;
}
/// Registers the SDK's Roboto as the test font, so widths mean something.
///
/// The default test font draws every glyph one em wide; this is the same
/// loader as in `workout_layout_test.dart`.
Future<void> loadRoboto() async {
final cache = File(Platform.resolvedExecutable).parent.parent.parent.parent;
final loader = FontLoader('Roboto');
for (final weight in ['Regular', 'Medium', 'Bold']) {
final ttf = File(
'${cache.path}/artifacts/material_fonts/Roboto-$weight.ttf',
);
loader.addFont(Future.value(ByteData.sublistView(ttf.readAsBytesSync())));
}
await loader.load();
}

View File

@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import 'package:workout_app/widgets/run_map.dart';
import '_run_map_fixtures.dart';
Future<void> _pump(WidgetTester tester, Widget map) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(themed(map));
await tester.pump();
await tester.pump();
}
MapCamera _camera(WidgetTester tester) =>
MapCamera.of(tester.element(find.byType(RichAttributionWidget)));
void main() {
group('RunMap.route', () {
testWidgets('draws one polyline, start and end markers', (tester) async {
final track = buildTrack();
await _pump(
tester,
RunMap.route(points: track.points, tileProviderOpener: fakeTiles()),
);
final line = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
expect(line.polylines, hasLength(1));
expect(line.polylines.single.points, hasLength(track.points.length));
final markers = tester.widget<MarkerLayer>(find.byType(MarkerLayer));
expect(markers.markers, hasLength(2));
});
testWidgets('asks OSM with the real user agent package', (tester) async {
await _pump(
tester,
RunMap.route(
points: buildTrack().points,
tileProviderOpener: fakeTiles(),
),
);
final tiles = tester.widget<TileLayer>(find.byType(TileLayer));
expect(tiles.urlTemplate, contains('tile.openstreetmap.org'));
// TileLayer folds userAgentPackageName into the provider's headers.
expect(
tiles.tileProvider.headers['User-Agent'],
contains('com.kuhy.workout_app'),
);
});
testWidgets('fits every point on screen', (tester) async {
final track = buildTrack();
await _pump(
tester,
RunMap.route(points: track.points, tileProviderOpener: fakeTiles()),
);
final camera = _camera(tester);
final area = Offset.zero & const Size(400, 600);
for (final p in track.points) {
final at = camera.latLngToScreenOffset(
LatLng(p.latitude, p.longitude),
);
expect(area.contains(at), isTrue, reason: '$at is off screen');
}
});
testWidgets('collapsed attribution shows no words until tapped', (
tester,
) async {
await _pump(
tester,
RunMap.route(
points: buildTrack().points,
tileProviderOpener: fakeTiles(),
),
);
// flutter_map keeps the credit in the tree and fades it in on tap, so
// "no visible words" means: fully transparent until the icon is tapped.
final credit = find.textContaining('OpenStreetMap');
expect(find.byType(Text), findsOneWidget);
double opacity() => tester
.widget<AnimatedOpacity>(
find
.ancestor(of: credit, matching: find.byType(AnimatedOpacity))
.first,
)
.opacity;
expect(opacity(), 0);
await tester.tap(find.byIcon(Icons.info_outline));
await tester.pumpAndSettle();
expect(opacity(), 1);
});
testWidgets('one point: a marker, no line, no crash', (tester) async {
await _pump(
tester,
RunMap.route(
points: buildTrack(count: 1).points,
tileProviderOpener: fakeTiles(),
),
);
expect(find.byType(PolylineLayer), findsNothing);
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers,
hasLength(1),
);
});
testWidgets('no points: plain map, no layers', (tester) async {
await _pump(
tester,
RunMap.route(points: const [], tileProviderOpener: fakeTiles()),
);
expect(find.byType(PolylineLayer), findsNothing);
expect(find.byType(MarkerLayer), findsNothing);
expect(find.byType(FlutterMap), findsOneWidget);
});
testWidgets('no tile layer until the provider has opened', (tester) async {
final opening = Completer<TileProvider>();
await _pump(
tester,
RunMap.route(
points: buildTrack().points,
tileProviderOpener: () => opening.future,
),
);
expect(find.byType(TileLayer), findsNothing);
expect(find.byType(PolylineLayer), findsOneWidget);
opening.complete(FakeTileProvider());
await tester.pump();
await tester.pump();
expect(find.byType(TileLayer), findsOneWidget);
});
testWidgets('offline: route still drawn, one loud warning', (tester) async {
final lines = await capturingDebugPrint(() async {
await _pump(
tester,
RunMap.route(
points: buildTrack().points,
tileProviderOpener: fakeTiles(offline: true),
),
);
await tester.pump(const Duration(milliseconds: 100));
});
expect(find.byType(PolylineLayer), findsOneWidget);
final warnings = lines.where((l) => l.contains('WARNING'));
expect(warnings, hasLength(1), reason: lines.join('\n'));
expect(warnings.single, contains('plain background'));
});
});
group('RunMap.live', () {
testWidgets('follows the last point and draws as the track grows', (
tester,
) async {
final track = ValueNotifier<RunTrack?>(null);
addTearDown(track.dispose);
await _pump(
tester,
RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()),
);
expect(find.byType(PolylineLayer), findsNothing);
expect(find.byType(MarkerLayer), findsNothing);
final full = buildTrack(count: 4);
track.value = full;
await tester.pump();
expect(find.byType(PolylineLayer), findsOneWidget);
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers,
hasLength(2),
);
final last = full.points.last;
final center = _camera(tester).center;
expect(center.latitude, closeTo(last.latitude, 1e-6));
expect(center.longitude, closeTo(last.longitude, 1e-6));
expect(find.byIcon(Icons.my_location), findsNothing);
});
testWidgets('a drag ends follow mode, the button resumes it', (
tester,
) async {
final full = buildTrack(count: 4);
final track = ValueNotifier<RunTrack?>(full);
addTearDown(track.dispose);
await _pump(
tester,
RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()),
);
await tester.drag(find.byType(FlutterMap), const Offset(120, 0));
await tester.pump();
expect(find.byIcon(Icons.my_location), findsOneWidget);
final dragged = _camera(tester).center.longitude;
expect(dragged, isNot(closeTo(full.points.last.longitude, 1e-6)));
// New fixes no longer pull the camera while follow is off.
track.value = buildTrack(count: 6);
await tester.pump();
expect(_camera(tester).center.longitude, closeTo(dragged, 1e-9));
await tester.tap(find.byIcon(Icons.my_location));
await tester.pump();
expect(find.byIcon(Icons.my_location), findsNothing);
final last = track.value!.points.last;
expect(_camera(tester).center.longitude, closeTo(last.longitude, 1e-6));
});
testWidgets('stops listening when disposed', (tester) async {
final track = ValueNotifier<RunTrack?>(buildTrack());
addTearDown(track.dispose);
await _pump(
tester,
RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()),
);
await tester.pumpWidget(const SizedBox());
track.value = buildTrack(count: 7);
await tester.pump();
expect(tester.takeException(), isNull);
});
});
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/runnerup_importer.dart';
import 'package:workout_app/widgets/runnerup_import_badge.dart';
void main() {
Future<void> pump(
WidgetTester tester,
ValueNotifier<RunnerUpImportStatus>? status,
) => tester.pumpWidget(
MaterialApp(
home: Scaffold(body: RunnerUpImportBadge(status: status)),
),
);
testWidgets('shows a spinner while importing, a glyph on failure, and '
'never any text', (tester) async {
final status = ValueNotifier(RunnerUpImportStatus.idle);
await pump(tester, status);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byIcon(Icons.sync_problem), findsNothing);
status.value = RunnerUpImportStatus.running;
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
status.value = RunnerUpImportStatus.failed;
await tester.pump();
expect(find.byIcon(Icons.sync_problem), findsOneWidget);
expect(find.byType(Text), findsNothing);
status.value = RunnerUpImportStatus.done;
await tester.pump();
expect(find.byIcon(Icons.sync_problem), findsNothing);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('listens to the live importer status by default', (tester) async {
runnerUpImportStatus.value = RunnerUpImportStatus.failed;
await pump(tester, null);
expect(find.byIcon(Icons.sync_problem), findsOneWidget);
runnerUpImportStatus.value = RunnerUpImportStatus.idle;
});
}