mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
commit
97d73b31f5
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
15
runnerup-retire.service
Normal 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
18
runnerup-retire.timer
Normal 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
|
||||
215
screen_locker/_runnerup_retire.py
Normal file
215
screen_locker/_runnerup_retire.py
Normal 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
|
||||
181
screen_locker/_runnerup_retire_adb.py
Normal file
181
screen_locker/_runnerup_retire_adb.py
Normal 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)
|
||||
62
screen_locker/_runnerup_retire_result.py
Normal file
62
screen_locker/_runnerup_retire_result.py
Normal 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
|
||||
71
screen_locker/_runnerup_retire_state.py
Normal file
71
screen_locker/_runnerup_retire_state.py
Normal 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)
|
||||
210
screen_locker/_runnerup_retire_tcx.py
Normal file
210
screen_locker/_runnerup_retire_tcx.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
99
screen_locker/runnerup_retire.py
Normal file
99
screen_locker/runnerup_retire.py
Normal 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())
|
||||
@ -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",),
|
||||
),
|
||||
)
|
||||
|
||||
137
screen_locker/tests/_retire_helpers.py
Normal file
137
screen_locker/tests/_retire_helpers.py
Normal 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
|
||||
216
screen_locker/tests/test_runnerup_retire.py
Normal file
216
screen_locker/tests/test_runnerup_retire.py
Normal 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
|
||||
225
screen_locker/tests/test_runnerup_retire_adb.py
Normal file
225
screen_locker/tests/test_runnerup_retire_adb.py
Normal 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")
|
||||
141
screen_locker/tests/test_runnerup_retire_cli.py
Normal file
141
screen_locker/tests/test_runnerup_retire_cli.py
Normal 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")
|
||||
117
screen_locker/tests/test_runnerup_retire_marker.py
Normal file
117
screen_locker/tests/test_runnerup_retire_marker.py
Normal 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
|
||||
52
screen_locker/tests/test_runnerup_retire_state.py
Normal file
52
screen_locker/tests/test_runnerup_retire_state.py
Normal 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")
|
||||
189
screen_locker/tests/test_runnerup_retire_tcx.py
Normal file
189
screen_locker/tests/test_runnerup_retire_tcx.py
Normal 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]
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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)}';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
@ -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)}',
|
||||
);
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
: [
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
stronglift_replacement/workout_app/lib/widgets/run_map.dart
Normal file
204
stronglift_replacement/workout_app/lib/widgets/run_map.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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 -:--');
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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'))}',
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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('.'));
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user