Let the PC see phone workouts again, and stop losing progression on reinstall
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

A real StrongLifts session on 2026-08-10 never reached this machine, so it
had to be logged as a manual workout -- spending a manual budget slot on a
workout that was genuinely verified. The session was in Firebase the whole
time; four separate defects each hid it.

- pull_synced_workout() read a hardcoded devices/phone/log.json. Since
  ffc6c72 the phone pushes under a per-install uuid, so that directory has
  been frozen since 2026-07-17 and the PC kept returning a stale session
  from 07-27 as if it were current. Both readers now walk EVERY device
  directory; reinstalls keep minting new ones, so no single path can be
  right for long.
- A session is identified by SHAPE (it has `exercises`), not by a `kind`
  field the phone never writes. That filter is why the one reader that did
  walk every directory discarded real sessions, and it also kept PC-origin
  runnerup/phone_verified records from being handed to a caller expecting
  a session.
- FirebaseSyncError is a SIBLING of GitHubSyncError, not a subclass, so
  `except GitHubSyncError` never caught it and a DNS failure took the whole
  locker down (status=1/FAILURE, then a restart loop). Catch the common
  parent; a pull failure degrades to a warning.
- A GitHub token is no longer required to sync. Firebase has been primary
  since eb4ff01, so a Firebase-only machine was reporting "sync is OFF"
  while syncing fine.

Sync also only ran once per locker start, so a workout finished after login
was invisible until the next login. workout-sync.timer runs the new headless
--sync-only every 15 minutes; remove_systemd.sh now removes every unit the
installer creates, not just two of them.

workout_app: the 2026-08-05 reinstall wiped progression because the DB seeds
factory defaults before the backup is restored, and the first write then
exported those defaults over the only off-device copy. Storage permission is
now settled before the DB opens, a defaults-only DB refuses to overwrite a
richer backup, and the three swallowed storage errors say what broke.

Sessions that exist only in Firebase/GitHub are restored into local history,
so a reinstalled app has the history it actually earned rather than starting
at whatever survived. Matched on (date, start_time), so re-syncing never
duplicates and local rows always win.

Adds a target-reps editor: progression can only ever RAISE reps, so a rep
target a defaults re-seed set wrong could not be corrected from the UI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JS5V44jkHZiH1wC7BH5h5n
This commit is contained in:
Krzysztof Rudnicki 2026-08-10 14:35:08 +02:00
parent b008992cb7
commit e7fa7fb44c
23 changed files with 1597 additions and 431 deletions

View File

@ -4,9 +4,13 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
EARLY_BIRD_TIMER_FILE="$SCRIPT_DIR/early-bird-workout-check.timer"
SYNC_SERVICE_FILE="$SCRIPT_DIR/workout-sync.service"
SYNC_TIMER_FILE="$SCRIPT_DIR/workout-sync.timer"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_NAME="workout-locker.service"
EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
SYNC_SERVICE_NAME="workout-sync.service"
SYNC_TIMER_NAME="workout-sync.timer"
# Check if service is already installed
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
@ -44,6 +48,12 @@ cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Copy early bird timer
cp "$EARLY_BIRD_TIMER_FILE" "$USER_SERVICE_DIR/$EARLY_BIRD_TIMER_NAME"
# Copy the periodic workout-sync units. Without these, syncing happens only
# once per locker start, so a workout finished after login is not seen until
# the next login.
cp "$SYNC_SERVICE_FILE" "$USER_SERVICE_DIR/$SYNC_SERVICE_NAME"
cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_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"
@ -59,6 +69,9 @@ systemctl --user enable "$SERVICE_NAME"
# Enable the early bird re-check timer
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
# Enable the periodic workout sync
systemctl --user enable --now "$SYNC_TIMER_NAME"
echo "✓ Workout locker service installed"
echo "✓ Early bird re-check timer installed (fires daily at 08:30)"
echo "✓ Service will start automatically on next login"
@ -81,6 +94,11 @@ if systemctl --user is-enabled "$EARLY_BIRD_TIMER_NAME" &>/dev/null; then
else
echo "✗ early bird timer: NOT enabled"
fi
if systemctl --user is-enabled "$SYNC_TIMER_NAME" &>/dev/null; then
echo "✓ workout sync timer: INSTALLED and enabled (every 15 min)"
else
echo "✗ workout sync timer: NOT enabled"
fi
I3_CONFIG="$HOME/.config/i3/config"
if [ -f "$I3_CONFIG" ] && grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then

View File

@ -8,7 +8,7 @@ requires-python = ">=3.10"
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.7.0#subdirectory=crdt-sync",
]
[project.scripts]

View File

@ -5,19 +5,23 @@ SERVICE_NAME="workout-locker.service"
TIMER_NAME="workout-locker.timer"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
# Stop the service and timer if running
systemctl --user stop "$TIMER_NAME" 2>/dev/null
systemctl --user stop "$SERVICE_NAME" 2>/dev/null
# Every unit install_systemd.sh creates. Removal has to enumerate all of them,
# or a "removed" locker keeps firing from a timer nobody remembers installing.
UNITS=(
"$SERVICE_NAME"
"$TIMER_NAME"
"early-bird-workout-check.timer"
"workout-sync.timer"
"workout-sync.service"
)
# Disable the service and timer
systemctl --user disable "$TIMER_NAME" 2>/dev/null
systemctl --user disable "$SERVICE_NAME" 2>/dev/null
# Remove service and timer files
rm -f "$USER_SERVICE_DIR/$SERVICE_NAME"
rm -f "$USER_SERVICE_DIR/$TIMER_NAME"
for unit in "${UNITS[@]}"; do
systemctl --user stop "$unit" 2>/dev/null
systemctl --user disable "$unit" 2>/dev/null
rm -f "$USER_SERVICE_DIR/$unit"
done
# Reload systemd daemon
systemctl --user daemon-reload
echo "✓ Workout locker service and timer removed"
echo "✓ Workout locker services and timers removed"

View File

@ -3,7 +3,7 @@
bandit>=1.7.0
codespell>=2.2.0
coverage>=7.4.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.6.0#subdirectory=crdt-sync
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.7.0#subdirectory=crdt-sync
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
mcp>=2,<3
mypy>=1.8.0

View File

@ -11,8 +11,13 @@ Retrying at the application layer rather than ordering the unit after
ordering does not apply, and the user services involved cannot depend on a
system target anyway.
Any :class:`GitHubSyncError` is retried, deliberately including
:class:`RepoNotFoundError`. Classifying "transient" vs "permanent" would mean
Any :class:`RemoteSyncError` is retried, deliberately including
:class:`RepoNotFoundError`. Catching the common parent rather than
:class:`GitHubSyncError` matters since the Firebase cutover:
:class:`FirebaseSyncError` is a *sibling* of :class:`GitHubSyncError`, not a
subclass, so catching the latter let every Firebase failure escape uncaught and
take the whole locker down (``status=1/FAILURE``) instead of degrading to "no
synced workout". Classifying "transient" vs "permanent" would mean
depending on which exception ``crdt_sync`` chains where, which is internal to
that library and has changed before; a few wasted seconds on a genuinely
misconfigured token is a much cheaper mistake than skipping a real retry.
@ -24,7 +29,7 @@ import logging
from time import sleep
from typing import TYPE_CHECKING, TypeVar
from crdt_sync import GitHubSyncError
from crdt_sync import RemoteSyncError
from screen_locker._constants import (
SYNC_RETRY_ATTEMPTS,
@ -60,7 +65,7 @@ def with_sync_retry(
Whatever *operation* returns.
Raises:
GitHubSyncError: If every attempt failed. The caller keeps its own
RemoteSyncError: If every attempt failed. The caller keeps its own
handling — this only buys time, it never hides the outcome.
"""
last_attempt = max(attempts, 1)
@ -69,7 +74,7 @@ def with_sync_retry(
for attempt in range(1, last_attempt):
try:
return operation()
except GitHubSyncError as exc:
except RemoteSyncError as exc:
wait = delay_seconds * (2 ** (attempt - 1))
_logger.warning(
"Could not %s (attempt %d/%d): %s — retrying in %.0fs; "
@ -85,7 +90,7 @@ def with_sync_retry(
try:
return operation()
except GitHubSyncError as exc:
except RemoteSyncError as exc:
_logger.warning(
"Could not %s after %d attempt(s): %s — giving up",
description,

View File

@ -12,22 +12,22 @@ from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from crdt_sync import (
CONFIG_FILE,
ConfigError,
FirebaseAuthError,
GitHubSyncClient,
GitHubSyncError,
Hlc,
Record,
RemoteStore,
RemoteSyncError,
firebase_client_for,
mirror_client_for,
)
from screen_locker._constants import (
SYNC_PHONE_DEVICE_ID,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
@ -36,10 +36,12 @@ from screen_locker._constants import (
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
from screen_locker._sync_retry import with_sync_retry
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_DEVICES_PREFIX = "screen-locker-sync/devices"
_LOG_PATH = f"{_DEVICES_PREFIX}/{SYNC_PHONE_DEVICE_ID}/log.json"
_PAYLOAD_FIELD = "payload"
@ -48,6 +50,22 @@ def _is_manual_payload(payload: object) -> bool:
return isinstance(payload, dict) and payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
def _is_session_payload(payload: object) -> bool:
"""True if a decoded sync payload is a completed StrongLifts session.
Identified by SHAPE -- the presence of ``exercises`` -- not by a ``kind``
field, because the phone app's ``WorkoutSession.toJson()`` emits no ``kind``
at all. Keying session detection on ``kind`` is what made real sessions
invisible to the only reader that walks every device directory.
Shape-matching also excludes the PC-origin ``runnerup_verified`` /
``phone_verified`` records that share these device logs: they are non-manual
too, so "not manual" alone would hand a caller expecting ``exercises`` and
``duration_seconds`` a record that has neither.
"""
return isinstance(payload, dict) and isinstance(payload.get("exercises"), list)
def read_sync_token() -> str | None:
"""Return the saved sync PAT, or None if sync isn't configured.
@ -61,38 +79,81 @@ def read_sync_token() -> str | None:
return token or None
def _latest_payload(log_json: str) -> dict | None:
"""Return the most recently pushed workout payload from a log.json blob.
def _merge_device_records(
client: RemoteStore,
extract: Callable[[str], dict[str, tuple[dict, Hlc]]],
what: str,
*,
strict: bool = False,
) -> dict[str, tuple[dict, Hlc]]:
"""Merge ``extract``-selected records across EVERY device log.
Raises:
TypeError: If the top-level JSON isn't an object, a record's shape
doesn't match what :meth:`Record.from_dict` expects, or the
payload field itself isn't a JSON object.
KeyError: If a record is missing an expected key.
ValueError: Via ``json.loads``, if the text isn't valid JSON, or via
:meth:`crdt_sync.Hlc.from_str` on a malformed clock string.
Walks ``devices/<id>/log.json`` for every device directory, keeping the
highest-HLC copy of each record id -- records are id-stable, so the same
workout mirrored into two device logs dedups to one.
Every device is walked because the phone's directory name is a per-install
uuid (since ffc6c72), not a fixed role id: it changes on every reinstall,
and old directories linger. Reading any single hardcoded device path is
guaranteed to go stale, which is exactly how phone sessions stopped
reaching this machine.
Best-effort per device: an unreachable or corrupt device log yields fewer
records rather than raising, and says so at ``warning`` -- naming the
device and what is being dropped -- so a partial pull is never silent.
``strict`` controls only the *top-level* listing failure, where nothing at
all could be read. Callers that distinguish "nothing to sync" from "the
sync broke" pass ``strict=True`` to get the exception instead of an empty
dict, since an empty dict would otherwise report a total failure as the
benign "no records" case.
"""
raw = json.loads(log_json)
if not isinstance(raw, dict):
msg = f"top-level sync payload is not a JSON object: {raw!r}"
raise TypeError(msg)
try:
devices = with_sync_retry(
lambda: client.list_directory(_DEVICES_PREFIX),
description="list synced device logs",
)
except RemoteSyncError as exc:
_logger.warning(
"Could not list device logs under %s in %s/%s: %s — pulling NO "
"synced %s, so phone-logged workouts will not count",
_DEVICES_PREFIX,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
exc,
what,
)
if strict:
raise
return {}
records = [Record.from_dict(data) for data in raw.values()]
candidates = [
record
for record in records
if _PAYLOAD_FIELD in record.fields
and not _is_manual_payload(record.fields[_PAYLOAD_FIELD][0])
]
if not candidates:
return None
latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1])
merged: dict[str, tuple[dict, Hlc]] = {}
for device in devices:
path = f"{_DEVICES_PREFIX}/{device}/log.json"
try:
text = client.get_file_text(path)
except RemoteSyncError as exc:
_logger.warning(
"Could not fetch device log %s: %s — SKIPPING that device, so "
"any %s it holds will not count",
path,
exc,
what,
)
continue
if text is None:
continue
try:
records = extract(text)
except (ValueError, KeyError, TypeError) as exc:
_logger.warning("Corrupt sync data at %s: %s", path, exc)
continue
for rid, (payload, hlc) in records.items():
existing = merged.get(rid)
if existing is None or existing[1] < hlc:
merged[rid] = (payload, hlc)
payload = latest.fields[_PAYLOAD_FIELD][0]
if not isinstance(payload, dict):
msg = f"payload field is not a JSON object: {payload!r}"
raise TypeError(msg)
return payload
return merged
def remote_client(github: RemoteStore) -> RemoteStore:
@ -121,70 +182,112 @@ def remote_client(github: RemoteStore) -> RemoteStore:
return github
def pull_synced_workout() -> tuple[dict | None, str | None]:
"""Return ``(data, error)``: the phone's last-synced workout, if any.
def sync_client() -> RemoteStore | None:
"""Return the configured read client, or None if sync is set up nowhere.
- No token configured: ``(None, None)`` -- benign, sync is optional.
- Nothing pushed yet (repo reachable, path unused): ``(None, None)``.
- A real sync error (network, bad token, repo unreachable):
``(None, <message>)``.
- Corrupt/malformed sync data: ``(None, "corrupt sync data: ...")``.
- Success: ``(payload_dict, None)``.
A GitHub token is no longer required: Firebase has been the primary backend
since eb4ff01, so a Firebase-only machine must still sync. Previously this
module returned early whenever the PAT was missing, reporting "sync is OFF"
on a machine whose sync was working perfectly -- a false negative that hid
a live backend behind a legacy credential check.
GitHub is used alone when only the PAT exists, Firebase alone when only
``~/.config/crdt-sync/`` exists, and the mirrored union when both do.
``None`` means neither is configured, which stays a benign, expected state.
"""
token = read_sync_token()
if token is None:
_logger.warning(
"Cannot pull the synced StrongLifts session: no sync token at %s — "
"GitHub sync is OFF, so only ADB/HTTP can verify a phone workout. "
"Create a fine-grained PAT with contents:write on %s/%s and save it "
"there (chmod 600)",
SYNC_TOKEN_FILE,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return None, None
client = remote_client(
github = (
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
if token is not None
else None
)
try:
text = with_sync_retry(
lambda: client.get_file_text(_LOG_PATH),
description="fetch the phone's workout log",
)
except GitHubSyncError as exc:
if github is not None:
return remote_client(github)
if not CONFIG_FILE.is_file():
_logger.warning(
"Could not fetch the phone's workout log %s from %s/%s: %s — "
"falling back to ADB/HTTP verification",
_LOG_PATH,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
"Cannot pull synced workouts: no sync token at %s and no Firebase "
"config at %s — sync is OFF, so only ADB/HTTP can verify a phone "
"workout and phone-logged workouts will NOT count here",
SYNC_TOKEN_FILE,
CONFIG_FILE,
)
return None
try:
return firebase_client_for("screen_locker")
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
_logger.warning(
"Firebase is configured at %s but unusable, and there is no GitHub "
"token at %s to fall back to: %s — pulling NO synced workouts",
CONFIG_FILE,
SYNC_TOKEN_FILE,
exc,
)
return None
def pull_synced_workout() -> tuple[dict | None, str | None]:
"""Return ``(data, error)``: the last-synced StrongLifts session, if any.
Merges sessions across EVERY device directory and returns the highest-HLC
one. It used to read a single hardcoded ``devices/phone/log.json``, which
silently went stale the moment the phone moved to a per-install uuid
(ffc6c72): this machine kept happily returning a session from 2026-07-27
while newer ones sat unread one directory over.
- Nothing configured anywhere: ``(None, None)`` -- benign, sync is optional.
- Nothing pushed yet: ``(None, None)``.
- A real sync error (network, bad token, backend unreachable):
``(None, <message>)``.
- Success: ``(payload_dict, None)``.
"""
client = sync_client()
if client is None:
return None, None
try:
merged = _merge_device_records(
client, _session_records, "StrongLifts sessions", strict=True
)
except RemoteSyncError as exc:
_logger.warning(
"Could not fetch synced workout logs under %s: %s — falling back "
"to ADB/HTTP verification",
_DEVICES_PREFIX,
exc,
)
return None, str(exc)
if text is None:
if not merged:
return None, None
try:
payload = _latest_payload(text)
except (ValueError, KeyError, TypeError) as exc:
_logger.warning("Corrupt sync data at %s: %s", _LOG_PATH, exc)
return None, f"corrupt sync data: {exc}"
_rid, (payload, _hlc) = max(merged.items(), key=lambda item: item[1][1])
return payload, None
def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
"""Return ``{record_id: (payload, hlc)}`` for manual records in a log blob.
def _session_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
"""Return ``{record_id: (payload, hlc)}`` for session records in a log blob.
Raises the same decode errors as :func:`_latest_payload`; callers treat a
corrupt device log as "no manual records from that device".
Raises the same decode errors as :func:`_manual_records`; callers treat a
corrupt device log as "no sessions from that device".
"""
return _records_matching(log_json, _is_session_payload)
def _records_matching(
log_json: str, predicate: Callable[[object], bool]
) -> dict[str, tuple[dict, Hlc]]:
"""Return ``{record_id: (payload, hlc)}`` for payloads passing ``predicate``.
Raises:
TypeError: If the top-level JSON isn't an object or a record's shape
doesn't match what :meth:`Record.from_dict` expects.
KeyError: If a record is missing an expected key.
ValueError: Via ``json.loads`` or :meth:`crdt_sync.Hlc.from_str`.
"""
raw = json.loads(log_json)
if not isinstance(raw, dict):
@ -197,11 +300,20 @@ def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
if field is None:
continue
payload, hlc = field
if _is_manual_payload(payload):
if predicate(payload):
result[record.id] = (payload, hlc)
return result
def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
"""Return ``{record_id: (payload, hlc)}`` for manual records in a log blob.
Raises the same decode errors as :func:`_records_matching`; callers treat a
corrupt device log as "no manual records from that device".
"""
return _records_matching(log_json, _is_manual_payload)
def pull_all_manual_records() -> list[tuple[str, dict]]:
"""Return all synced manual-workout records across every device log.
@ -212,66 +324,9 @@ def pull_all_manual_records() -> list[tuple[str, dict]]:
log yields fewer/no records rather than raising — manual sync, like session
sync, is optional.
"""
token = read_sync_token()
if token is None:
_logger.warning(
"Pulling NO synced manual workouts: no sync token at %s — GitHub "
"sync is OFF, so workouts logged on the phone will NOT count here. "
"Create a fine-grained PAT with contents:write on %s/%s and save it "
"there (chmod 600)",
SYNC_TOKEN_FILE,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
client = sync_client()
if client is None:
return []
client = remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
)
try:
devices = with_sync_retry(
lambda: client.list_directory(_DEVICES_PREFIX),
description="list synced device logs",
)
except GitHubSyncError as exc:
_logger.warning(
"Could not list device logs under %s in %s/%s: %s — pulling NO "
"synced manual workouts, so phone-logged workouts will not count",
_DEVICES_PREFIX,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
exc,
)
return []
merged: dict[str, tuple[dict, Hlc]] = {}
for device in devices:
path = f"{_DEVICES_PREFIX}/{device}/log.json"
try:
text = client.get_file_text(path)
except GitHubSyncError as exc:
_logger.warning(
"Could not fetch device log %s: %s — SKIPPING that device, so "
"any manual workouts it holds will not count",
path,
exc,
)
continue
if text is None:
continue
try:
records = _manual_records(text)
except (ValueError, KeyError, TypeError) as exc:
_logger.warning("Corrupt sync data at %s: %s", path, exc)
continue
for rid, (payload, hlc) in records.items():
existing = merged.get(rid)
if existing is None or existing[1] < hlc:
merged[rid] = (payload, hlc)
merged = _merge_device_records(client, _manual_records, "manual workouts")
return [(rid, payload) for rid, (payload, _hlc) in merged.items()]

View File

@ -227,6 +227,16 @@ class ScreenLocker(
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
self._check_heat_skip_exit()
def sync_now(self) -> None:
"""Run one sync pass: publish this PC's workouts, ingest everyone's.
The public entry point behind ``--sync-only``, which the
``workout-sync.timer`` unit calls. Sync used to happen only inside the
locker's startup path, so a workout finished after login stayed
invisible until the next login.
"""
self._ingest_synced_manual_workouts()
def _ingest_synced_manual_workouts(self) -> None:
"""Sync manual workouts: publish this PC's, ingest everyone else's.
@ -342,6 +352,24 @@ if __name__ == "__main__":
_sl.workout_data = {}
run_status(_sl)
if "--sync-only" in sys.argv:
# Headless sync for the timer unit: pull other devices' workouts and
# apply their credit, with no Tk and no lock screen. Sync used to run
# ONLY at process start, so a workout finished after login was not seen
# until the next login — the timer closes that window.
#
# Same __init__ bypass as --status: this path touches only log_file and
# workout_data, and constructing the real ScreenLocker would build a UI.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
_sl = object.__new__(ScreenLocker)
_sl.log_file = Path(__file__).resolve().parent / "workout_log.json"
_sl.workout_data = {}
_sl.sync_now()
sys.exit(0)
demo_mode = True
verify_only = "--verify-workout" in sys.argv

View File

@ -0,0 +1,97 @@
"""Record/payload builders shared by the workout-sync test modules.
Split out of ``test_workout_sync`` (and imported by it and its ``_part2``)
so neither file exceeds the repo's 400-line limit. Everything here builds
crdt-sync wire data the way the phone app actually emits it -- in
particular ``_session_payload`` carries NO ``kind`` field, because session
detection is by shape, and a fixture that invented a ``kind`` would hide
the very bug these tests exist to pin down.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from crdt_sync import Hlc, Record
if TYPE_CHECKING:
from pathlib import Path
from types import ModuleType
import pytest
def _record_json(payload: dict, *, wall_time_ms: int = 1000) -> dict:
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
record = Record(id="x", fields={"payload": (payload, hlc)})
return record.to_dict()
def _session_payload(**extra: object) -> dict:
"""A StrongLifts session as the phone app's ``toJson()`` actually emits it.
Deliberately carries NO ``kind`` field: session detection is by shape (an
``exercises`` list), and keying it on ``kind`` is the bug that made real
sessions invisible.
"""
payload: dict = {
"exercises": [{"name": "Squat", "sets": 5}],
"duration_seconds": 1800,
}
payload.update(extra)
return payload
def _session_record_dict(
record_id: str, payload: dict, *, wall_time_ms: int = 1000
) -> dict:
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
def _manual_payload(**extra: object) -> dict:
payload = {"kind": "manual_workout", "date": "2026-07-13"}
payload.update(extra)
return payload
def _manual_record_dict(
record_id: str, payload: dict, *, wall_time_ms: int = 1000
) -> dict:
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
def _multi_device_client(device_logs: dict[str, object]) -> MagicMock:
"""A client whose ``devices/<id>/log.json`` tree is ``device_logs``.
An ``Exception`` value is raised on fetch, so a single dict describes
healthy, missing and unreachable devices at once.
"""
client = MagicMock()
client.list_directory.return_value = list(device_logs)
def _get(path: str) -> object:
device = path.split("/")[-2]
value = device_logs[device]
if isinstance(value, Exception):
raise value
return value
client.get_file_text.side_effect = _get
return client
def _firebase_config(
module: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Path:
"""Point CONFIG_FILE at a real file so the Firebase branch is taken.
The autouse ``_no_real_firebase_config`` fixture aims it at a nonexistent
path, so any test wanting the Firebase side must opt back in explicitly.
"""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(module, "CONFIG_FILE", config)
return config

View File

@ -342,3 +342,31 @@ class TestTryAutoUpgradeEarlyBirdRunnerUp:
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_early_bird() is False
class TestSyncNow:
"""Tests for sync_now, the public entry point behind ``--sync-only``."""
def test_runs_one_full_sync_pass(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The timer unit's entry point must actually push and ingest.
``workout-sync.timer`` calls this headlessly; if it stopped delegating,
the timer would run and silently sync nothing.
"""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker.screen_lock.pull_all_manual_records",
return_value=[("manual:x", {})],
),
patch("screen_locker.screen_lock.push_pc_workouts") as push,
patch(
"screen_locker.screen_lock.ingest_manual_records",
return_value=["manual:x"],
) as ingest,
):
locker.sync_now()
push.assert_called_once()
ingest.assert_called_once()

View File

@ -1,25 +1,35 @@
"""Tests for the phone-workout GitHub sync pull (crdt-sync transport)."""
"""Tests for the phone-workout sync pull (crdt-sync transport).
Covers the token read, the payload predicates, the per-log record extractors
and the cross-device merge. The backend selection (``sync_client`` /
``remote_client``) and the two public pulls live in ``_part2``.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record
from crdt_sync import GitHubSyncError, Hlc, Record
import pytest
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
_manual_payload,
_manual_record_dict,
_multi_device_client,
_record_json,
_session_payload,
_session_record_dict,
)
# The autouse `_isolate_sync_token` fixture in conftest.py already redirects
# `_workout_sync.SYNC_TOKEN_FILE` into a per-test tmp_path, so tests here can
# write to it directly without needing their own isolation fixture.
def _record_json(payload: dict, *, wall_time_ms: int = 1000) -> dict:
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
record = Record(id="x", fields={"payload": (payload, hlc)})
return record.to_dict()
# The autouse `isolate_sync_token` fixture (registered repo-wide via the
# `pytest_plugins` entry for `screen_locker.tests.test_sync_fixtures` in
# pyproject.toml) already redirects `_workout_sync.SYNC_TOKEN_FILE` into a
# per-test tmp_path, so tests here can write to it directly without needing
# their own isolation fixture. The autouse `_no_real_firebase_config` fixture
# in conftest.py likewise points `CONFIG_FILE` at a nonexistent path, so tests
# that want the Firebase branch must monkeypatch it back at a file they own.
class TestReadSyncToken:
@ -35,116 +45,29 @@ class TestReadSyncToken:
assert _workout_sync.read_sync_token() == "abc123"
class TestLatestPayload:
def test_returns_none_for_an_empty_log(self) -> None:
assert _workout_sync._latest_payload("{}") is None
class TestIsSessionPayload:
"""Sessions are identified by SHAPE (an ``exercises`` list), not ``kind``."""
def test_returns_the_only_records_payload(self) -> None:
log = {"a": _record_json({"succeeded": True})}
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
def test_true_for_a_dict_with_an_exercises_list(self) -> None:
assert _workout_sync._is_session_payload(_session_payload()) is True
def test_returns_the_payload_with_the_greatest_clock(self) -> None:
log = {
"old": _record_json({"succeeded": False}, wall_time_ms=100),
"new": _record_json({"succeeded": True}, wall_time_ms=200),
}
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
def test_true_even_with_an_empty_exercises_list(self) -> None:
assert _workout_sync._is_session_payload({"exercises": []}) is True
def test_skips_records_with_no_payload_field(self) -> None:
no_payload = Record(
id="a",
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
)
log = {"a": no_payload.to_dict()}
assert _workout_sync._latest_payload(json.dumps(log)) is None
def test_false_for_a_manual_workout(self) -> None:
assert _workout_sync._is_session_payload(_manual_payload()) is False
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
with pytest.raises(TypeError, match="not a JSON object"):
_workout_sync._latest_payload(json.dumps([1, 2, 3]))
def test_false_for_verified_records_sharing_the_same_log(self) -> None:
"""``*_verified`` records are non-manual but have no ``exercises``."""
assert _workout_sync._is_session_payload({"kind": "runnerup_verified"}) is False
assert _workout_sync._is_session_payload({"kind": "phone_verified"}) is False
def test_raises_type_error_when_payload_field_is_not_an_object(self) -> None:
log = {"a": _record_json("not-a-dict")}
with pytest.raises(TypeError, match="payload field is not a JSON object"):
_workout_sync._latest_payload(json.dumps(log))
def test_false_when_exercises_is_not_a_list(self) -> None:
assert _workout_sync._is_session_payload({"exercises": "Squat"}) is False
def test_raises_key_error_for_a_malformed_record(self) -> None:
log = {"a": {"id": "a"}} # missing "fields"
with pytest.raises(KeyError):
_workout_sync._latest_payload(json.dumps(log))
def test_raises_value_error_for_invalid_json(self) -> None:
with pytest.raises(ValueError, match="Expecting property name"):
_workout_sync._latest_payload("{not valid json")
def _mock_client(*, get_file_text: object) -> MagicMock:
client = MagicMock()
if isinstance(get_file_text, Exception):
client.get_file_text.side_effect = get_file_text
else:
client.get_file_text.return_value = get_file_text
return client
class TestPullSyncedWorkout:
def test_returns_none_none_when_no_token_is_configured(self) -> None:
with patch.object(_workout_sync, "GitHubSyncClient") as client_cls:
assert _workout_sync.pull_synced_workout() == (None, None)
client_cls.assert_not_called()
def test_returns_the_error_message_on_a_sync_error(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _mock_client(get_file_text=GitHubSyncError("offline"))
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, "offline")
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _mock_client(get_file_text=None)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
def test_returns_the_payload_on_success(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
log = json.dumps({"a": _record_json({"succeeded": True})})
client = _mock_client(get_file_text=log)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == ({"succeeded": True}, None)
def test_returns_a_corrupt_data_message_on_malformed_sync_data(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _mock_client(get_file_text="{not valid json")
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert data is None
assert error is not None
assert "corrupt sync data" in error
def _manual_payload(**extra: object) -> dict:
payload = {"kind": "manual_workout", "date": "2026-07-13"}
payload.update(extra)
return payload
def _manual_record_dict(
record_id: str, payload: dict, *, wall_time_ms: int = 1000
) -> dict:
hlc = Hlc(wall_time_ms=wall_time_ms, counter=0, node_id="phone")
return Record(id=record_id, fields={"payload": (payload, hlc)}).to_dict()
class TestLatestPayloadSkipsManual:
def test_manual_only_log_yields_no_session(self) -> None:
log = {"m": _manual_record_dict("manual:1", _manual_payload())}
assert _workout_sync._latest_payload(json.dumps(log)) is None
def test_returns_session_even_when_a_later_manual_exists(self) -> None:
log = {
"s": _record_json({"succeeded": True}, wall_time_ms=100),
"m": _manual_record_dict("manual:1", _manual_payload(), wall_time_ms=999),
}
assert _workout_sync._latest_payload(json.dumps(log)) == {"succeeded": True}
def test_false_for_a_non_dict_payload(self) -> None:
assert _workout_sync._is_session_payload("not-a-dict") is False
assert _workout_sync._is_session_payload(None) is False
class TestIsManualPayload:
@ -152,14 +75,46 @@ class TestIsManualPayload:
assert _workout_sync._is_manual_payload({"kind": "manual_workout"}) is True
def test_false_for_session_or_non_dict(self) -> None:
assert _workout_sync._is_manual_payload({"succeeded": True}) is False
assert _workout_sync._is_manual_payload(_session_payload()) is False
assert _workout_sync._is_manual_payload("not-a-dict") is False
class TestSessionRecords:
def test_returns_only_session_records(self) -> None:
log = {
"s": _session_record_dict("session:1", _session_payload()),
"m": _manual_record_dict("manual:1", _manual_payload()),
"v": _manual_record_dict("verified:1", {"kind": "runnerup_verified"}),
}
result = _workout_sync._session_records(json.dumps(log))
assert list(result) == ["session:1"]
def test_skips_records_without_a_payload_field(self) -> None:
no_payload = Record(
id="a",
fields={"other": (1, Hlc(wall_time_ms=1, counter=0, node_id="phone"))},
)
log = {"a": no_payload.to_dict()}
assert _workout_sync._session_records(json.dumps(log)) == {}
def test_raises_type_error_when_top_level_is_not_an_object(self) -> None:
with pytest.raises(TypeError, match="not a JSON object"):
_workout_sync._session_records(json.dumps([1, 2, 3]))
def test_raises_value_error_for_invalid_json(self) -> None:
with pytest.raises(ValueError, match="Expecting property name"):
_workout_sync._session_records("{not valid json")
def test_raises_key_error_for_a_malformed_record(self) -> None:
log = {"a": {"id": "a"}} # missing "fields"
with pytest.raises(KeyError):
_workout_sync._session_records(json.dumps(log))
class TestManualRecords:
def test_returns_only_manual_records(self) -> None:
log = {
"s": _record_json({"succeeded": True}),
"s": _record_json(_session_payload()),
"m": _manual_record_dict("manual:1", _manual_payload()),
}
result = _workout_sync._manual_records(json.dumps(log))
@ -178,166 +133,146 @@ class TestManualRecords:
_workout_sync._manual_records(json.dumps([1, 2, 3]))
def _multi_device_client(device_logs: dict[str, object]) -> MagicMock:
client = MagicMock()
client.list_directory.return_value = list(device_logs)
class TestMergeDeviceRecords:
"""The walk over EVERY ``devices/<id>/log.json``, not one hardcoded path."""
def _get(path: str) -> object:
device = path.split("/")[-2]
value = device_logs[device]
if isinstance(value, Exception):
raise value
return value
client.get_file_text.side_effect = _get
return client
class TestPullAllManualRecords:
def test_returns_empty_when_no_token(self) -> None:
assert _workout_sync.pull_all_manual_records() == []
def test_returns_empty_when_listing_fails(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_all_manual_records() == []
def test_merges_manual_records_across_devices(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
def test_merges_records_across_every_device(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _manual_record_dict("manual:a", _manual_payload())}
{"a": _session_record_dict("session:a", _session_payload())}
),
"pc": json.dumps(
{"b": _manual_record_dict("manual:b", _manual_payload())}
{"b": _session_record_dict("session:b", _session_payload())}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert sorted(merged) == ["session:a", "session:b"]
def test_skips_missing_and_corrupt_device_logs(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
def test_dedups_the_same_id_keeping_the_highest_clock(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _manual_record_dict("manual:a", _manual_payload())}
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert len(merged) == 1
assert merged["session:a"][0]["note"] == "NEW"
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="NEW"),
wall_time_ms=200,
)
}
),
"pc": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLD"),
wall_time_ms=100,
)
}
),
}
)
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert merged["session:a"][0]["note"] == "NEW"
def test_skips_a_corrupt_device_log(self) -> None:
client = _multi_device_client(
{
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"gone": GitHubSyncError("404"),
"empty": None,
"corrupt": "{not json",
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert [rid for rid, _ in result] == ["manual:a"]
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_dedups_same_id_keeping_highest_clock(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
def test_skips_an_unreachable_device(self) -> None:
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="OLD"),
wall_time_ms=100,
)
}
),
"pc": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="NEW"),
wall_time_ms=200,
)
}
"good": json.dumps(
{"a": _session_record_dict("session:a", _session_payload())}
),
"gone": GitHubSyncError("404"),
"empty": None,
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert len(result) == 1
assert result[0][1]["cost"] == "NEW"
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
assert list(merged) == ["session:a"]
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="NEW"),
wall_time_ms=200,
def test_returns_empty_on_a_listing_failure_when_not_strict(self) -> None:
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
merged = _workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
}
),
"pc": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="OLD"),
wall_time_ms=100,
assert merged == {}
def test_reraises_on_a_listing_failure_when_strict(self) -> None:
"""Callers separating "nothing to sync" from "sync broke" need the raise."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with pytest.raises(GitHubSyncError, match="offline"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions", strict=True
)
}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert len(result) == 1
assert result[0][1]["cost"] == "NEW"
class TestRemoteClient:
"""Which backend the workout pull reads from during the cutover."""
def test_stays_on_github_without_firebase_config(
self, monkeypatch: pytest.MonkeyPatch
def test_warns_about_a_listing_failure(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""An unconfigured machine must not reach the network at all."""
monkeypatch.setattr(
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
"""A total pull failure must never be silent."""
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
github = object()
assert "offline" in caplog.text
assert _workout_sync.remote_client(github) is github
def test_mirrors_to_github_when_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
def test_warns_about_a_skipped_device(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Configured: reads union both, so either backend's workout counts."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
monkeypatch.setattr(
_workout_sync,
"mirror_client_for",
lambda _app, client: ("mirror", client),
client = _multi_device_client({"gone": GitHubSyncError("404")})
with caplog.at_level("WARNING"):
_workout_sync._merge_device_records(
client, _workout_sync._session_records, "sessions"
)
github = object()
assert _workout_sync.remote_client(github) == ("mirror", github)
def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken Firebase must degrade to GitHub, never fail the pull."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom)
github = object()
assert _workout_sync.remote_client(github) is github
assert "SKIPPING that device" in caplog.text

View File

@ -0,0 +1,400 @@
"""Backend selection and the two public pulls, split from ``test_workout_sync``.
``sync_client`` decides WHICH backend is read; ``pull_synced_workout`` and
``pull_all_manual_records`` are what the locker actually calls.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from crdt_sync import (
ConfigError,
FirebaseAuthError,
FirebaseSyncError,
GitHubSyncError,
)
import pytest
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import (
_firebase_config,
_manual_payload,
_manual_record_dict,
_multi_device_client,
_session_payload,
_session_record_dict,
)
if TYPE_CHECKING:
from pathlib import Path
class TestSyncClient:
"""Every configuration of (GitHub token, Firebase config), plus failure."""
def test_returns_none_when_nothing_is_configured(self) -> None:
assert _workout_sync.sync_client() is None
def test_warns_when_nothing_is_configured(
self, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level("WARNING"):
_workout_sync.sync_client()
assert "sync is OFF" in caplog.text
def test_uses_github_alone_when_only_the_token_exists(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
github = MagicMock()
with patch.object(_workout_sync, "GitHubSyncClient", return_value=github):
assert _workout_sync.sync_client() is github
def test_mirrors_when_both_the_token_and_firebase_exist(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
_firebase_config(_workout_sync, tmp_path, monkeypatch)
github = MagicMock()
monkeypatch.setattr(
_workout_sync, "mirror_client_for", lambda _app, client: ("mirror", client)
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=github):
assert _workout_sync.sync_client() == ("mirror", github)
def test_uses_firebase_alone_when_only_the_config_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Firebase-only machine must still sync -- no PAT required."""
_firebase_config(_workout_sync, tmp_path, monkeypatch)
firebase = MagicMock()
monkeypatch.setattr(_workout_sync, "firebase_client_for", lambda _app: firebase)
assert _workout_sync.sync_client() is firebase
@pytest.mark.parametrize(
"error",
[
ConfigError("no password"),
FirebaseAuthError("bad credentials"),
FirebaseSyncError("backend down"),
],
)
def test_returns_none_when_firebase_only_is_unusable(
self, error: Exception, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No PAT to fall back to: report it loudly, do not raise."""
_firebase_config(_workout_sync, tmp_path, monkeypatch)
def _boom(*_args: object, **_kwargs: object) -> None:
raise error
monkeypatch.setattr(_workout_sync, "firebase_client_for", _boom)
assert _workout_sync.sync_client() is None
def test_warns_when_firebase_only_is_unusable(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
_firebase_config(_workout_sync, tmp_path, monkeypatch)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "backend down"
raise FirebaseSyncError(message)
monkeypatch.setattr(_workout_sync, "firebase_client_for", _boom)
with caplog.at_level("WARNING"):
_workout_sync.sync_client()
assert "backend down" in caplog.text
class TestPullSyncedWorkout:
def test_returns_none_none_when_no_token_is_configured(self) -> None:
with patch.object(_workout_sync, "GitHubSyncClient") as client_cls:
assert _workout_sync.pull_synced_workout() == (None, None)
client_cls.assert_not_called()
def test_returns_the_error_message_on_a_sync_error(self) -> None:
"""A failed device LISTING is a real error, not "nothing to sync"."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, "offline")
def test_does_not_propagate_a_firebase_sync_error(self) -> None:
"""Regression: FirebaseSyncError is a SIBLING of GitHubSyncError.
Catching only ``GitHubSyncError`` let a Firebase failure escape and
crash the locker with status=1/FAILURE. It must come back as an error
tuple like any other backend failure.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = FirebaseSyncError("firebase exploded")
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert data is None
assert error == "firebase exploded"
def test_does_not_propagate_a_firebase_error_on_a_firebase_only_machine(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The crash's real shape: no PAT, Firebase primary, backend throws.
The configuration the locker actually died in, pinned in its own right
so a future GitHub-only narrowing of the handler cannot pass.
"""
_firebase_config(_workout_sync, tmp_path, monkeypatch)
client = MagicMock()
client.list_directory.side_effect = FirebaseSyncError("firebase exploded")
monkeypatch.setattr(_workout_sync, "firebase_client_for", lambda _app: client)
data, error = _workout_sync.pull_synced_workout()
assert data is None
assert error == "firebase exploded"
def test_returns_none_none_when_nothing_has_been_pushed_yet(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client({"phone": None})
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
def test_returns_none_none_when_there_are_no_devices_at_all(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client({})
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
def test_returns_the_payload_on_success(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
payload = _session_payload()
client = _multi_device_client(
{"phone": json.dumps({"a": _session_record_dict("session:a", payload)})}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (payload, None)
def test_finds_a_session_in_a_uuid_named_device_directory(self) -> None:
"""The actual bug: the phone's dir is a per-install uuid, not "phone".
Reading a single hardcoded ``devices/phone/log.json`` returned a stale
session forever while newer ones sat unread one directory over.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
fresh = _session_payload(note="FROM-UUID-DIR")
client = _multi_device_client(
{
"pc": json.dumps(
{
"old": _session_record_dict(
"session:old",
_session_payload(note="STALE"),
wall_time_ms=100,
)
}
),
"0f1d2c3b-4a59-4c6d-8e7f-a0b1c2d3e4f5": json.dumps(
{
"new": _session_record_dict(
"session:new", fresh, wall_time_ms=200
)
}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (fresh, None)
def test_returns_the_highest_clock_session_across_devices(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
newest = _session_payload(note="NEWEST")
client = _multi_device_client(
{
"phone-a": json.dumps(
{
"a": _session_record_dict(
"session:a",
_session_payload(note="OLDER"),
wall_time_ms=100,
)
}
),
"phone-b": json.dumps(
{"b": _session_record_dict("session:b", newest, wall_time_ms=300)}
),
"phone-c": json.dumps(
{
"c": _session_record_dict(
"session:c",
_session_payload(note="MIDDLE"),
wall_time_ms=200,
)
}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert error is None
assert data == newest
def test_ignores_non_session_records_in_the_same_logs(self) -> None:
"""``phone_verified`` / ``runnerup_verified`` share these device logs.
They are non-manual but have no ``exercises``, so "not manual" alone
would hand back a record with none of the fields a caller expects --
even though here they carry the HIGHEST clocks in the log.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
session = _session_payload(note="REAL-SESSION")
client = _multi_device_client(
{
"phone": json.dumps(
{
"s": _session_record_dict(
"session:a", session, wall_time_ms=100
),
"v": _session_record_dict(
"verified:1",
{"kind": "phone_verified", "date": "2026-08-10"},
wall_time_ms=900,
),
"r": _session_record_dict(
"verified:2",
{"kind": "runnerup_verified", "date": "2026-08-10"},
wall_time_ms=950,
),
"m": _manual_record_dict(
"manual:1", _manual_payload(), wall_time_ms=999
),
}
)
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
data, error = _workout_sync.pull_synced_workout()
assert error is None
assert data == session
def test_skips_a_corrupt_device_log_instead_of_failing(self) -> None:
"""A corrupt log costs that device's sessions, not the whole pull."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
payload = _session_payload()
client = _multi_device_client(
{
"corrupt": "{not valid json",
"phone": json.dumps({"a": _session_record_dict("session:a", payload)}),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (payload, None)
def test_returns_none_none_when_every_device_log_is_corrupt(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client({"corrupt": "{not valid json"})
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_synced_workout() == (None, None)
class TestPullAllManualRecords:
def test_returns_empty_when_no_token(self) -> None:
assert _workout_sync.pull_all_manual_records() == []
def test_returns_empty_when_listing_fails(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = MagicMock()
client.list_directory.side_effect = GitHubSyncError("offline")
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
assert _workout_sync.pull_all_manual_records() == []
def test_merges_manual_records_across_devices(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _manual_record_dict("manual:a", _manual_payload())}
),
"pc": json.dumps(
{"b": _manual_record_dict("manual:b", _manual_payload())}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
def test_skips_missing_and_corrupt_device_logs(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
{"a": _manual_record_dict("manual:a", _manual_payload())}
),
"gone": GitHubSyncError("404"),
"empty": None,
"corrupt": "{not json",
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert [rid for rid, _ in result] == ["manual:a"]
def test_dedups_same_id_keeping_highest_clock(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="OLD"),
wall_time_ms=100,
)
}
),
"pc": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="NEW"),
wall_time_ms=200,
)
}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert len(result) == 1
assert result[0][1]["cost"] == "NEW"
def test_ignores_a_lower_clock_duplicate_seen_later(self) -> None:
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="NEW"),
wall_time_ms=200,
)
}
),
"pc": json.dumps(
{
"a": _manual_record_dict(
"manual:a",
_manual_payload(cost="OLD"),
wall_time_ms=100,
)
}
),
}
)
with patch.object(_workout_sync, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert len(result) == 1
assert result[0][1]["cost"] == "NEW"

View File

@ -0,0 +1,62 @@
"""``remote_client`` mirror selection, split from ``test_workout_sync_part2``.
Kept separate from the pulls so no workout-sync test file exceeds the repo's
400-line limit.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from crdt_sync import ConfigError
from screen_locker import _workout_sync
from screen_locker.tests._workout_sync_fixtures import _firebase_config
if TYPE_CHECKING:
import pytest
class TestRemoteClient:
"""Which backend the workout pull reads from during the cutover."""
def test_stays_on_github_without_firebase_config(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unconfigured machine must not reach the network at all."""
monkeypatch.setattr(
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
)
github = object()
assert _workout_sync.remote_client(github) is github
def test_mirrors_to_github_when_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Configured: reads union both, so either backend's workout counts."""
_firebase_config(_workout_sync, tmp_path, monkeypatch)
monkeypatch.setattr(
_workout_sync,
"mirror_client_for",
lambda _app, client: ("mirror", client),
)
github = object()
assert _workout_sync.remote_client(github) == ("mirror", github)
def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken Firebase must degrade to GitHub, never fail the pull."""
_firebase_config(_workout_sync, tmp_path, monkeypatch)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom)
github = object()
assert _workout_sync.remote_client(github) is github

View File

@ -17,7 +17,20 @@ void main() async {
// Before anything that stamps an Hlc or syncs: until this resolves, the
// device id falls back to the pre-migration constant.
await initSyncDeviceId();
await BackupService.instance.requestStoragePermission();
// Storage permission MUST be settled before the DB is opened: opening it
// seeds factory defaults for any missing exercise, and restoring afterwards
// is a no-op once rows exist. Losing the race means a reinstall silently
// comes up at defaults with months of progression gone -- which is what
// happened on 2026-08-05.
final storageGranted = await BackupService.instance
.requestStoragePermission();
if (!storageGranted) {
debugPrint(
'WorkoutApp: storage permission DENIED — the backup at $kBackupPath can '
'neither be read nor written. Progression cannot be restored on this '
'launch and will not be protected against the next reinstall.',
);
}
await StorageService.init();
await StorageService.instance.restoreFromBackupIfNeeded();
await HttpServerService.instance.start();

View File

@ -57,6 +57,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
Future<void> _load() async {
// Put any of this device's own sessions that only exist remotely back into
// the local table FIRST, so they show up in this same load. A reinstall
// wipes local history while Firebase/GitHub keep it; without this the app
// silently shows less history than actually happened.
final remote = await _readSyncedPayloads();
await StorageService.instance.restoreSyncedSessions(remote);
final rows = await StorageService.instance.getWorkoutHistory(limit: 200);
final names = <String>[];
final seen = <String>{};
@ -76,7 +82,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
state = await StorageService.instance.getExerciseState(_selected);
// coverage:ignore-end
}
final synced = await _loadSyncedWorkouts();
final synced = _syncedOnly(remote);
if (mounted) {
setState(() {
_rows = rows;
@ -88,20 +94,27 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
}
/// Every payload the sync backends hold, across all devices.
///
/// Read once per load and used twice: to restore this device's own missing
/// sessions into local history, and to list the PC-only records below them.
Future<List<Map<String, dynamic>>> _readSyncedPayloads() =>
WorkoutSyncService(
httpClient: widget.httpClient,
).readMergedWorkoutPayloads();
/// Workouts the PC published that this phone has no local record of.
///
/// The PC pushes its whole `workout_log.json` (RunnerUp runs and manual
/// entries included), so pulling them here is what makes both devices show
/// the SAME history. Sorted newest-first to match the local session list.
Future<List<Map<String, dynamic>>> _loadSyncedWorkouts() async {
final payloads = await WorkoutSyncService(
httpClient: widget.httpClient,
).readMergedWorkoutPayloads();
final synced =
/// StrongLifts sessions are deliberately excluded: they are restored into
/// local history instead, so listing them here too would double them up.
List<Map<String, dynamic>> _syncedOnly(
List<Map<String, dynamic>> payloads,
) =>
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
return synced;
}
Future<void> _pickExercise(String name) async {
ExerciseState? state;

View File

@ -59,10 +59,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
final Map<String, int> _successThresholds = {};
final Map<String, int> _failThresholds = {};
final Map<String, double> _weights = {};
final Map<String, int> _reps = {};
// Debounce weight saves to avoid resetting streaks on every tap.
final Map<String, Timer> _weightTimers = {};
// Same debounce for reps, for the same reason.
final Map<String, Timer> _repsTimers = {};
final _tokenController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@ -154,6 +158,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
for (final t in _weightTimers.values) {
t.cancel();
}
for (final t in _repsTimers.values) {
t.cancel();
}
_tokenController.dispose();
_emailController.dispose();
_passwordController.dispose();
@ -169,6 +176,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_successThresholds[s.name] = s.successThreshold;
_failThresholds[s.name] = s.failThreshold;
_weights[s.name] = s.weight;
_reps[s.name] = s.reps;
}
_tokenController.text = syncSettings.token;
_loading = false;
@ -299,6 +307,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
void _onRepsChanged(String name, int value) {
setState(() => _reps[name] = value);
_repsTimers[name]?.cancel();
_repsTimers[name] = Timer(const Duration(milliseconds: 600), () {
unawaited(StorageService.instance.setExerciseReps(name, value));
});
}
Future<void> _onThresholdChanged(String name, int success, int fail) async {
setState(() {
_successThresholds[name] = success;
@ -404,6 +420,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}),
const SizedBox(height: 20),
const _SectionHeader('TARGET REPS'),
const SizedBox(height: 4),
Text(
'Override target reps per set. Resets streak counters. '
'Progression only ever raises reps, so this is the only way '
'to lower one.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: 12),
..._orderedNames.map((name) {
final r = _reps[name];
if (r == null) return const SizedBox.shrink();
return _RepsRow(
name: name,
reps: r,
onChanged: (v) => _onRepsChanged(name, v),
);
}),
const SizedBox(height: 20),
const _SectionHeader('PROGRESSION THRESHOLDS'),
const SizedBox(height: 4),
Text(
@ -559,6 +597,59 @@ class _SectionHeader extends StatelessWidget {
}
}
class _RepsRow extends StatelessWidget {
const _RepsRow({
required this.name,
required this.reps,
required this.onChanged,
});
final String name;
final int reps;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Expanded(
child: Text(
name,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
_StepperButton(
icon: Icons.remove,
onTap: () => onChanged((reps - 1).clamp(1, 999)),
),
SizedBox(
width: 72,
child: Text(
'$reps reps',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.label,
fontWeight: FontWeight.bold,
),
),
),
_StepperButton(
icon: Icons.add,
onTap: () => onChanged((reps + 1).clamp(1, 999)),
),
],
),
);
}
}
class _WeightRow extends StatelessWidget {
const _WeightRow({
required this.name,

View File

@ -14,7 +14,7 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:permission_handler/permission_handler.dart';
/// Default directory backups live in on external storage.
@ -87,9 +87,13 @@ class BackupService {
/// Writes [data] to the backup path as pretty-printed JSON.
///
/// Silently swallows errors (e.g. permission not granted, no external
/// storage) so the caller never crashes.
Future<void> export(Map<String, dynamic> data) async {
/// Never throws — a failed backup must not crash a workout in progress — but
/// it is NOT silent: a backup that quietly stops working is invisible until
/// the reinstall that needed it, which is exactly how the 2026-08-05 wipe
/// destroyed months of progression.
///
/// Returns true if the backup was written.
Future<bool> export(Map<String, dynamic> data) async {
try {
final dir = Directory(_baseDir);
if (!dir.existsSync()) {
@ -98,8 +102,14 @@ class BackupService {
await File(
_backupPath,
).writeAsString(const JsonEncoder.withIndent(' ').convert(data));
} on Exception {
// Backup is best-effort; never throw.
return true;
} on Exception catch (e) {
debugPrint(
'WorkoutApp: BACKUP FAILED to $_backupPath: $e — progression state is '
'now unprotected against an uninstall/reinstall. Check that storage '
'permission is granted.',
);
return false;
}
}
@ -107,13 +117,29 @@ class BackupService {
/// Returns the parsed backup JSON, or null if the file does not exist or
/// is unreadable.
///
/// Distinguishes the two cases in the log: "no backup exists" is normal on a
/// genuinely first install, whereas "a backup exists but could not be read"
/// means a restore is about to fall through to defaults and silently lose
/// real data. The second must never pass unnoticed.
Future<Map<String, dynamic>?> readBackup() async {
try {
final f = File(_backupPath);
if (!f.existsSync()) return null;
try {
if (!f.existsSync()) {
debugPrint(
'WorkoutApp: no backup at $_backupPath — nothing to restore. Normal '
'on a first install; on a REINSTALL it means the backup was lost.',
);
return null;
}
final raw = await f.readAsString();
return jsonDecode(raw) as Map<String, dynamic>;
} on Exception {
} on Exception catch (e) {
debugPrint(
'WorkoutApp: BACKUP UNREADABLE at $_backupPath: $e — a backup EXISTS '
'but cannot be parsed, so restore will fall through to defaults and '
'lose progression state. Do not let the app overwrite it.',
);
return null;
}
}

View File

@ -316,6 +316,22 @@ class StorageService {
);
}
/// Sets the target reps for [name], resetting streaks.
///
/// Progression can only ever raise reps (by one, and only once an exercise
/// is at its max weight), so without this there is no way to lower a rep
/// target that a defaults re-seed set wrong -- the 2026-08-05 wipe dropped
/// Situp from 31 to the default 30 with no way back short of editing the DB.
Future<void> setExerciseReps(String name, int reps) async {
await _db.update(
'exercise_state',
{'reps': reps, 'success_streak': 0, 'fail_streak': 0},
where: 'name = ?',
whereArgs: [name],
);
unawaited(_backupNow());
}
/// Sets the working weight for [name], resetting streaks.
Future<void> setExerciseWeight(String name, double weight) async {
await _db.update(
@ -449,6 +465,76 @@ class StorageService {
}
/// Returns up to [limit] rows from workout history, newest first.
/// Inserts synced sessions this device has no local record of.
///
/// These are the user's OWN completed workouts, already in Firebase/GitHub;
/// a reinstall wipes the local `workout_history` table while the remote copy
/// survives, so the app ends up showing far less history than actually
/// happened. This puts the real records back rather than inventing anything.
///
/// Idempotent: a session is matched on (date, start_time) taken from the
/// payload, so repeated syncs never duplicate a row. Sessions the device
/// already has always win -- the local row is the one the progression
/// engine acted on.
///
/// Returns the number of sessions restored.
Future<int> restoreSyncedSessions(
List<Map<String, dynamic>> payloads,
) async {
if (payloads.isEmpty) return 0;
final existing = await _db.query('workout_history', columns: ['json']);
final seen = <String>{
for (final row in existing) _sessionKey(_decodeJson(row['json'])),
};
var restored = 0;
for (final payload in payloads) {
// Only real sessions: a payload with no exercises is a PC-side
// runnerup/manual record, which belongs in the synced list, not here.
if (payload['exercises'] is! List) continue;
final key = _sessionKey(payload);
if (key.isEmpty || seen.contains(key)) continue;
seen.add(key);
await _db.insert('workout_history', {
'date': payload['date'] as String? ?? '',
'workout_type': payload['workout_type'] as String? ?? '',
'duration_seconds': (payload['duration_seconds'] as num?)?.toInt() ?? 0,
'succeeded': (payload['succeeded'] as bool? ?? false) ? 1 : 0,
'json': jsonEncode(payload),
});
restored++;
}
if (restored > 0) {
debugPrint('WorkoutApp: restored $restored synced session(s) locally.');
unawaited(_backupNow());
}
return restored;
}
/// Identity of a session: its date plus start time, both from the payload.
///
/// `start_time` alone would be enough, but pairing it with the date keeps
/// the key readable in logs and survives a payload missing either field.
static String _sessionKey(Map<String, dynamic>? payload) {
if (payload == null) return '';
final start = payload['start_time'] as String? ?? '';
final date = payload['date'] as String? ?? '';
return start.isEmpty && date.isEmpty ? '' : '$date|$start';
}
static Map<String, dynamic>? _decodeJson(Object? raw) {
if (raw is! String || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : null;
} on FormatException {
// A corrupt local row must not block restoring the rest; it simply
// cannot be matched against, so it is treated as "not seen".
return null;
}
}
/// Returns up to [limit] past sessions, newest first.
Future<List<Map<String, dynamic>>> getWorkoutHistory({
int limit = 60,
}) async {
@ -491,10 +577,34 @@ class StorageService {
// ── Backup / restore ───────────────────────────────────────────────────────
/// Exports all persistent data to external storage as a JSON snapshot.
///
/// Refuses to overwrite a richer existing backup with a freshly-seeded,
/// history-less DB. That combination is never a real state worth saving: it
/// only happens when a restore failed (permission denied, unreadable file)
/// and the DB is sitting at [workoutA]/[workoutB] defaults. The old code
/// exported anyway on the first write after such a seed, replacing the only
/// off-device copy of months of progression with default rows — the step
/// that turned the 2026-08-05 reinstall from recoverable into permanent.
Future<void> _backupNow() async {
final exerciseRows = await _db.query('exercise_state');
final historyRows = await _db.query('workout_history');
final settingsRows = await _db.query('settings');
if (historyRows.isEmpty) {
final existing = await BackupService.instance.readBackup();
final existingHistory =
(existing?['workout_history'] as List?) ?? const [];
if (existingHistory.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 '
'so the data stays recoverable.',
);
return;
}
}
await BackupService.instance.export({
'exercise_state': exerciseRows,
'workout_history': historyRows,
@ -514,7 +624,16 @@ class StorageService {
if (hasHistory > 0 || hasType != null) return; // DB has real data
final backup = await BackupService.instance.readBackup();
if (backup == null) return;
if (backup == null) {
// readBackup() has already said which case this is. Say what it MEANS:
// the DB is sitting at seeded defaults and that is now the live state.
debugPrint(
'WorkoutApp: no backup restored — the database is at factory defaults. '
'If this is a reinstall, progression state (weights, reps, streaks) '
'has been lost and must be re-entered.',
);
return;
}
await _db.transaction((txn) async {
for (final row in (backup['exercise_state'] as List? ?? [])

View File

@ -141,11 +141,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.7.0"
resolved-ref: "6eef99d66132e12eb5056ae8e1780b34237fa013"
ref: "crdt_sync_dart-v0.9.0"
resolved-ref: f902bbe0aa6689adbe4ef0ef6bb298655f4e136a
url: "https://github.com/kuhyx/utils"
source: git
version: "0.7.0"
version: "0.9.0"
crypto:
dependency: transitive
description:

View File

@ -12,7 +12,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.7.0
ref: crdt_sync_dart-v0.9.0
path: crdt_sync_dart
flutter:
sdk: flutter

View File

@ -134,14 +134,40 @@ void main() {
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('shows WEIGHTS and PROGRESSION THRESHOLDS sections', (
testWidgets('shows WEIGHTS, TARGET REPS and PROGRESSION THRESHOLDS', (
tester,
) async {
await _pump(tester, _wrap());
expect(find.text('WEIGHTS'), findsOneWidget);
// TARGET REPS and the thresholds below it are off-screen at the test
// viewport height, so scroll them into view rather than asserting on
// whatever happens to be built.
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
expect(find.text('TARGET REPS'), findsOneWidget);
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
expect(find.text('PROGRESSION THRESHOLDS'), findsOneWidget);
});
testWidgets('increment reps button increases the target reps', (
tester,
) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
await tester.pumpAndSettle();
// Situp defaults to 30 reps and is the only exercise at that value.
expect(find.text('30 reps'), findsOneWidget);
final plus = find.descendant(
of: find.ancestor(
of: find.text('30 reps'),
matching: find.byType(Row),
).first,
matching: find.byIcon(Icons.add),
);
await tester.tap(plus);
await tester.pumpAndSettle();
expect(find.text('31 reps'), findsOneWidget);
});
testWidgets('shows all exercise names from both workout plans', (
tester,
) async {
@ -193,6 +219,10 @@ void main() {
testWidgets('threshold circles show values 1-5', (tester) async {
await _pump(tester, _wrap());
// The threshold cards sit below WEIGHTS and TARGET REPS, past the test
// viewport's fold, so they are not built until scrolled to.
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
await tester.pumpAndSettle();
for (int i = 1; i <= 5; i++) {
expect(find.text('$i'), findsWidgets);
}
@ -582,6 +612,11 @@ void main() {
testWidgets('changing a success threshold persists it', (tester) async {
await _pump(tester, _wrap());
// Scroll to the section header first: the threshold rows below it are not
// built at all until then, so a `.first` finder for the label would throw
// "Bad state: No element" before it could match anything.
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
await tester.pumpAndSettle();
final label = find.text('↑ Increase after N successes').first;
await tester.scrollUntilVisible(
label,
@ -630,6 +665,33 @@ void main() {
expect(states2!.any((s) => s.failThreshold == 4), isTrue);
});
testWidgets('reps change is debounced then written to storage', (
tester,
) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
await tester.pumpAndSettle();
// Situp defaults to 30 reps and is the only exercise at that value.
final minus = find.descendant(
of: find
.ancestor(of: find.text('30 reps'), matching: find.byType(Row))
.first,
matching: find.byIcon(Icons.remove),
);
// Same 600ms debounce as weights: run on the real loop so the timer fires.
await tester.runAsync(() async {
await tester.tap(minus);
await Future<void>.delayed(const Duration(milliseconds: 800));
});
await tester.pump();
final after = (await tester.runAsync(
() => StorageService.instance.getExerciseState('Situp'),
))!.reps;
expect(after, 29, reason: 'the decrement must reach storage');
});
testWidgets('weight change is debounced then written to storage', (
tester,
) async {

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
@ -156,6 +157,25 @@ void main() {
});
});
group('setExerciseReps', () {
test('updates reps and resets streaks', () async {
// Progression can only ever RAISE reps, so this is the only way to
// correct a rep target a defaults re-seed set wrong.
const name = 'Situp';
await _svc.setExerciseReps(name, 31);
final state = await _svc.getExerciseState(name);
expect(state!.reps, 31);
expect(state.successStreak, 0);
expect(state.failStreak, 0);
});
test('can lower a rep target', () async {
const name = 'Situp';
await _svc.setExerciseReps(name, 25);
expect((await _svc.getExerciseState(name))!.reps, 25);
});
});
group('setExerciseWeight', () {
test('updates weight and resets streaks', () async {
final name = workoutA.first.name;
@ -438,6 +458,169 @@ void main() {
// ── Restore from backup ─────────────────────────────────────────────────────
// Regression guard for the 2026-08-05 data loss: a reinstall re-seeded the
// DB to factory defaults, and the first write afterwards exported those
// defaults over /sdcard/WorkoutTracker/backup.json -- destroying the only
// off-device copy of months of progression. The seed is survivable; the
// clobber is what made it permanent.
group('restoreSyncedSessions', () {
Map<String, dynamic> session(String date, String start) => {
'workout_type': 'B',
'date': date,
'start_time': start,
'duration_seconds': 3600,
'succeeded': true,
'exercises': [
{'name': 'Situp', 'targetReps': 31, 'targetWeight': 10.0, 'sets': []},
],
};
test('restores sessions the local DB is missing', () async {
final restored = await _svc.restoreSyncedSessions([
session('2026-07-17', '2026-07-17T09:48:34.338'),
session('2026-07-27', '2026-07-27T09:14:22.547'),
]);
expect(restored, 2);
final hist = await _svc.getWorkoutHistory();
expect(hist.length, 2);
expect(hist.first['date'], '2026-07-27');
});
test('is idempotent — re-syncing never duplicates a session', () async {
final payloads = [session('2026-07-17', '2026-07-17T09:48:34.338')];
expect(await _svc.restoreSyncedSessions(payloads), 1);
expect(await _svc.restoreSyncedSessions(payloads), 0);
expect((await _svc.getWorkoutHistory()).length, 1);
});
test('does not duplicate a session the device already recorded', () async {
final payload = session('2026-08-10', '2026-08-10T10:19:44.075703');
await _svc.saveSession(
date: '2026-08-10',
workoutType: 'B',
durationSeconds: 10313,
succeeded: true,
json: jsonEncode(payload),
);
expect(await _svc.restoreSyncedSessions([payload]), 0);
expect((await _svc.getWorkoutHistory()).length, 1);
});
test('ignores PC-side records that are not sessions', () async {
// runnerup/manual entries have no `exercises` and belong in the synced
// list, not local history.
final restored = await _svc.restoreSyncedSessions([
{'kind': 'runnerup_verified', 'date': '2026-07-12', 'type': 'x'},
{'kind': 'manual_workout', 'date': '2026-07-13', 'type': 'y'},
]);
expect(restored, 0);
expect(await _svc.getWorkoutHistory(), isEmpty);
});
test('empty payload list is a no-op', () async {
expect(await _svc.restoreSyncedSessions([]), 0);
});
test('skips a payload with no date or start_time', () async {
final restored = await _svc.restoreSyncedSessions([
{'exercises': <dynamic>[], 'duration_seconds': 60},
]);
expect(restored, 0);
});
test('a corrupt local row does not block restoring the rest', () async {
await _svc.saveSession(
date: '2026-07-01',
workoutType: 'A',
durationSeconds: 60,
succeeded: true,
json: 'not valid json',
);
final restored = await _svc.restoreSyncedSessions([
session('2026-07-17', '2026-07-17T09:48:34.338'),
]);
expect(restored, 1);
});
});
group('_backupNow clobber guard', () {
late Directory tmp;
setUp(() {
tmp = Directory.systemTemp.createTempSync('mw_clobber');
BackupService.baseDirForTesting = tmp.path;
});
tearDown(() {
BackupService.baseDirForTesting = kBackupDir;
tmp.deleteSync(recursive: true);
});
test('refuses to overwrite a real backup from a history-less DB', () async {
// A good backup: real progression and a real session.
await BackupService.instance.export({
'exercise_state': [
{
'name': 'Situp',
'weight': 10.0,
'reps': 31,
'success_streak': 2,
'fail_streak': 0,
'max_weight': 10.0,
'success_threshold': 5,
'fail_threshold': 2,
},
],
'workout_history': [
{
'date': '2026-07-17',
'workout_type': 'B',
'duration_seconds': 6465,
'succeeded': 1,
'json': '{}',
},
],
'settings': [
{'key': 'last_workout_type', 'value': 'B'},
],
});
// The DB here is freshly seeded: defaults, and no history at all.
// Any write triggers _backupNow(), which used to export over the good
// backup. setExerciseWeight is one such write.
await _svc.setExerciseWeight('Situp', 10.0);
// Let the unawaited _backupNow() settle.
await Future<void>.delayed(const Duration(milliseconds: 50));
final backup = await BackupService.instance.readBackup();
final history = backup!['workout_history'] as List;
final exercises = backup['exercise_state'] as List;
// The good backup must still be intact.
expect(history.length, 1, reason: 'real session was clobbered');
expect((history.first as Map)['date'], '2026-07-17');
final situp = exercises.firstWhere(
(e) => (e as Map)['name'] == 'Situp',
) as Map;
expect(situp['reps'], 31, reason: 'progression reps were clobbered');
});
test('still writes the backup once the DB has real history', () async {
await _svc.saveSession(
date: '2026-08-10',
workoutType: 'B',
durationSeconds: 10313,
succeeded: true,
json: '{}',
);
await Future<void>.delayed(const Duration(milliseconds: 50));
final backup = await BackupService.instance.readBackup();
expect(backup, isNotNull, reason: 'a real DB must still back itself up');
expect((backup!['workout_history'] as List).length, 1);
});
});
group('restoreFromBackupIfNeeded', () {
late Directory tmp;

15
workout-sync.service Normal file
View File

@ -0,0 +1,15 @@
[Unit]
Description=Pull phone workouts into the screen-locker log (crdt_sync)
[Service]
Type=oneshot
# No WorkingDirectory/PYTHONPATH needed: screen_locker is pip-installed
# (pip install --user --break-system-packages -e .), so /usr/bin/python3
# finds it via user site-packages. Run as a module for the same reason the
# fit check is (see dd233b2) -- it works where nothing is installed.
#
# --sync-only is headless: no DISPLAY, no Tk, no lock screen. It pulls other
# devices' workouts and applies their credit, which the locker itself only
# did once per process start -- so a workout finished after login was invisible
# until the next login.
ExecStart=/usr/bin/python3 -m screen_locker.screen_lock --sync-only

12
workout-sync.timer Normal file
View File

@ -0,0 +1,12 @@
[Unit]
Description=Periodically pull phone workouts into the screen-locker log
[Timer]
# Every 15 minutes, matching diet-guard-sync.timer and the phone app's
# WorkManager floor, so both sides converge on roughly the same cadence.
# Persistent catches a run missed while the machine was suspended.
OnCalendar=*-*-* *:00/15:00
Persistent=true
[Install]
WantedBy=timers.target