diff --git a/docs/todo/workstream-d-shared-crdt-transport.md b/docs/todo/workstream-d-shared-crdt-transport.md index 5ca4000..efaf290 100644 --- a/docs/todo/workstream-d-shared-crdt-transport.md +++ b/docs/todo/workstream-d-shared-crdt-transport.md @@ -3,14 +3,36 @@ ## Status Scoping pass complete — see `workstream-d-scoping-findings.md` (2026-07-04) -for the answers to the four questions below and two sub-decisions that fell -out of it. Still not implemented; those sub-decisions need a user call -before any code is written. Split out from the original status-view plan +for the answers to the four questions below and the two sub-decisions that +fell out of it (both resolved by explicit user call, 2026-07-04). + +**The two shared libraries are now implemented and locally verified +(2026-07-05):** +- `~/crdt-sync` (Python) — 60 tests, 100% branch coverage, clean + ruff/mypy/pylint/bandit/pre-commit. Local git repo, one commit. +- `~/crdt_sync_dart` (pure Dart, no Flutter dep) — 59 tests, clean + `dart analyze`. Local git repo, one commit. + +Both implement the unified LWW-map-with-sticky-remove scheme (`Hlc`, +`Record`/`merge_record`, `Log`/`merge_logs`) plus a GitHub Contents-API +transport client and a domain-agnostic `sync_log`/`syncLog` orchestrator. + +Both were originally pushed as standalone public repos (2026-07-05, matching +`gatelock`'s convention), then consolidated (2026-07-10) into the shared +`github.com/kuhyx/utils` monorepo alongside `gatelock` and `guard-lib`, each +as its own subdirectory. Consume via +`crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync` +(Python) or a pubspec `git:`/`path:` dependency pointing at +`crdt_sync_dart-v0.1.0`/`crdt_sync_dart` (Dart). + +**Not yet done — none of the four apps have been migrated onto it yet**: +todo (off `sqlite_crdt`), diet-guard (Python + Dart merge code), wake-alarm +(`shutdown-wrapper.sh` rewrite), and screen-locker's own Workstream C +adoption. Each is its own separate, production-code-touching task per the +scoping doc's per-app cost estimates. + +Split out from the original status-view plan (`~/.claude/plans/screen-locker-status-streamed-feigenbaum.md`, Workstream A). -The plan is explicit: **"Scope and spec this as its own dedicated task -before writing any code — do not start it opportunistically inside -Workstream A/B/C work."** This document is orientation for that future -scoping pass, not a spec to start coding from. ## The decision this workstream implements diff --git a/pyproject.toml b/pyproject.toml index c8e257f..76eb279 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,11 @@ version = "1.0.0" description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration" requires-python = ">=3.10" # gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm +# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport, +# also used by diet_guard; here for phone-workout GitHub sync (Workstream C) dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock", + "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.1.0#subdirectory=crdt-sync", ] [project.scripts] @@ -53,9 +56,19 @@ unfixable = [] "PLC0415", "PLR0913", "PLR2004", "PTH", "RUF003", "S101", "SLF001", ] "screen_locker/_runnerup_verification.py" = ["S314"] +# URL is always built from the hardcoded https:// _WTTR_URL constant with +# only the (quoted) city interpolated — never user/attacker controlled. +# ruff's S310 can't verify that via static analysis of a variable URL, so +# it always flags urlopen() here regardless; a runtime scheme check was +# considered and rejected (it can't ever trigger, so it's just an untested +# branch and a crash risk in the lock's startup path for zero real benefit). +"screen_locker/_temperature.py" = ["S310"] "screen_locker/_shutdown_base.py" = ["SLF001"] "screen_locker/_status.py" = ["PLR0915", "SLF001", "T201"] -"screen_locker/status_view.py" = ["PLC0415", "SLF001", "T201"] +# StatusWindow.__init__ takes one arg per independently-mocked dependency +# (verifier_factory, temperature_fetcher, log_file, ...) — same +# dependency-injection rationale as _status_data.py's gather_status below. +"screen_locker/status_view.py" = ["PLC0415", "PLR0913", "SLF001", "T201"] # explain_lock_decision takes one arg per real file path + sick history, and # returns early per lock-decision-chain stage (see module docstring) — both # are the deliberate design, not something to collapse into a config object. @@ -67,6 +80,10 @@ unfixable = [] # validation-chain shape as _compliance_state.py's explain_lock_decision) — # deliberate design, not something to collapse into a config object. "screen_locker/_manual_workout.py" = ["C901", "PLR0911"] +# _verify_phone_workout returns early per verification-chain stage (clock +# check, sync, ADB, HTTP, then terminal statuses) — same shape as the two +# ignores above, not something to collapse into a config object. +"screen_locker/_phone_verification.py" = ["C901", "PLR0911"] "scripts/check_file_length.py" = ["PTH123"] [tool.ruff.lint.pydocstyle] @@ -162,6 +179,10 @@ addopts = [ "--cov-branch", "--cov-report=term-missing", "--cov-report=lcov", + # A fixture-only plugin module, not conftest.py (already at the + # repo's 400-line-per-file cap) -- see its own docstring. + "-p", + "screen_locker.tests.test_sync_fixtures", ] filterwarnings = [ "error", diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index 715c569..ee7489c 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -243,6 +243,7 @@ def explain_lock_decision( ) expired = pending and not window_open early_bird_open_and_pending = pending and window_open + window_end_label = "09:00" if extended_early_bird else "08:30" sick_today = is_sick_day_today(sick_history, today=today_str) logged = has_logged_today(log_file, today=today_str) @@ -342,10 +343,12 @@ def explain_lock_decision( result = _check( "early_bird_time_fresh", fired=window_open, - reason_true="Currently inside the early-bird window (05:00-08:30/09:00).", + reason_true=( + f"Currently inside the early-bird window (05:00-{window_end_label})." + ), reason_false="Not currently inside the early-bird window.", terminal_reason="Inside the early-bird window — lock skipped, pending marker " - "would be saved for the 08:30/09:00 re-check.", + f"would be saved for the {window_end_label} re-check.", ) if result is not None: return result diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index c4c2d1b..9676f90 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -108,3 +108,20 @@ RUNNERUP_EXPORT_DIRS: tuple[str, ...] = ( "/sdcard/Documents/RunnerUp", "/storage/emulated/0/Documents/RunnerUp", ) + +# --------------------------------------------------------------------------- +# Sync (phone workout data) — Workstream C, built on the shared crdt-sync +# library. GitHub is used purely as dumb file storage via the REST Contents +# API (not a git clone), same pattern as diet_guard's cross-device sync. The +# phone app pushes to devices//log.json; the PC only +# ever reads that one path, it never pushes here itself. +# --------------------------------------------------------------------------- +SYNC_REPO_OWNER: str = "kuhyx" +SYNC_REPO_NAME: str = "syncs" +SYNC_PHONE_DEVICE_ID: str = "phone" +# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The +# user creates this once via github.com and saves it here, mode 600. Never +# committed — this path is outside the repo entirely. Unlike diet_guard, +# this file being absent is a normal, expected state (sync is optional here). +SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token" +SYNC_TIMEOUT_SECONDS: float = 10.0 diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index 088b88f..3dbe2ca 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -1,4 +1,4 @@ -"""Phone workout verification: ADB pull first, HTTP scan as fallback.""" +"""Phone workout verification: GitHub sync first, ADB/HTTP as fallback.""" from __future__ import annotations @@ -24,6 +24,7 @@ from screen_locker._constants import ( WORKOUT_HTTP_PORT, ) from screen_locker._time_check import check_clock_skew +from screen_locker._workout_sync import pull_synced_workout _HTTPConnection = _http_client.HTTPConnection _HTTPException = _http_client.HTTPException @@ -243,16 +244,39 @@ class PhoneVerificationMixin: # ── Main verification entry point ───────────────────────────────────────── def _verify_phone_workout(self) -> tuple[str, str]: - """Verify today's workout: ADB pull if available, HTTP scan as fallback. + """Verify today's workout: GitHub sync first, ADB/HTTP as fallback. Returns (status, message). Status values: - verified / too_short / not_verified / no_phone / + verified / too_short / not_verified / no_phone / sync_failed / stale / no_exercises / clock_tampered. """ clock_ok, clock_msg = check_clock_skew() if not clock_ok: return "clock_tampered", clock_msg + # GitHub sync is the primary channel — it works without the phone + # being on the same network as the PC. Only a *verified* sync result + # short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the + # phone's last push predates today, or a later push failed offline) + # must not preempt a fresher ADB pull, so anything less than + # "verified" is kept as a fallback-of-last-resort candidate instead + # of being returned immediately. A sync error never blocks unlocking + # either; `sync_failed` only surfaces if ADB/HTTP also come up empty. + synced_data, sync_error = pull_synced_workout() + sync_result: tuple[str, str] | None = None + if synced_data is not None: + sync_result = self._validate_json_data(synced_data) + if sync_result[0] == "verified": + return sync_result + _logger.info( + "Synced data not fully verified (%s) — trying ADB/HTTP...", + sync_result[0], + ) + elif sync_error is not None: + _logger.info( + "GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error + ) + # Prefer ADB when a device is visible, but if the pull yields no usable # JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP # server may still hold today's workout even when the file pull fails. @@ -268,6 +292,10 @@ class PhoneVerificationMixin: data = self._fetch_http_workout() if data is not None: return self._validate_json_data(data) + if sync_result is not None: + return sync_result + if sync_error is not None: + return "sync_failed", sync_error if adb_connected: return ( "not_verified", diff --git a/screen_locker/_temperature.py b/screen_locker/_temperature.py index d3e00e8..6058d6e 100644 --- a/screen_locker/_temperature.py +++ b/screen_locker/_temperature.py @@ -6,6 +6,9 @@ user claims about the temperature. from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as _FutureTimeoutError +from dataclasses import dataclass import json import logging import urllib.error @@ -15,13 +18,29 @@ _logger = logging.getLogger(__name__) _WTTR_URL = "https://wttr.in/{city}?format=j1" _TIMEOUT_SECONDS = 5 +# urlopen's `timeout` bounds the socket connect/read, but not necessarily DNS +# resolution (`getaddrinfo`) on every platform/resolver — a stalled resolver +# can block far past `_TIMEOUT_SECONDS` with no visible failure. This is a +# second, hard wall-clock ceiling enforced independently in a worker thread. +HARD_TIMEOUT_SECONDS = 6.0 -def fetch_current_temp_celsius(city: str) -> float | None: +@dataclass(frozen=True) +class TemperatureCheck: + """Result of a bounded live temperature fetch.""" + + temp_celsius: float | None + timed_out: bool + + +def _fetch_current_temp_celsius_unbounded(city: str) -> float | None: """Return the current temperature in °C for *city*, or None on failure. Uses wttr.in's JSON API (no API key required). Returns None on network errors, timeouts, or unexpected response shapes so callers can fail-closed. + May block past `_TIMEOUT_SECONDS` if DNS resolution stalls — callers + should go through `fetch_current_temp_with_status` for a hard bound + instead of calling this directly. """ url = _WTTR_URL.format(city=urllib.request.quote(city, safe="")) try: @@ -37,12 +56,26 @@ def fetch_current_temp_celsius(city: str) -> float | None: return None -def is_too_hot(city: str, threshold: float) -> float | None: - """Return the current temperature if it exceeds *threshold*, else None. +def fetch_current_temp_with_status( + city: str, *, hard_timeout: float = HARD_TIMEOUT_SECONDS +) -> TemperatureCheck: + """Fetch °C for *city*, bounded to *hard_timeout* seconds wall-clock. - Fail-closed: API unavailable → returns None → no heat skip offered. + Runs the fetch in a worker thread and stops waiting on it after + *hard_timeout* regardless of what the network call is doing internally + (DNS, connect, read). The worker thread itself can't be killed and may + keep running in the background after we give up on it — acceptable for + a fire-and-forget diagnostic read. Distinguishes "timed out" from other + failures (network/parse errors) so callers can report which happened. """ - temp = fetch_current_temp_celsius(city) - if temp is None: - return None - return temp if temp >= threshold else None + executor = ThreadPoolExecutor(max_workers=1) + future = executor.submit(_fetch_current_temp_celsius_unbounded, city) + executor.shutdown(wait=False) + try: + temp = future.result(timeout=hard_timeout) + except _FutureTimeoutError: + _logger.warning( + "Temperature fetch exceeded hard timeout of %.1fs", hard_timeout + ) + return TemperatureCheck(temp_celsius=None, timed_out=True) + return TemperatureCheck(temp_celsius=temp, timed_out=False) diff --git a/screen_locker/_temperature_status_mixin.py b/screen_locker/_temperature_status_mixin.py new file mode 100644 index 0000000..bc2f0ad --- /dev/null +++ b/screen_locker/_temperature_status_mixin.py @@ -0,0 +1,100 @@ +"""Live Warsaw-temperature background check mixin for ``StatusWindow``. + +Fetches the same reading the real locker's heat-skip check would see +(``_temperature.fetch_current_temp_with_status``), in a background thread, +bounded to ``_temperature.HARD_TIMEOUT_SECONDS`` — display-only, never +writes to ``workout_log.json``. Host class must provide: + +- ``self.root`` — the Tk root, for ``root.after`` polling. +- ``self.temperature_fetcher`` — injected fetch callable (tests override it). +- ``self._temp_future`` / ``self._temp_result`` — state slots, initialized + by the host's ``__init__``. +- ``self._last_snapshot`` — the most recently rendered ``StatusSnapshot``. +- ``self.render(snapshot)`` — full redraw, called after a result resolves. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module +from typing import TYPE_CHECKING + +from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD +from screen_locker._temperature import HARD_TIMEOUT_SECONDS + +if TYPE_CHECKING: + import tkinter as tk + + from screen_locker._temperature import TemperatureCheck + + +class TemperatureStatusMixin: + """Provides the background Warsaw-temperature fetch/poll/render.""" + + def _section_temperature(self, parent: tk.Widget) -> None: + """Render the live Warsaw temperature. + + The same reading the real locker's heat-skip check would see, + fetched in the background. + """ + del parent + self._label("Warsaw Temperature", font_size=16, pady=8) + result = self._temp_result + if result is None: + self._text("Checking Warsaw temperature…", font_size=12, color="#888888") + return + if result.timed_out: + self._text( + f"Warsaw temperature check timed out after ~" + f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.", + font_size=12, + color="#ff8844", + ) + return + if result.temp_celsius is None: + self._text( + "Warsaw temperature check failed (network/API error).", + font_size=12, + color="#ff8844", + ) + return + hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD + self._text( + f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold " + f"{HEAT_SKIP_TEMP_THRESHOLD}°C)", + font_size=12, + color="#ff8844" if hot else "#cccccc", + ) + if hot: + self._text("Would trigger heat-skip today.", font_size=11, color="#ff4444") + + def _start_temperature_check(self) -> None: + """Submit a background Warsaw-temperature fetch and start polling it. + + Unlike ``_on_check_phone_clicked`` (an explicit, opt-in user action), + this runs on every window open and refresh, so the first poll is + always deferred through ``root.after`` rather than checked + synchronously — otherwise a worker thread that happens to finish + before this call returns would trigger a second, redundant render + from within the same call stack that hasn't finished the first one. + """ + executor = ThreadPoolExecutor(max_workers=1) + self._temp_future = executor.submit(self.temperature_fetcher, HEAT_SKIP_CITY) + executor.shutdown(wait=False) + self.root.after(500, self._poll_temperature_check) + + def _poll_temperature_check(self) -> None: + """Poll the background temperature-check future until it resolves.""" + if self._temp_future is not None and self._temp_future.done(): + self._on_temperature_check_result(self._temp_future.result()) + else: + self.root.after(500, self._poll_temperature_check) + + def _on_temperature_check_result(self, result: TemperatureCheck) -> None: + """Store the resolved temperature check and redraw to show it. + + Re-renders the last snapshot already on screen rather than + re-gathering from disk — the temperature reading is independent of + on-disk workout state, so there's nothing new to read. + """ + self._temp_result = result + self.render(self._last_snapshot) diff --git a/screen_locker/_workout_sync.py b/screen_locker/_workout_sync.py new file mode 100644 index 0000000..d5aafef --- /dev/null +++ b/screen_locker/_workout_sync.py @@ -0,0 +1,108 @@ +"""Pulls the phone's last-synced workout via crdt-sync's GitHub transport. + +GitHub is used purely as dumb file storage (see ``crdt_sync``'s own docs) -- +the phone app pushes its completed-workout log to a private repo; this +module only ever reads it, it never pushes here itself (the PC has no +workout data of its own to contribute). Sync is optional: an unconfigured +token is a normal, expected state, not an error -- unlike diet_guard, where +sync is core to the app. +""" + +from __future__ import annotations + +import json +import logging + +from crdt_sync import GitHubSyncClient, GitHubSyncError, Record + +from screen_locker._constants import ( + SYNC_PHONE_DEVICE_ID, + SYNC_REPO_NAME, + SYNC_REPO_OWNER, + SYNC_TIMEOUT_SECONDS, + SYNC_TOKEN_FILE, +) + +_logger = logging.getLogger(__name__) + +_LOG_PATH = f"screen-locker-sync/devices/{SYNC_PHONE_DEVICE_ID}/log.json" +_PAYLOAD_FIELD = "payload" + + +def read_sync_token() -> str | None: + """Return the saved sync PAT, or None if sync isn't configured. + + Unlike diet_guard's equivalent, an absent or empty token file is a + normal state here -- sync is an optional primary channel, not something + the app requires to function. + """ + if not SYNC_TOKEN_FILE.exists(): + return None + token = SYNC_TOKEN_FILE.read_text().strip() + return token or None + + +def _latest_payload(log_json: str) -> dict | None: + """Return the most recently pushed workout payload from a log.json blob. + + 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. + """ + 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) + + records = [Record.from_dict(data) for data in raw.values()] + candidates = [record for record in records if _PAYLOAD_FIELD in record.fields] + if not candidates: + return None + latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1]) + + 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 + + +def pull_synced_workout() -> tuple[dict | None, str | None]: + """Return ``(data, error)``: the phone's last-synced workout, if any. + + - 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, )``. + - Corrupt/malformed sync data: ``(None, "corrupt sync data: ...")``. + - Success: ``(payload_dict, None)``. + """ + token = read_sync_token() + if token is None: + return None, None + + client = GitHubSyncClient( + SYNC_REPO_OWNER, + SYNC_REPO_NAME, + token, + timeout_seconds=SYNC_TIMEOUT_SECONDS, + ) + try: + text = client.get_file_text(_LOG_PATH) + except GitHubSyncError as exc: + return None, str(exc) + + if text is None: + 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}" + + return payload, None diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index b6a5f41..2b0bc03 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -46,7 +46,7 @@ from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._shutdown import ShutdownMixin from screen_locker._shutdown_base import reset_to_base_if_new_day from screen_locker._sick_dialog import SickDialogMixin -from screen_locker._temperature import is_too_hot +from screen_locker._temperature import fetch_current_temp_with_status from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_widgets import UIWidgetsMixin @@ -224,17 +224,36 @@ class ScreenLocker( _logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus) def _check_heat_skip_exit(self) -> None: - """Exit early if today qualifies for the extreme-heat skip dialog.""" - hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD) - if hot_temp is None: + """Exit early if today qualifies for the extreme-heat skip dialog. + + Fail-closed by construction: a failed or timed-out temperature fetch + falls straight through to the lock, same as "not hot enough" — the + only difference is this logs *why* explicitly, so a fetch failure + is never silently indistinguishable from a normal day in the logs. + """ + check = fetch_current_temp_with_status(HEAT_SKIP_CITY) + if check.timed_out: + _logger.warning( + "Heat-skip temperature check timed out — defaulting to lock." + ) + return + if check.temp_celsius is None: + _logger.warning( + "Heat-skip temperature check failed (network/API error) — " + "defaulting to lock." + ) + return + if check.temp_celsius < HEAT_SKIP_TEMP_THRESHOLD: return _logger.info( "Temperature %.0f°C exceeds threshold — showing heat-skip dialog.", - hot_temp, + check.temp_celsius, ) - if self._show_heat_skip_dialog(hot_temp): - self._save_heat_skip_log(hot_temp) - _logger.info("User skipped workout due to heat (%.0f°C).", hot_temp) + if self._show_heat_skip_dialog(check.temp_celsius): + self._save_heat_skip_log(check.temp_celsius) + _logger.info( + "User skipped workout due to heat (%.0f°C).", check.temp_celsius + ) sys.exit(0) def _apply_weekly_shutdown_bonus(self) -> None: diff --git a/screen_locker/status_view.py b/screen_locker/status_view.py index 9404dc6..c9706a3 100644 --- a/screen_locker/status_view.py +++ b/screen_locker/status_view.py @@ -1,12 +1,20 @@ """Read-only Tkinter status window plus a lightweight i3blocks summary CLI. Opening or refreshing this window only ever reads files already on disk -(via ``_status_data.gather_status``). The "Check Phone" button submits -``PhoneVerificationMixin._verify_phone_workout`` to a background thread — -mirroring the existing submit/poll/``Future`` idiom used by -``_ui_flows_relaxed.py`` — and only ever *displays* the result; it never -calls ``save_workout_log()``. The one deliberate exception is "Log Manual -Workout": a user-initiated, explicit evidence-form submission (see +(via ``_status_data.gather_status``), with two deliberate exceptions that +only ever *display* a result and never write to ``workout_log.json``: + +* "Check Phone" submits ``PhoneVerificationMixin._verify_phone_workout`` to a + background thread — mirroring the existing submit/poll/``Future`` idiom + used by ``_ui_flows_relaxed.py``. +* The live Warsaw temperature (see ``_section_temperature``) is fetched via + ``_temperature.fetch_current_temp_with_status`` on open and on every + refresh, in its own background thread, bounded to + ``_temperature.HARD_TIMEOUT_SECONDS`` — the same call the real locker's + heat-skip check makes, so the window shows what the lock actually sees. + +The one exception that *does* write is "Log Manual Workout": a +user-initiated, explicit evidence-form submission (see ``_manual_workout_dialog.ManualWorkoutDialogMixin``) — never a silent log. """ @@ -30,6 +38,10 @@ from screen_locker._status_data import ( format_summary_line, gather_status, ) +from screen_locker._temperature import ( + fetch_current_temp_with_status, +) +from screen_locker._temperature_status_mixin import TemperatureStatusMixin from screen_locker._ui_widgets import UIWidgetsMixin from screen_locker._weekly_check import ( WEEKLY_WORKOUT_MINIMUM as _WEEKLY_WORKOUT_MINIMUM, @@ -40,6 +52,7 @@ if TYPE_CHECKING: from concurrent.futures import Future from screen_locker._compliance_state import LockExplanation + from screen_locker._temperature import TemperatureCheck from screen_locker.screen_lock import ScreenLocker _DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "workout_log.json" @@ -60,7 +73,7 @@ def _make_bare_verifier(log_file: Path) -> ScreenLocker: return verifier -class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin): +class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin, TemperatureStatusMixin): """Thin Tk view over a :class:`StatusSnapshot`. Formatting logic lives in ``_status_data``, not here. @@ -74,27 +87,37 @@ class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin): on_refresh: Callable[[], None], log_file: Path = _DEFAULT_LOG_FILE, verifier_factory: Callable[[Path], ScreenLocker] = _make_bare_verifier, + temperature_fetcher: Callable[ + [str], TemperatureCheck + ] = fetch_current_temp_with_status, ) -> None: """Build the window's container and render *snapshot* immediately.""" self.root = root self.on_refresh = on_refresh self.log_file = log_file self.verifier_factory = verifier_factory + self.temperature_fetcher = temperature_fetcher self.demo_mode = True # only affects UIWidgetsMixin._button's cursor self._phone_future: Future[tuple[str, str]] | None = None self._phone_check_result: tuple[str, str] | None = None self._manual_workout_saved_message: str | None = None + self._temp_future: Future[TemperatureCheck] | None = None + self._temp_result: TemperatureCheck | None = None + self._last_snapshot = snapshot self.container = tk.Frame(root, bg="#1a1a1a") self.container.pack(fill="both", expand=True) + self._start_temperature_check() self.render(snapshot) def render(self, snapshot: StatusSnapshot) -> None: """Redraw the whole window from *snapshot*.""" + self._last_snapshot = snapshot self.clear_container() self._label("Workout Status", font_size=28, pady=15) self._section_today(self.container, snapshot.today) self._section_week(self.container, snapshot.week) self._section_lock_explanation(self.container, snapshot.lock_explanation) + self._section_temperature(self.container) self._section_sick_budget(self.container, snapshot.sick_budget) self._section_manual_workout_budget( self.container, snapshot.manual_workout_budget @@ -187,13 +210,6 @@ class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin): font_size=11, color="#ffaa00", ) - if expl.fired and not expl.heat_skip_evaluated: - self._text( - "Live Warsaw temperature is not checked here — the real " - "locker may still offer a heat-skip.", - font_size=11, - color="#888888", - ) def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None: """Render rolling sick-day budget usage.""" @@ -249,8 +265,10 @@ class StatusWindow(UIWidgetsMixin, ManualWorkoutDialogMixin): self._text(shutdown.explanation, font_size=10, color="#666666") def _on_refresh_clicked(self) -> None: - """Clear any stale phone-check result and re-gather the snapshot.""" + """Clear any stale phone-check/temperature results, re-check both.""" self._phone_check_result = None + self._temp_result = None + self._start_temperature_check() self.on_refresh() def _on_check_phone_clicked(self) -> None: diff --git a/screen_locker/tests/_status_view_helpers.py b/screen_locker/tests/_status_view_helpers.py index d3d814c..8356ee2 100644 --- a/screen_locker/tests/_status_view_helpers.py +++ b/screen_locker/tests/_status_view_helpers.py @@ -23,6 +23,7 @@ from screen_locker._status_data import ( StatusSnapshot, WeeklySummary, ) +from screen_locker._temperature import TemperatureCheck from screen_locker.status_view import StatusWindow _WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") @@ -152,10 +153,21 @@ def _button_texts(mock_tk: MagicMock) -> set[str]: return {c.kwargs.get("text") for c in mock_tk.Button.call_args_list} +def _temp_check( + *, temp_celsius: float | None = 20.0, timed_out: bool = False +) -> TemperatureCheck: + return TemperatureCheck(temp_celsius=temp_celsius, timed_out=timed_out) + + def _make_window( mock_tk: MagicMock, snapshot: StatusSnapshot, **kwargs: object ) -> StatusWindow: + """Build a StatusWindow with an instant, non-hot, non-network temperature + fetcher by default, so unrelated tests never trigger real network I/O — + tests exercising temperature behavior pass their own ``temperature_fetcher``. + """ root = MagicMock() + kwargs.setdefault("temperature_fetcher", lambda _city: _temp_check()) return StatusWindow( root, snapshot, on_refresh=kwargs.pop("on_refresh", MagicMock()), **kwargs ) diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 7dd8d1e..9302d39 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -103,18 +103,18 @@ def mock_subprocess_run() -> Generator[MagicMock]: @pytest.fixture(autouse=True) def _block_real_network() -> Iterator[None]: - """Block real subnet probes for every test. + """Block real subnet probes and wttr.in calls for every test. - ``_scan_for_http_server`` / ``_try_wireless_reconnect`` open real TCP - sockets to scan the LAN; without this an unmocked ``_verify_phone_workout`` - would actually reach the phone over the network (flaky, environment-coupled). - Defaults ``create_connection`` to refuse — tests needing a successful probe - patch it locally, which takes precedence inside the test body. + Otherwise phone verification / StatusWindow's temperature fetch would + reach the real network. Tests needing a real probe patch it locally. """ - with patch( + targets = ( "screen_locker._phone_verification.socket.create_connection", - side_effect=OSError("network blocked in tests"), - ): + "screen_locker._temperature.urllib.request.urlopen", + ) + with ExitStack() as stack: + for target in targets: + stack.enter_context(patch(target, side_effect=OSError("blocked"))) yield diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py index 44b1b20..9634cd4 100644 --- a/screen_locker/tests/test_compliance_state_part2.py +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -148,6 +148,27 @@ class TestExplainLockDecision: ) assert result.fired is False assert result.stage == "early_bird_time_fresh" + assert "08:30" in result.reason + assert "09:00" not in result.reason + + def test_fresh_early_bird_time_names_09_00_when_extended( + self, tmp_path: Path + ) -> None: + """The re-check time isn't ambiguous — it's whichever one actually applies.""" + files = self._files(tmp_path) + now = datetime.now(tz=timezone.utc).astimezone().replace(hour=6, minute=0) + result = explain_lock_decision( + **files, + sick_history=SickHistory(), + extended_early_bird=True, + weekly_minimum_met=False, + relaxed_day=False, + now=now, + ) + assert result.fired is False + assert result.stage == "early_bird_time_fresh" + assert "09:00" in result.reason + assert "08:30" not in result.reason def test_relaxed_day_skips(self, tmp_path: Path) -> None: files = self._files(tmp_path) diff --git a/screen_locker/tests/test_phone_verification_part3.py b/screen_locker/tests/test_phone_verification_part3.py index bafffee..06ecc2e 100644 --- a/screen_locker/tests/test_phone_verification_part3.py +++ b/screen_locker/tests/test_phone_verification_part3.py @@ -252,3 +252,89 @@ class TestVerifyPhoneWorkoutFallthrough: ): status, _ = locker._verify_phone_workout() assert status == "no_phone" + + def test_synced_data_is_validated_directly_without_adb_or_http( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """A successful sync pull short-circuits ADB/HTTP entirely.""" + locker = create_locker(mock_tk, tmp_path) + synced_data = { + "date": _today(), + "exercises": ["a"], + "duration_seconds": 4000, + "succeeded": True, + } + adb = MagicMock() + http = MagicMock() + with ( + patch( + "screen_locker._phone_verification.check_clock_skew", + return_value=(True, ""), + ), + patch( + "screen_locker._phone_verification.pull_synced_workout", + return_value=(synced_data, None), + ), + patch.object(locker, "_is_phone_connected", adb), + patch.object(locker, "_fetch_http_workout", http), + ): + status, _ = locker._verify_phone_workout() + assert status == "verified" + adb.assert_not_called() + http.assert_not_called() + + def test_sync_error_falls_through_to_a_successful_fallback_silently( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """A sync error must never block a result the fallback can still find.""" + locker = create_locker(mock_tk, tmp_path) + http_data = { + "date": _today(), + "exercises": ["a"], + "duration_seconds": 4000, + "succeeded": True, + } + with ( + patch( + "screen_locker._phone_verification.check_clock_skew", + return_value=(True, ""), + ), + patch( + "screen_locker._phone_verification.pull_synced_workout", + return_value=(None, "network unreachable"), + ), + patch.object(locker, "_is_phone_connected", return_value=False), + patch.object(locker, "_fetch_http_workout", return_value=http_data), + ): + status, _ = locker._verify_phone_workout() + assert status == "verified" + + def test_sync_error_and_empty_fallback_returns_sync_failed( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Nothing found anywhere AND sync errored → sync_failed, not no_phone.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch( + "screen_locker._phone_verification.check_clock_skew", + return_value=(True, ""), + ), + patch( + "screen_locker._phone_verification.pull_synced_workout", + return_value=(None, "bad token"), + ), + patch.object(locker, "_is_phone_connected", return_value=False), + patch.object(locker, "_fetch_http_workout", return_value=None), + ): + status, message = locker._verify_phone_workout() + assert status == "sync_failed" + assert message == "bad token" diff --git a/screen_locker/tests/test_phone_verification_part4.py b/screen_locker/tests/test_phone_verification_part4.py index a095d34..2d498ec 100644 --- a/screen_locker/tests/test_phone_verification_part4.py +++ b/screen_locker/tests/test_phone_verification_part4.py @@ -137,6 +137,69 @@ class TestScanForHttpServer: assert locker._scan_for_http_server() is None +class TestVerifyPhoneWorkoutSyncStaleness: + """A stale/non-verified sync result must not preempt a fresher fallback.""" + + def test_stale_synced_data_falls_through_to_a_fresh_adb_pull( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """A stale cloud entry must not preempt a fresher ADB result.""" + locker = create_locker(mock_tk, tmp_path) + stale_synced = { + "date": "2000-01-01", + "exercises": ["old"], + "duration_seconds": 4000, + "succeeded": True, + } + fresh_adb = { + "date": _today(), + "exercises": ["new"], + "duration_seconds": 4000, + "succeeded": True, + } + with ( + patch( + "screen_locker._phone_verification.check_clock_skew", + return_value=(True, ""), + ), + patch( + "screen_locker._phone_verification.pull_synced_workout", + return_value=(stale_synced, None), + ), + patch.object(locker, "_is_phone_connected", return_value=True), + patch.object(locker, "_pull_workout_app_json", return_value=fresh_adb), + ): + status, _ = locker._verify_phone_workout() + assert status == "verified" + + def test_stale_synced_data_used_as_last_resort_when_fallback_is_empty( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """A non-verified sync result still beats no_phone/not_verified.""" + locker = create_locker(mock_tk, tmp_path) + stale_synced = { + "date": "2000-01-01", + "exercises": ["old"], + "duration_seconds": 4000, + "succeeded": True, + } + with ( + patch( + "screen_locker._phone_verification.check_clock_skew", + return_value=(True, ""), + ), + patch( + "screen_locker._phone_verification.pull_synced_workout", + return_value=(stale_synced, None), + ), + patch.object(locker, "_is_phone_connected", return_value=False), + patch.object(locker, "_fetch_http_workout", return_value=None), + ): + status, message = locker._verify_phone_workout() + assert status == "stale" + assert "2000-01-01" in message + + class TestFetchHttpWorkout: """Tests for _fetch_http_workout over the local HTTP server.""" diff --git a/screen_locker/tests/test_runnerup_verification_part1.py b/screen_locker/tests/test_runnerup_verification_part1.py index 1e62b8e..30af585 100644 --- a/screen_locker/tests/test_runnerup_verification_part1.py +++ b/screen_locker/tests/test_runnerup_verification_part1.py @@ -376,5 +376,3 @@ class TestPullAndParseTcx: # --------------------------------------------------------------------------- # _verify_runnerup_via_files # --------------------------------------------------------------------------- - - diff --git a/screen_locker/tests/test_runnerup_verification_part2.py b/screen_locker/tests/test_runnerup_verification_part2.py index a588dd2..ce05e29 100644 --- a/screen_locker/tests/test_runnerup_verification_part2.py +++ b/screen_locker/tests/test_runnerup_verification_part2.py @@ -80,7 +80,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str: # --------------------------------------------------------------------------- - class TestVerifyRunnerupViaFiles: """Tests for _verify_runnerup_via_files (lines 147-165).""" @@ -168,5 +167,3 @@ class TestVerifyRunnerupViaFiles: # --------------------------------------------------------------------------- # _scan_and_fill_week_runnerup # --------------------------------------------------------------------------- - - diff --git a/screen_locker/tests/test_runnerup_verification_part3.py b/screen_locker/tests/test_runnerup_verification_part3.py index c414e9d..422ec18 100644 --- a/screen_locker/tests/test_runnerup_verification_part3.py +++ b/screen_locker/tests/test_runnerup_verification_part3.py @@ -81,7 +81,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str: # --------------------------------------------------------------------------- - class TestScanAndFillWeekRunnerup: """Tests for _scan_and_fill_week_runnerup (lines 186-248).""" @@ -350,5 +349,3 @@ class TestScanAndFillWeekRunnerup: # --------------------------------------------------------------------------- # _find_runnerup_package # --------------------------------------------------------------------------- - - diff --git a/screen_locker/tests/test_runnerup_verification_part4.py b/screen_locker/tests/test_runnerup_verification_part4.py index 24cb369..998651c 100644 --- a/screen_locker/tests/test_runnerup_verification_part4.py +++ b/screen_locker/tests/test_runnerup_verification_part4.py @@ -83,7 +83,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str: # --------------------------------------------------------------------------- - class TestFindRunnerupPackage: """Tests for _find_runnerup_package (lines 256-260).""" @@ -319,5 +318,3 @@ class TestQueryTodaysRun: # --------------------------------------------------------------------------- # _verify_runnerup_via_db # --------------------------------------------------------------------------- - - diff --git a/screen_locker/tests/test_runnerup_verification_part5.py b/screen_locker/tests/test_runnerup_verification_part5.py index f8d330e..26c4c77 100644 --- a/screen_locker/tests/test_runnerup_verification_part5.py +++ b/screen_locker/tests/test_runnerup_verification_part5.py @@ -84,7 +84,6 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str: # --------------------------------------------------------------------------- - class TestVerifyRunnerupViaDb: """Tests for _verify_runnerup_via_db (lines 364-376).""" diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py index 4e1fa77..4c317fb 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -107,9 +107,8 @@ class TestSectionLockExplanation: texts = _texts(mock_tk) assert any(t == "Full lock." for t in texts) assert any("Pending auto-upgrade: will try phone" in t for t in texts) - assert any("Live Warsaw temperature" in t for t in texts) - def test_not_fired_no_auto_upgrade_no_heat_line(self, mock_tk: MagicMock) -> None: + def test_not_fired_no_auto_upgrade(self, mock_tk: MagicMock) -> None: snap = _snapshot( lock_explanation=_lock_explanation(fired=False, reason="Skipped.") ) @@ -117,16 +116,17 @@ class TestSectionLockExplanation: texts = _texts(mock_tk) assert any(t == "Skipped." for t in texts) assert not any("Pending auto-upgrade" in t for t in texts) - assert not any("Live Warsaw temperature" in t for t in texts) - def test_fired_but_heat_skip_already_evaluated_no_heat_line( + def test_temperature_section_renders_regardless_of_heat_skip_evaluated( self, mock_tk: MagicMock ) -> None: + """The live temperature section is unconditional — see + ``TestTemperatureSectionRendering`` for its actual content states.""" snap = _snapshot( lock_explanation=_lock_explanation(fired=True, heat_skip_evaluated=True) ) _make_window(mock_tk, snap) - assert not any("Live Warsaw temperature" in t for t in _texts(mock_tk)) + assert any("Warsaw Temperature" in t for t in _texts(mock_tk)) class TestSectionSickBudget: diff --git a/screen_locker/tests/test_status_view_part2.py b/screen_locker/tests/test_status_view_part2.py index a8e63bf..dadefe6 100644 --- a/screen_locker/tests/test_status_view_part2.py +++ b/screen_locker/tests/test_status_view_part2.py @@ -16,6 +16,7 @@ from screen_locker.tests._status_view_helpers import ( _lock_explanation, _make_window, _snapshot, + _temp_check, _texts, _week, ) @@ -220,3 +221,96 @@ class TestMain: captured["on_refresh"]() assert mock_gather.call_count == calls_before + 1 + + +class TestTemperatureSectionRendering: + def test_shows_checking_message_before_result(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_result = None + window.render(_snapshot()) + assert any("Checking Warsaw temperature" in t for t in _texts(mock_tk)) + + def test_shows_resolved_temperature(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=22.0) + window.render(_snapshot()) + assert any("Warsaw: 22" in t for t in _texts(mock_tk)) + + def test_flags_would_trigger_heat_skip_when_hot(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=35.0) + window.render(_snapshot()) + assert any("Would trigger heat-skip today." in t for t in _texts(mock_tk)) + + def test_does_not_flag_heat_skip_when_below_threshold( + self, mock_tk: MagicMock + ) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=22.0) + window.render(_snapshot()) + assert not any("Would trigger heat-skip today." in t for t in _texts(mock_tk)) + + def test_shows_timeout_message(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=None, timed_out=True) + window.render(_snapshot()) + assert any("timed out" in t for t in _texts(mock_tk)) + + def test_shows_failure_message_when_not_timed_out(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_result = _temp_check(temp_celsius=None, timed_out=False) + window.render(_snapshot()) + assert any("failed (network/API error)" in t for t in _texts(mock_tk)) + + +class TestTemperatureCheckFlow: + def test_init_starts_temperature_future(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + assert window._temp_future is not None + + def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + mock_future = MagicMock() + mock_future.done.return_value = True + mock_future.result.return_value = _temp_check(temp_celsius=25.0) + window._temp_future = mock_future + with patch.object(window, "_on_temperature_check_result") as mock_handle: + window._poll_temperature_check() + mock_handle.assert_called_once_with(mock_future.result.return_value) + + def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + mock_future = MagicMock() + mock_future.done.return_value = False + window._temp_future = mock_future + with patch.object(window, "_on_temperature_check_result") as mock_handle: + window._poll_temperature_check() + mock_handle.assert_not_called() + window.root.after.assert_called_with(500, window._poll_temperature_check) + + def test_poll_waits_when_future_is_none(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot()) + window._temp_future = None + with patch.object(window, "_on_temperature_check_result") as mock_handle: + window._poll_temperature_check() + mock_handle.assert_not_called() + + def test_result_rerenders_last_snapshot_without_gathering_from_disk( + self, mock_tk: MagicMock + ) -> None: + """The temperature reading is independent of on-disk workout state — + must not trigger a real ``gather_status()`` disk read.""" + window = _make_window(mock_tk, _snapshot()) + result = _temp_check(temp_celsius=19.0) + with patch("screen_locker.status_view.gather_status") as mock_gather: + window._on_temperature_check_result(result) + assert window._temp_result == result + mock_gather.assert_not_called() + + def test_refresh_clears_result_and_restarts_check(self, mock_tk: MagicMock) -> None: + window = _make_window(mock_tk, _snapshot(), on_refresh=MagicMock()) + window._temp_result = _temp_check(temp_celsius=30.0) + with patch.object(window, "_start_temperature_check") as mock_start: + window._on_refresh_clicked() + assert window._temp_result is None + mock_start.assert_called_once() diff --git a/screen_locker/tests/test_sync_fixtures.py b/screen_locker/tests/test_sync_fixtures.py new file mode 100644 index 0000000..644d9de --- /dev/null +++ b/screen_locker/tests/test_sync_fixtures.py @@ -0,0 +1,35 @@ +"""Isolation fixture for the phone-workout GitHub sync token. + +Split out of ``conftest.py`` (already at the repo's 400-line-per-file cap) +and re-exported there so pytest still picks this up as an autouse fixture +for every test in this directory. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +@pytest.fixture(autouse=True) +def isolate_sync_token(tmp_path: Path) -> Iterator[None]: + """Redirect SYNC_TOKEN_FILE to tmp_path so tests never see a real token. + + Without this, any test calling ``_verify_phone_workout`` would fall + through to ``pull_synced_workout()``, which -- if a real + ``~/.config/screen_locker/sync_token`` happens to exist on the host -- + would make a real GitHub API call. Defaulting to a nonexistent tmp_path + file makes ``read_sync_token()`` return None, the same benign "sync not + configured" state every existing test already assumes. + """ + with patch( + "screen_locker._workout_sync.SYNC_TOKEN_FILE", + tmp_path / "sync_token", + ): + yield diff --git a/screen_locker/tests/test_temperature.py b/screen_locker/tests/test_temperature.py index 7af8454..bc57a90 100644 --- a/screen_locker/tests/test_temperature.py +++ b/screen_locker/tests/test_temperature.py @@ -2,10 +2,11 @@ from __future__ import annotations +import time from unittest.mock import MagicMock, patch import urllib.error -from screen_locker._temperature import fetch_current_temp_celsius, is_too_hot +from screen_locker._temperature import TemperatureCheck, fetch_current_temp_with_status def _mock_urlopen_returning(body: bytes) -> MagicMock: @@ -16,35 +17,39 @@ def _mock_urlopen_returning(body: bytes) -> MagicMock: return mock_urlopen -class TestFetchCurrentTempCelsius: +class TestFetchCurrentTempWithStatus: def test_returns_parsed_temperature_on_success(self) -> None: body = b'{"current_condition": [{"temp_C": "27"}]}' with patch( "screen_locker._temperature.urllib.request.urlopen", _mock_urlopen_returning(body), ): - assert fetch_current_temp_celsius("Warsaw") == 27.0 + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=27.0, timed_out=False) def test_returns_none_on_url_error(self) -> None: with patch( "screen_locker._temperature.urllib.request.urlopen", side_effect=urllib.error.URLError("unreachable"), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) - def test_returns_none_on_timeout(self) -> None: + def test_returns_none_on_timeout_error(self) -> None: with patch( "screen_locker._temperature.urllib.request.urlopen", side_effect=TimeoutError("timed out"), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_os_error(self) -> None: with patch( "screen_locker._temperature.urllib.request.urlopen", side_effect=OSError("network unreachable"), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_missing_key(self) -> None: body = b'{"unexpected": "shape"}' @@ -52,7 +57,8 @@ class TestFetchCurrentTempCelsius: "screen_locker._temperature.urllib.request.urlopen", _mock_urlopen_returning(body), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_empty_current_condition(self) -> None: body = b'{"current_condition": []}' @@ -60,7 +66,8 @@ class TestFetchCurrentTempCelsius: "screen_locker._temperature.urllib.request.urlopen", _mock_urlopen_returning(body), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_non_numeric_temp(self) -> None: body = b'{"current_condition": [{"temp_C": "not-a-number"}]}' @@ -68,31 +75,25 @@ class TestFetchCurrentTempCelsius: "screen_locker._temperature.urllib.request.urlopen", _mock_urlopen_returning(body), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) def test_returns_none_on_invalid_json(self) -> None: with patch( "screen_locker._temperature.urllib.request.urlopen", _mock_urlopen_returning(b"not json{{{"), ): - assert fetch_current_temp_celsius("Warsaw") is None + result = fetch_current_temp_with_status("Warsaw") + assert result == TemperatureCheck(temp_celsius=None, timed_out=False) + def test_reports_timed_out_when_fetch_exceeds_hard_timeout(self) -> None: + def _slow_fetch(_city: str) -> float | None: + time.sleep(0.3) + return 27.0 -class TestIsTooHot: - def test_returns_none_when_fetch_fails(self) -> None: with patch( - "screen_locker._temperature.fetch_current_temp_celsius", return_value=None + "screen_locker._temperature._fetch_current_temp_celsius_unbounded", + side_effect=_slow_fetch, ): - assert is_too_hot("Warsaw", 33) is None - - def test_returns_none_when_below_threshold(self) -> None: - with patch( - "screen_locker._temperature.fetch_current_temp_celsius", return_value=20.0 - ): - assert is_too_hot("Warsaw", 33) is None - - def test_returns_temp_when_at_or_above_threshold(self) -> None: - with patch( - "screen_locker._temperature.fetch_current_temp_celsius", return_value=33.0 - ): - assert is_too_hot("Warsaw", 33) == 33.0 + result = fetch_current_temp_with_status("Warsaw", hard_timeout=0.05) + assert result == TemperatureCheck(temp_celsius=None, timed_out=True) diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py index 578b1b8..95992a1 100644 --- a/screen_locker/tests/test_weekly_logic_part2.py +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch +from screen_locker._temperature import TemperatureCheck from screen_locker.screen_lock import ScreenLocker from screen_locker.tests.conftest import create_locker, create_locker_relaxed_day @@ -216,7 +217,8 @@ class TestHeatSkipBranch: with ( patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch( - "screen_locker.screen_lock.is_too_hot", return_value=None + "screen_locker.screen_lock.fetch_current_temp_with_status", + return_value=TemperatureCheck(temp_celsius=20.0, timed_out=False), ) as mock_hot, patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog, ): @@ -234,7 +236,10 @@ class TestHeatSkipBranch: ) -> None: with ( patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), - patch("screen_locker.screen_lock.is_too_hot", return_value=35.0), + patch( + "screen_locker.screen_lock.fetch_current_temp_with_status", + return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False), + ), patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=True), patch.object(ScreenLocker, "_save_heat_skip_log") as mock_save, ): @@ -251,7 +256,10 @@ class TestHeatSkipBranch: ) -> None: with ( patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), - patch("screen_locker.screen_lock.is_too_hot", return_value=35.0), + patch( + "screen_locker.screen_lock.fetch_current_temp_with_status", + return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False), + ), patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=False), patch.object(ScreenLocker, "_save_heat_skip_log") as mock_save, ): @@ -259,3 +267,43 @@ class TestHeatSkipBranch: mock_save.assert_not_called() mock_sys_exit.assert_not_called() + + def test_fetch_timed_out_defaults_to_lock_no_dialog( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """A timed-out temperature check must fail closed, not skip the lock.""" + with ( + patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), + patch( + "screen_locker.screen_lock.fetch_current_temp_with_status", + return_value=TemperatureCheck(temp_celsius=None, timed_out=True), + ), + patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog, + ): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_dialog.assert_not_called() + mock_sys_exit.assert_not_called() + + def test_fetch_failed_defaults_to_lock_no_dialog( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """A failed (non-timeout) temperature check must also fail closed.""" + with ( + patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), + patch( + "screen_locker.screen_lock.fetch_current_temp_with_status", + return_value=TemperatureCheck(temp_celsius=None, timed_out=False), + ), + patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog, + ): + create_locker(mock_tk, tmp_path, has_logged=False) + + mock_dialog.assert_not_called() + mock_sys_exit.assert_not_called() diff --git a/screen_locker/tests/test_workout_sync.py b/screen_locker/tests/test_workout_sync.py new file mode 100644 index 0000000..1608d14 --- /dev/null +++ b/screen_locker/tests/test_workout_sync.py @@ -0,0 +1,120 @@ +"""Tests for the phone-workout GitHub sync pull (crdt-sync transport).""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from crdt_sync import GitHubSyncError, Hlc, Record +import pytest + +from screen_locker import _workout_sync + +# 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() + + +class TestReadSyncToken: + def test_returns_none_when_file_is_missing(self) -> None: + assert _workout_sync.read_sync_token() is None + + def test_returns_none_when_file_is_empty(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text(" \n") + assert _workout_sync.read_sync_token() is None + + def test_returns_the_stripped_token(self) -> None: + _workout_sync.SYNC_TOKEN_FILE.write_text(" abc123 \n") + 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 + + 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_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_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_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_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_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 diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index e78381e..ac65bd4 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -3,15 +3,28 @@ library; import 'dart:async'; +import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/services/github_device_auth.dart'; import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; + +/// How to style a [_SyncStatusBadge]. +enum _SyncStatusKind { success, pending, error } /// Screen for editing per-exercise thresholds and manual weight overrides. class SettingsScreen extends StatefulWidget { /// Creates a [SettingsScreen]. - const SettingsScreen({super.key}); + const SettingsScreen({super.key, this.httpClient}); + + /// Injectable HTTP client; tests pass a `MockClient` so the device-flow + /// requests never hit the real network. + final http.Client? httpClient; @override State createState() => _SettingsScreenState(); @@ -27,6 +40,21 @@ class _SettingsScreenState extends State { // Debounce weight saves to avoid resetting streaks on every tap. final Map _weightTimers = {}; + final _tokenController = TextEditingController(); + + // Persistent (not a transient SnackBar) so the result of Connect GitHub / + // Save is still visible if the user looks back at the screen later -- + // mirrors diet-guard/todo's `_status` field in their settings screens. + String? _syncStatus; + _SyncStatusKind _syncStatusKind = _SyncStatusKind.pending; + + void _setSyncStatus(String message, _SyncStatusKind kind) { + setState(() { + _syncStatus = message; + _syncStatusKind = kind; + }); + } + @override void initState() { super.initState(); @@ -38,11 +66,13 @@ class _SettingsScreenState extends State { for (final t in _weightTimers.values) { t.cancel(); } + _tokenController.dispose(); super.dispose(); } Future _load() async { final states = await StorageService.instance.getAllExerciseStates(); + final syncSettings = await SyncSettings.load(); if (mounted) { setState(() { for (final s in states) { @@ -50,8 +80,90 @@ class _SettingsScreenState extends State { _failThresholds[s.name] = s.failThreshold; _weights[s.name] = s.weight; } + _tokenController.text = syncSettings.token; _loading = false; }); + // Reflect an already-configured token immediately on open, not only + // after Connect GitHub / Save is tapped this session. + if (syncSettings.isConfigured) { + _setSyncStatus('Connected.', _SyncStatusKind.success); + } + } + } + + Future _saveToken() async { + final saved = await SyncSettings( + token: _tokenController.text.trim(), + ).save(); + if (!mounted) return; + _setSyncStatus( + saved ? 'Sync token saved.' : 'Could not save token on this device.', + saved ? _SyncStatusKind.success : _SyncStatusKind.error, + ); + } + + /// Runs the OAuth device flow and, on success, saves the resulting token + /// and verifies it actually works against the sync repo -- a saved token + /// that can't reach `$syncRepoOwner/$syncRepoName` (wrong scope, revoked, + /// etc.) must be surfaced immediately, not discovered on the next workout. + Future _connectGitHub() async { + final auth = GitHubDeviceAuth( + clientId: SyncSettings.defaultClientId, + httpClient: widget.httpClient, + ); + try { + final device = await auth.requestDeviceCode(); + if (!mounted) return; + final token = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _DeviceCodeDialog(device: device, auth: auth), + ); + if (token != null && token.isNotEmpty) { + setState(() => _tokenController.text = token); + _setSyncStatus('Connected — verifying…', _SyncStatusKind.pending); + final saved = await SyncSettings(token: token).save(); + if (!saved) { + if (!mounted) return; + _setSyncStatus( + 'Connected, but could not save the token on this device.', + _SyncStatusKind.error, + ); + return; + } + await _verifyConnection(token); + } + } on Exception catch (e) { + if (!mounted) return; + _setSyncStatus('Could not start device flow: $e', _SyncStatusKind.error); + } finally { + auth.close(); + } + } + + /// Confirms [token] can actually read `$syncRepoOwner/$syncRepoName`. + Future _verifyConnection(String token) async { + final client = GitHubClient( + owner: syncRepoOwner, + repo: syncRepoName, + token: token, + httpClient: widget.httpClient, + ); + try { + await client.getFileText('devices/phone/log.json'); + if (!mounted) return; + _setSyncStatus( + 'Connected and verified via GitHub.', + _SyncStatusKind.success, + ); + } on GitHubSyncError catch (e) { + if (!mounted) return; + _setSyncStatus( + 'Connected, but could not verify: $e', + _SyncStatusKind.error, + ); + } finally { + client.close(); } } @@ -185,6 +297,42 @@ class _SettingsScreenState extends State { _onThresholdChanged(name, _successThresholds[name]!, v), ); }), + const SizedBox(height: 20), + const _SectionHeader('GITHUB SYNC'), + const SizedBox(height: 4), + const Text( + 'Authorize in your browser -- no token to paste. Syncs to ' + '$syncRepoOwner/$syncRepoName. Workouts push automatically ' + 'on completion.', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + const SizedBox(height: 12), + if (_syncStatus != null) ...[ + _SyncStatusBadge(text: _syncStatus!, kind: _syncStatusKind), + const SizedBox(height: 12), + ], + ElevatedButton.icon( + onPressed: _connectGitHub, + icon: const Icon(Icons.login), + label: const Text('Connect GitHub'), + ), + const SizedBox(height: 8), + ExpansionTile( + title: const Text( + 'Advanced: paste a token instead', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + collapsedIconColor: Colors.white54, + iconColor: Colors.white54, + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(top: 8, bottom: 8), + children: [ + _SyncTokenField( + controller: _tokenController, + onSave: _saveToken, + ), + ], + ), ], ), ); @@ -285,6 +433,101 @@ class _StepperButton extends StatelessWidget { } } +/// A visible, colored status pill for the GitHub sync connection state -- +/// placed directly under the section description (not buried below the +/// collapsed Advanced field) so "am I connected?" has an immediate answer. +class _SyncStatusBadge extends StatelessWidget { + const _SyncStatusBadge({required this.text, required this.kind}); + + final String text; + final _SyncStatusKind kind; + + @override + Widget build(BuildContext context) { + final color = switch (kind) { + _SyncStatusKind.success => Colors.greenAccent, + _SyncStatusKind.error => Colors.redAccent, + _SyncStatusKind.pending => Colors.white70, + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + if (kind == _SyncStatusKind.pending) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + kind == _SyncStatusKind.success + ? Icons.check_circle + : Icons.error, + color: color, + size: 16, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _SyncTokenField extends StatelessWidget { + const _SyncTokenField({required this.controller, required this.onSave}); + + final TextEditingController controller; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: controller, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'GitHub PAT', + hintStyle: TextStyle(color: Colors.grey.shade600), + filled: true, + fillColor: Colors.grey.shade800, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton(onPressed: onSave, child: const Text('Save')), + ], + ); + } +} + class _ExerciseThresholdCard extends StatelessWidget { const _ExerciseThresholdCard({ required this.name, @@ -392,3 +635,107 @@ class _ThresholdRow extends StatelessWidget { ); } } + +/// Dialog shown during the device flow: displays the user code, opens the +/// verification page, and polls until authorized -- popping the token (or +/// null if cancelled / failed). +class _DeviceCodeDialog extends StatefulWidget { + const _DeviceCodeDialog({required this.device, required this.auth}); + + final DeviceCodeResponse device; + final GitHubDeviceAuth auth; + + @override + State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); +} + +class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { + String? _error; + + @override + void initState() { + super.initState(); + unawaited(_poll()); + } + + Future _poll() async { + try { + final token = await widget.auth.pollForToken(widget.device); + if (mounted) Navigator.of(context).pop(token); + } on Exception catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _openPage() async { + await Clipboard.setData(ClipboardData(text: widget.device.userCode)); + await launchUrl( + Uri.parse(widget.device.verificationUri), + mode: LaunchMode.externalApplication, + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.grey.shade900, + title: const Text( + 'Authorize on GitHub', + style: TextStyle(color: Colors.white), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter this code on GitHub:', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 8), + SelectableText( + widget.device.userCode, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + if (_error == null) + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Expanded( + child: Text( + 'Waiting for authorization…', + style: TextStyle(color: Colors.white70), + ), + ), + ], + ) + else + Text(_error!, style: const TextStyle(color: Colors.redAccent)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white70), + ), + ), + FilledButton.icon( + onPressed: _openPage, + icon: const Icon(Icons.open_in_new), + label: const Text('Open GitHub & copy code'), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 746d3fa..4fdb808 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -12,6 +12,7 @@ import 'package:workout_app/models/set_result.dart'; import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_service.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/widgets/break_banner.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/workout_summary_dialog.dart'; @@ -462,6 +463,7 @@ class _WorkoutScreenState extends State { await storage.clearActiveSession(); final syncResult = await _sync.writeWorkoutResult(session); + unawaited(WorkoutSyncService().push(session)); if (!mounted) return; unawaited( diff --git a/stronglift_replacement/workout_app/lib/services/backup_service.dart b/stronglift_replacement/workout_app/lib/services/backup_service.dart index 3c52508..a71da5f 100644 --- a/stronglift_replacement/workout_app/lib/services/backup_service.dart +++ b/stronglift_replacement/workout_app/lib/services/backup_service.dart @@ -1,22 +1,58 @@ -/// Backup service: mirrors the DB as JSON to external storage so data survives -/// uninstall/reinstall on Android 11+ where internal storage is wiped. +/// Backup service: mirrors the DB (and, deliberately, the GitHub sync token) +/// as files on external storage so they survive uninstall/reinstall on +/// Android 11+, where internal storage -- including the Keystore-backed +/// secure storage the token normally lives in -- is wiped. +/// +/// Mirroring the token here is a deliberate, explicit trade-off: it moves +/// the token from Keystore-only secrecy to plaintext-on-external-storage, +/// which is readable by any app holding storage permission or by plugging +/// the phone into a PC. That's a real credential-exposure increase over +/// diet-guard/todo (which keep their tokens Keystore-only and do not survive +/// reinstall) -- accepted here because losing sync on every reinstall during +/// active development was worse in practice than the exposure risk. library; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:permission_handler/permission_handler.dart'; -/// Path where the backup JSON lives on external storage. -const String kBackupPath = '/sdcard/WorkoutTracker/backup.json'; +/// Default directory backups live in on external storage. +const String kBackupDir = '/sdcard/WorkoutTracker'; -/// Handles exporting and importing the workout database as a JSON file on -/// external storage, which persists across app uninstalls. +/// Path where the backup JSON lives on external storage. +const String kBackupPath = '$kBackupDir/backup.json'; + +/// Path where the GitHub sync token is mirrored on external storage. +const String kSyncTokenBackupPath = '$kBackupDir/sync_token'; + +/// Handles exporting and importing the workout database (and the GitHub +/// sync token) as files on external storage, which persist across app +/// uninstalls. class BackupService { BackupService._(); /// The singleton instance. static final BackupService instance = BackupService._(); + // Overridable for unit tests (set by resetForTesting) so round-trips can + // be verified against a real temp directory instead of `/sdcard`, which + // doesn't exist on the test host. + static String _baseDir = kBackupDir; + + /// The directory backup files are currently written to/read from. + /// Test-only. + @visibleForTesting + static String get baseDirForTesting => _baseDir; + + /// Points the backup files at this directory instead of [kBackupDir]. + /// Test-only. + @visibleForTesting + static set baseDirForTesting(String dir) => _baseDir = dir; + + String get _backupPath => '$_baseDir/backup.json'; + String get _syncTokenPath => '$_baseDir/sync_token'; + // ── Permission ───────────────────────────────────────────────────────────── /// Returns true if the app has MANAGE_EXTERNAL_STORAGE. @@ -35,19 +71,19 @@ class BackupService { // ── Export ───────────────────────────────────────────────────────────────── - /// Writes [data] to [kBackupPath] as pretty-printed JSON. + /// 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 export(Map data) async { try { - final dir = Directory('/sdcard/WorkoutTracker'); + final dir = Directory(_baseDir); if (!dir.existsSync()) { dir.createSync(recursive: true); } - await File(kBackupPath).writeAsString( - const JsonEncoder.withIndent(' ').convert(data), - ); + await File( + _backupPath, + ).writeAsString(const JsonEncoder.withIndent(' ').convert(data)); } on Exception { // Backup is best-effort; never throw. } @@ -59,7 +95,7 @@ class BackupService { /// is unreadable. Future?> readBackup() async { try { - final f = File(kBackupPath); + final f = File(_backupPath); if (!f.existsSync()) return null; final raw = await f.readAsString(); return jsonDecode(raw) as Map; @@ -67,4 +103,38 @@ class BackupService { return null; } } + + // ── Sync token backup ──────────────────────────────────────────────────── + + /// Mirrors [token] to external storage (deleting the file when empty). + /// Best-effort like [export]; see the library doc for the security + /// trade-off this represents. + Future exportSyncToken(String token) async { + try { + final f = File(_syncTokenPath); + if (token.isEmpty) { + if (f.existsSync()) await f.delete(); + return; + } + final dir = Directory(_baseDir); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + await f.writeAsString(token); + } on Exception { + // Backup is best-effort; never throw. + } + } + + /// Returns the mirrored sync token, or null if none exists / unreadable. + Future readSyncToken() async { + try { + final f = File(_syncTokenPath); + if (!f.existsSync()) return null; + final token = await f.readAsString(); + return token.isEmpty ? null : token; + } on Exception { + return null; + } + } } diff --git a/stronglift_replacement/workout_app/lib/services/github_device_auth.dart b/stronglift_replacement/workout_app/lib/services/github_device_auth.dart new file mode 100644 index 0000000..6111d3a --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/github_device_auth.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// First-stage response of the GitHub OAuth Device Flow: the code the user +/// types on github.com and the URL to type it into. +class DeviceCodeResponse { + /// Creates a [DeviceCodeResponse]. + const DeviceCodeResponse({ + required this.deviceCode, + required this.userCode, + required this.verificationUri, + required this.interval, + required this.expiresIn, + }); + + /// Parses GitHub's `POST /login/device/code` response body. + factory DeviceCodeResponse.fromJson(Map json) { + return DeviceCodeResponse( + deviceCode: json['device_code'] as String, + userCode: json['user_code'] as String, + verificationUri: json['verification_uri'] as String, + interval: (json['interval'] as int?) ?? 5, + expiresIn: (json['expires_in'] as int?) ?? 900, + ); + } + + /// Opaque code the client polls with (not shown to the user). + final String deviceCode; + + /// Short code the user enters on the verification page. + final String userCode; + + /// Page the user opens to enter [userCode] (github.com/login/device). + final String verificationUri; + + /// Minimum seconds to wait between polls. + final int interval; + + /// Seconds until [deviceCode] expires. + final int expiresIn; +} + +/// Raised when the device-flow authorization fails or is declined. +class DeviceAuthException implements Exception { + /// Creates a [DeviceAuthException] for the given GitHub error [code]. + DeviceAuthException(this.code, this.message); + + /// GitHub error code, e.g. `access_denied`, `expired_token`. + final String code; + + /// Human-readable description of [code]. + final String message; + + @override + String toString() => 'DeviceAuthException($code): $message'; +} + +/// Implements the GitHub OAuth **Device Flow** so the user can authorize the +/// app by visiting a URL and entering a short code — no token pasting. +/// +/// Device flow needs only a public `client_id` (no client secret), which +/// makes it safe for a distributed app. The resulting access token is then +/// used exactly like a PAT by [WorkoutSyncService]. +/// +/// References: +/// - POST https://github.com/login/device/code +/// - POST https://github.com/login/oauth/access_token +class GitHubDeviceAuth { + /// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId]. + GitHubDeviceAuth({ + required this.clientId, + this.scope = 'repo', + http.Client? httpClient, + Future Function(Duration)? delay, + }) : _http = httpClient ?? http.Client(), + // Indirection so tests can skip real waiting between polls. + _delay = delay ?? Future.delayed; + + /// The GitHub OAuth App's public client id. + final String clientId; + + /// OAuth scope requested. `repo` is required for private-repo contents. + final String scope; + + final http.Client _http; + final Future Function(Duration) _delay; + + static const _deviceCodeUrl = 'https://github.com/login/device/code'; + static const _tokenUrl = 'https://github.com/login/oauth/access_token'; + static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code'; + + /// Step 1: ask GitHub for a device + user code. + Future requestDeviceCode() async { + final res = await _http.post( + Uri.parse(_deviceCodeUrl), + headers: const {'Accept': 'application/json'}, + body: {'client_id': clientId, 'scope': scope}, + ); + if (res.statusCode != 200) { + throw DeviceAuthException('http_${res.statusCode}', res.body); + } + return DeviceCodeResponse.fromJson( + jsonDecode(res.body) as Map, + ); + } + + /// Step 2: poll until the user authorizes, returning the access token. + /// + /// Honors GitHub's pacing protocol: `authorization_pending` keeps polling, + /// `slow_down` increases the interval, and terminal errors throw a + /// [DeviceAuthException]. + Future pollForToken(DeviceCodeResponse device) async { + var intervalSeconds = device.interval; + final deadline = DateTime.now().add(Duration(seconds: device.expiresIn)); + + while (DateTime.now().isBefore(deadline)) { + await _delay(Duration(seconds: intervalSeconds)); + + final Map json; + try { + final res = await _http.post( + Uri.parse(_tokenUrl), + headers: const {'Accept': 'application/json'}, + body: { + 'client_id': clientId, + 'device_code': device.deviceCode, + 'grant_type': _grantType, + }, + ); + json = jsonDecode(res.body) as Map; + } on Exception { + // A dropped connection or malformed response is transient noise on a + // long-lived poll loop (e.g. a flaky network) -- retry on the next + // interval rather than aborting the whole ~15-minute authorization + // window over one blip. The deadline above still bounds this. + continue; + } + + final token = json['access_token'] as String?; + if (token != null) return token; + + switch (json['error'] as String?) { + case 'authorization_pending': + continue; // User has not finished authorizing yet. + case 'slow_down': + // GitHub asks us to back off; obey its new interval. + intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5; + case final String error: + throw DeviceAuthException( + error, + (json['error_description'] as String?) ?? error, + ); + case null: + throw DeviceAuthException('unknown', 'Unexpected response: $json'); + } + } + throw DeviceAuthException('expired_token', 'Device code expired.'); + } + + /// Closes the underlying HTTP client. + void close() => _http.close(); +} diff --git a/stronglift_replacement/workout_app/lib/services/sync_settings.dart b/stronglift_replacement/workout_app/lib/services/sync_settings.dart new file mode 100644 index 0000000..4a0bbac --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/sync_settings.dart @@ -0,0 +1,89 @@ +/// Locally-stored GitHub sync token, trimmed from diet-guard's +/// `sync_settings.dart`: this app only ever talks to one hardcoded repo, so +/// there's no owner/repo to persist -- just the pasted PAT (kept as a +/// fallback; see [defaultClientId] for the primary "Connect GitHub" path). +/// +/// Unlike diet-guard/todo, the token is also mirrored to external storage +/// via [BackupService] so it survives an app uninstall -- see that class's +/// library doc for the security trade-off this represents. +library; + +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:workout_app/services/backup_service.dart'; + +/// The repo owner/org, matching the PC's `SYNC_REPO_OWNER` constant. +const syncRepoOwner = 'kuhyx'; + +/// The repo name, matching the PC's `SYNC_REPO_NAME` constant. +const syncRepoName = 'syncs'; + +/// The GitHub token is kept in the OS keystore (Android Keystore) via +/// [FlutterSecureStorage] -- it never touches `SharedPreferences`, so there's +/// no plaintext-migration path to carry (unlike diet-guard/todo, which +/// predate this app and had an existing plaintext copy to migrate away from). +class SyncSettings { + /// Creates a [SyncSettings] from its token. + const SyncSettings({required this.token}); + + /// A GitHub PAT with contents read/write on `$syncRepoOwner/$syncRepoName`. + final String token; + + /// True when a token has been pasted and sync can be attempted. + bool get isConfigured => token.isNotEmpty; + + /// This app's own GitHub OAuth App (device-flow enabled) client id, baked + /// in so "Connect GitHub" works with zero setup. Registered 2026-07-06 at + /// github.com/settings/developers, device flow enabled -- distinct from + /// the sibling diet-guard/todo apps' OAuth Apps, which belong to different + /// products and can't be shared across them. + static const defaultClientId = 'Ov23liJNyu82dkIUiXSZ'; + + static const _secureToken = 'sync.token'; + + /// Default options keep us off the deprecated `encryptedSharedPreferences` + /// path on Android. + static const _secure = FlutterSecureStorage(); + + /// Loads the token from the keystore, falling back to the external-storage + /// backup (and re-seeding the keystore from it) if the keystore is empty -- + /// which is exactly what happens right after a reinstall, since the + /// keystore entry does not survive uninstall but the backup file does. + static Future load() async { + String? token; + try { + token = await _secure.read(key: _secureToken); + } on PlatformException { + token = null; + } + if (token == null || token.isEmpty) { + final backedUp = await BackupService.instance.readSyncToken(); + if (backedUp != null && backedUp.isNotEmpty) { + token = backedUp; + try { + await _secure.write(key: _secureToken, value: backedUp); + } on PlatformException { + // Keystore unavailable; still return the recovered token below. + } + } + } + return SyncSettings(token: token ?? ''); + } + + /// Persists [token] to the keystore (deleting the entry when empty) and + /// mirrors it to external storage so it survives an app uninstall. + /// Returns false if the platform secret service is unavailable. + Future save() async { + await BackupService.instance.exportSyncToken(token); + try { + if (token.isEmpty) { + await _secure.delete(key: _secureToken); + } else { + await _secure.write(key: _secureToken, value: token); + } + return true; + } on PlatformException { + return false; + } + } +} diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart new file mode 100644 index 0000000..cc49dd4 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -0,0 +1,88 @@ +/// Pushes completed workout sessions to the shared GitHub sync repo. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/sync_settings.dart'; + +const _deviceId = 'phone'; +const _pathPrefix = 'screen-locker-sync/devices'; +const _logFilename = 'log.json'; + +String _encode(Log log) => + jsonEncode(log.map((id, record) => MapEntry(id, record.toJson()))); + +Log _decode(String text) => (jsonDecode(text) as Map).map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), +); + +/// Mirrors the PC-side `_workout_sync.py`: GitHub is used purely as dumb +/// file storage via the Contents API, one [Record] per completed session, +/// merged onto whatever this phone has already pushed. A missing token or a +/// failed push is swallowed here -- sync being unconfigured or unreachable +/// must never crash or delay the workout-completion flow that calls [push]. +class WorkoutSyncService { + /// Creates a [WorkoutSyncService]. [owner]/[repo]/[httpClient] default to + /// the real `syncs` repo (`screen-locker-sync/` subdirectory) and a fresh + /// [http.Client]; tests + /// override them to point at an in-memory [http.testing.MockClient] + /// instead of the real network. + WorkoutSyncService({ + this.owner = syncRepoOwner, + this.repo = syncRepoName, + http.Client? httpClient, + // Dart forbids private named params, so this can't be an initializing + // formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient). + // ignore: prefer_initializing_formals + }) : _httpClient = httpClient; + + /// The repo owner/org to push to. + final String owner; + + /// The repo name to push to. + final String repo; + final http.Client? _httpClient; + + /// Pushes [session] to `devices/phone/log.json`, merging with whatever + /// this device has already pushed. No-ops silently if sync isn't + /// configured; logs (but does not rethrow) any [GitHubSyncError]. + Future push(WorkoutSession session) async { + final settings = await SyncSettings.load(); + if (!settings.isConfigured) return; + + final client = GitHubClient( + owner: owner, + repo: repo, + token: settings.token, + httpClient: _httpClient, + ); + try { + const path = '$_pathPrefix/$_deviceId/$_logFilename'; + final existingText = await client.getFileText(path); + final existingLog = existingText == null + ? {} + : _decode(existingText); + final record = Record( + id: session.startTime.toIso8601String(), + fields: {'payload': (session.toJson(), Hlc.newTick(_deviceId))}, + ); + final localLog = mergeLogs(existingLog, {record.id: record}); + await syncLog( + client: client, + deviceId: _deviceId, + pathPrefix: _pathPrefix, + localLog: localLog, + encode: _encode, + decode: _decode, + ); + } on GitHubSyncError catch (error) { + debugPrint('WorkoutSyncService.push failed: $error'); + } finally { + client.close(); + } + } +} diff --git a/stronglift_replacement/workout_app/pubspec.lock b/stronglift_replacement/workout_app/pubspec.lock index 3bf2f5c..d7fc0a5 100644 --- a/stronglift_replacement/workout_app/pubspec.lock +++ b/stronglift_replacement/workout_app/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "4.2.1" audioplayers_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: audioplayers_platform_interface sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" @@ -113,6 +113,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crdt_sync: + dependency: "direct main" + description: + path: crdt_sync_dart + ref: "crdt_sync_dart-v0.1.0" + resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6" + url: "https://github.com/kuhyx/utils" + source: git + version: "0.1.0" crypto: dependency: transitive description: @@ -190,6 +199,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -217,7 +274,7 @@ packages: source: hosted version: "2.0.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -653,6 +710,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: @@ -742,5 +863,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.12.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/stronglift_replacement/workout_app/pubspec.yaml b/stronglift_replacement/workout_app/pubspec.yaml index d9bb7e0..576610a 100644 --- a/stronglift_replacement/workout_app/pubspec.yaml +++ b/stronglift_replacement/workout_app/pubspec.yaml @@ -5,20 +5,29 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ^3.12.0 + sdk: ^3.12.2 dependencies: audioplayers: ^6.4.0 + crdt_sync: + git: + url: https://github.com/kuhyx/utils + ref: crdt_sync_dart-v0.1.0 + path: crdt_sync_dart flutter: sdk: flutter + flutter_secure_storage: ^10.3.1 + http: ^1.6.0 path: ^1.9.1 path_provider: ^2.1.5 permission_handler: ^12.0.0 shared_preferences: ^2.5.3 sqflite: ^2.4.2 + url_launcher: ^6.3.2 vibration: ^3.1.0 dev_dependencies: + audioplayers_platform_interface: ^7.1.1 flutter_lints: ^6.0.0 flutter_test: sdk: flutter diff --git a/stronglift_replacement/workout_app/test/fake_audio_platform.dart b/stronglift_replacement/workout_app/test/fake_audio_platform.dart new file mode 100644 index 0000000..7c63a2b --- /dev/null +++ b/stronglift_replacement/workout_app/test/fake_audio_platform.dart @@ -0,0 +1,133 @@ +/// Fakes the `audioplayers` platform channels so widget tests exercising +/// [AudioPlayer] (e.g. WorkoutScreen's break-end sound) don't hit a real +/// platform and throw `MissingPluginException`. +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; + +class _FakeAudioplayersPlatform extends AudioplayersPlatformInterface { + final _controllers = >{}; + + StreamController _controllerFor(String playerId) => + _controllers.putIfAbsent( + playerId, + () => StreamController.broadcast(), + ); + + @override + Future create(String playerId) async {} + + @override + Future dispose(String playerId) async { + await _controllers.remove(playerId)?.close(); + } + + @override + Future pause(String playerId) async {} + + @override + Future stop(String playerId) async {} + + @override + Future resume(String playerId) async {} + + @override + Future release(String playerId) async {} + + @override + Future seek(String playerId, Duration position) async {} + + @override + Future setBalance(String playerId, double balance) async {} + + @override + Future setVolume(String playerId, double volume) async {} + + @override + Future setReleaseMode( + String playerId, + ReleaseMode releaseMode, + ) async {} + + @override + Future setPlaybackRate(String playerId, double playbackRate) async {} + + @override + Future setSourceUrl( + String playerId, + String url, { + bool? isLocal, + String? mimeType, + }) async { + _controllerFor( + playerId, + ).add(const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true)); + } + + @override + Future setSourceBytes( + String playerId, + Uint8List bytes, { + String? mimeType, + }) async { + _controllerFor( + playerId, + ).add(const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true)); + } + + @override + Future setAudioContext( + String playerId, + AudioContext audioContext, + ) async {} + + @override + Future setPlayerMode(String playerId, PlayerMode playerMode) async {} + + @override + Future getDuration(String playerId) async => 0; + + @override + Future getCurrentPosition(String playerId) async => 0; + + @override + Future emitLog(String playerId, String message) async {} + + @override + Future emitError(String playerId, String code, String message) async {} + + @override + Stream getEventStream(String playerId) => + _controllerFor(playerId).stream; +} + +class _FakeGlobalAudioplayersPlatform + implements GlobalAudioplayersPlatformInterface { + final _controller = StreamController.broadcast(); + + @override + Future init() async {} + + @override + Future setGlobalAudioContext(AudioContext ctx) async {} + + @override + Future emitGlobalLog(String message) async {} + + @override + Future emitGlobalError(String code, String message) async {} + + @override + Stream getGlobalEventStream() => _controller.stream; +} + +/// Installs fake `audioplayers` platform implementations. Call once from a +/// test's `setUp` before pumping any widget that creates an [AudioPlayer]. +void installFakeAudioPlatform() { + AudioplayersPlatformInterface.instance = _FakeAudioplayersPlatform(); + GlobalAudioplayersPlatformInterface.instance = + _FakeGlobalAudioplayersPlatform(); +} diff --git a/stronglift_replacement/workout_app/test/fake_secure_storage.dart b/stronglift_replacement/workout_app/test/fake_secure_storage.dart new file mode 100644 index 0000000..7725065 --- /dev/null +++ b/stronglift_replacement/workout_app/test/fake_secure_storage.dart @@ -0,0 +1,52 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// In-memory fake for the `flutter_secure_storage` platform channel so tests +/// never touch the real OS keystore. Install it from a test (or `setUp`) and +/// it auto-removes on tear down. +/// +/// Pass [throwing] to simulate a host with no secret service: every call +/// raises a [PlatformException], which exercises the "save failed" path in +/// [SyncSettings]. +void installFakeSecureStorage({ + Map? initial, + bool throwing = false, +}) { + const channel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); + final store = {...?initial}; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + messenger.setMockMethodCallHandler(channel, (call) async { + if (throwing) { + throw PlatformException(code: 'unavailable'); + } + final args = (call.arguments as Map?) ?? const {}; + final key = args['key'] as String?; + switch (call.method) { + case 'read': + return store[key]; + case 'write': + store[key!] = args['value'] as String; + return null; + case 'delete': + store.remove(key); + return null; + case 'containsKey': + return store.containsKey(key); + case 'readAll': + return Map.from(store); + case 'deleteAll': + store.clear(); + return null; + default: + return null; + } + }); + + addTearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart index 8cc9c4e..3218fdd 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart @@ -1,10 +1,41 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/screens/settings_screen.dart'; import 'package:workout_app/services/storage_service.dart'; +import '../fake_secure_storage.dart'; + +/// Stub launcher that records the URL instead of opening it, so the device +/// dialog's "Open GitHub & copy code" can be exercised without a real +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + void main() { setUpAll(() { sqfliteFfiInit(); @@ -14,6 +45,7 @@ void main() { setUp(() async { StorageService.resetForTesting(); await StorageService.init(); + installFakeSecureStorage(); }); Future _pump(WidgetTester tester, Widget w) async { @@ -24,7 +56,35 @@ void main() { await tester.pump(); } - Widget _wrap() => const MaterialApp(home: SettingsScreen()); + Widget _wrap({http.Client? httpClient}) => + MaterialApp(home: SettingsScreen(httpClient: httpClient)); + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future _pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + /// The GITHUB SYNC section sits below several exercises' worth of weight + /// rows and threshold cards; `ListView` only builds elements within the + /// viewport (+ cache extent) even for a plain `children:` list, so it must + /// be scrolled into view before `find` can see it. + Future _scrollToGitHubSync(WidgetTester tester) async { + await tester.scrollUntilVisible( + find.text('Connect GitHub'), + 500, + scrollable: find.byType(Scrollable), + ); + } testWidgets('shows Settings app bar', (tester) async { await _pump(tester, _wrap()); @@ -94,6 +154,7 @@ void main() { expect(find.text('Reset to defaults?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Reset to defaults?'), findsNothing); }); @@ -115,4 +176,196 @@ void main() { await StorageService.instance.getExerciseState(workoutA.first.name); expect(state!.weight, workoutA.first.weight); }); + + testWidgets('shows GITHUB SYNC section with a Connect GitHub button', ( + tester, + ) async { + await _pump(tester, _wrap()); + await _scrollToGitHubSync(tester); + expect(find.text('GITHUB SYNC'), findsOneWidget); + expect(find.text('Connect GitHub'), findsOneWidget); + }); + + testWidgets( + 'shows Connected immediately when a token is already saved', + (tester) async { + installFakeSecureStorage(initial: {'sync.token': 'already-saved'}); + await _pump(tester, _wrap()); + await _scrollToGitHubSync(tester); + expect(find.text('Connected.'), findsOneWidget); + }, + ); + + testWidgets('Advanced section reveals the PAT fallback field', ( + tester, + ) async { + await _pump(tester, _wrap()); + await _scrollToGitHubSync(tester); + expect(find.text('Save'), findsNothing); + + await tester.tap(find.text('Advanced: paste a token instead')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, ''), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('saving a pasted token shows a confirmation', (tester) async { + await _pump(tester, _wrap()); + await _scrollToGitHubSync(tester); + await tester.tap(find.text('Advanced: paste a token instead')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.widgetWithText(ElevatedButton, 'Save')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'a-pasted-token'); + await tester.tap(find.text('Save')); + await tester.pump(); + expect(find.text('Sync token saved.'), findsOneWidget); + }); + + testWidgets('device flow failure to start shows a message', (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + await _scrollToGitHubSync(tester); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => + find + .textContaining('Could not start device flow') + .evaluate() + .isNotEmpty, + ); + + expect( + find.textContaining('Could not start device flow'), + findsOneWidget, + ); + }); + }); + + testWidgets('device flow happy path saves and verifies the token', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + // The post-connect verification GET on devices/phone/log.json. + return http.Response( + jsonEncode({'content': base64Encode(utf8.encode('{}'))}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + await _scrollToGitHubSync(tester); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + await _pumpUntil( + tester, + () => + find + .textContaining('Connected and verified via GitHub') + .evaluate() + .isNotEmpty, + ); + + expect( + find.textContaining('Connected and verified via GitHub'), + findsOneWidget, + ); + }); + }); + + testWidgets('device dialog: failed poll shows the error and Open launches', ( + tester, + ) async { + final launcher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = launcher; + + // The dialog's Open button copies the code to the clipboard first; + // there's no clipboard plugin in the test host, so stub the channel. + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + return http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'no'}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget(_wrap(httpClient: mock)); + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + await _scrollToGitHubSync(tester); + + await tester.tap(find.text('Connect GitHub')); + await _pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + await _pumpUntil( + tester, + () => find.textContaining('access_denied').evaluate().isNotEmpty, + ); + expect(find.textContaining('access_denied'), findsOneWidget); + + await tester.tap(find.text('Open GitHub & copy code')); + await tester.pump(); + expect(launcher.launched, 'https://github.com/login/device'); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + }); + }); } diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart index d802725..b2fe613 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart @@ -5,6 +5,10 @@ import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/screens/workout_screen.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; +import 'package:workout_app/widgets/rep_circle.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_secure_storage.dart'; const _exercises = [ Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0), @@ -33,6 +37,14 @@ void main() { setUp(() async { StorageService.resetForTesting(); await StorageService.init(); + // WorkoutScreen fires an unawaited WorkoutSyncService().push() on + // completion, which reads the sync token via FlutterSecureStorage -- + // without this, the unmocked platform channel throws + // MissingPluginException as an unhandled Future error. + installFakeSecureStorage(); + // The break-end sound creates a real AudioPlayer; fake its platform + // channels too, for the same reason as above. + installFakeAudioPlatform(); }); Future _pump(WidgetTester tester, Widget w) async { @@ -75,6 +87,7 @@ void main() { expect(find.text('Reset workout?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Reset workout?'), findsNothing); }); diff --git a/stronglift_replacement/workout_app/test/services/backup_service_test.dart b/stronglift_replacement/workout_app/test/services/backup_service_test.dart new file mode 100644 index 0000000..ca5162e --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/backup_service_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/backup_service.dart'; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('backup_service_test_'); + BackupService.baseDirForTesting = tempDir.path; + }); + + tearDown(() { + BackupService.baseDirForTesting = kBackupDir; + tempDir.deleteSync(recursive: true); + }); + + test('baseDirForTesting getter reflects the last setter value', () { + expect(BackupService.baseDirForTesting, tempDir.path); + }); + + test('export then readBackup round-trips the data', () async { + await BackupService.instance.export({'a': 1, 'b': 'two'}); + final result = await BackupService.instance.readBackup(); + expect(result, {'a': 1, 'b': 'two'}); + }); + + test('readBackup returns null when nothing was exported', () async { + expect(await BackupService.instance.readBackup(), isNull); + }); + + test('readBackup returns null for unreadable JSON', () async { + File('${tempDir.path}/backup.json').writeAsStringSync('not json'); + expect(await BackupService.instance.readBackup(), isNull); + }); + + test('exportSyncToken then readSyncToken round-trips the token', () async { + await BackupService.instance.exportSyncToken('gho_abc123'); + expect(await BackupService.instance.readSyncToken(), 'gho_abc123'); + }); + + test('readSyncToken returns null when nothing was exported', () async { + expect(await BackupService.instance.readSyncToken(), isNull); + }); + + test('exportSyncToken with an empty token deletes an existing file', () async { + await BackupService.instance.exportSyncToken('gho_abc123'); + await BackupService.instance.exportSyncToken(''); + expect(await BackupService.instance.readSyncToken(), isNull); + }); + + test('exportSyncToken with an empty token is a no-op when no file exists', () async { + await BackupService.instance.exportSyncToken(''); + expect(await BackupService.instance.readSyncToken(), isNull); + }); + + test('export creates the target directory if missing', () async { + final nested = Directory('${tempDir.path}/nested'); + BackupService.baseDirForTesting = nested.path; + await BackupService.instance.export({'x': 1}); + expect(await BackupService.instance.readBackup(), {'x': 1}); + }); + + test('exportSyncToken creates the target directory if missing', () async { + final nested = Directory('${tempDir.path}/nested2'); + BackupService.baseDirForTesting = nested.path; + await BackupService.instance.exportSyncToken('tok'); + expect(await BackupService.instance.readSyncToken(), 'tok'); + }); + + test('export swallows errors when the path cannot be created', () async { + // A regular file used as the "directory" makes Directory.createSync + // throw -- export must swallow it, not crash the caller. + final blocker = File('${tempDir.path}/blocker'); + blocker.writeAsStringSync('x'); + BackupService.baseDirForTesting = blocker.path; + await BackupService.instance.export({'x': 1}); + // No exception reaching here is the assertion. + }); + + test( + 'exportSyncToken swallows errors when the path cannot be created', + () async { + final blocker = File('${tempDir.path}/blocker2'); + blocker.writeAsStringSync('x'); + BackupService.baseDirForTesting = blocker.path; + await BackupService.instance.exportSyncToken('tok'); + }, + ); + + test('readBackup swallows filesystem errors', () async { + // Points at a directory instead of a file so File.readAsString throws. + final asDir = Directory('${tempDir.path}/backup.json'); + asDir.createSync(); + expect(await BackupService.instance.readBackup(), isNull); + }); + + test('readSyncToken swallows filesystem errors', () async { + final asDir = Directory('${tempDir.path}/sync_token'); + asDir.createSync(); + expect(await BackupService.instance.readSyncToken(), isNull); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/github_device_auth_test.dart b/stronglift_replacement/workout_app/test/services/github_device_auth_test.dart new file mode 100644 index 0000000..dd063c6 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/github_device_auth_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:workout_app/services/github_device_auth.dart'; + +/// Builds an auth instance whose polls resolve instantly (no real waiting). +GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth( + clientId: 'test-client-id', + httpClient: client, + delay: (_) => Future.value(), +); + +const _device = DeviceCodeResponse( + deviceCode: 'dev-123', + userCode: 'WXYZ-1234', + verificationUri: 'https://github.com/login/device', + interval: 1, + expiresIn: 900, +); + +void main() { + test('requestDeviceCode parses the device + user code', () async { + final client = MockClient((req) async { + expect(req.url.toString(), contains('login/device/code')); + expect(req.bodyFields['client_id'], 'test-client-id'); + expect(req.bodyFields['scope'], 'repo'); + return http.Response( + jsonEncode({ + 'device_code': 'dev-123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 5, + 'expires_in': 900, + }), + 200, + ); + }); + + final res = await authWith(client).requestDeviceCode(); + expect(res.deviceCode, 'dev-123'); + expect(res.userCode, 'WXYZ-1234'); + expect(res.verificationUri, 'https://github.com/login/device'); + }); + + test('requestDeviceCode throws on a non-200 response', () async { + final client = MockClient((_) async => http.Response('nope', 422)); + expect( + () => authWith(client).requestDeviceCode(), + throwsA(isA()), + ); + }); + + test('pollForToken returns the token after authorization_pending', () async { + var calls = 0; + final client = MockClient((req) async { + calls++; + // Pending on the first two polls, then success. + if (calls < 3) { + return http.Response( + jsonEncode({'error': 'authorization_pending'}), + 200, + ); + } + return http.Response( + jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), + 200, + ); + }); + + final token = await authWith(client).pollForToken(_device); + expect(token, 'gho_abc'); + expect(calls, 3); + }); + + test('pollForToken retries past a dropped connection', () async { + var calls = 0; + final client = MockClient((req) async { + calls++; + if (calls == 1) { + throw http.ClientException( + 'Connection closed before full header was received', + ); + } + return http.Response(jsonEncode({'access_token': 'gho_retry'}), 200); + }); + + final token = await authWith(client).pollForToken(_device); + expect(token, 'gho_retry'); + expect(calls, 2); + }); + + test('pollForToken retries past a malformed response body', () async { + var calls = 0; + final client = MockClient((req) async { + calls++; + if (calls == 1) return http.Response('not json', 200); + return http.Response(jsonEncode({'access_token': 'gho_ok2'}), 200); + }); + + final token = await authWith(client).pollForToken(_device); + expect(token, 'gho_ok2'); + expect(calls, 2); + }); + + test('pollForToken obeys slow_down and still succeeds', () async { + var calls = 0; + final client = MockClient((req) async { + calls++; + if (calls == 1) { + return http.Response( + jsonEncode({'error': 'slow_down', 'interval': 1}), + 200, + ); + } + return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200); + }); + + final token = await authWith(client).pollForToken(_device); + expect(token, 'gho_xyz'); + }); + + test('pollForToken throws on access_denied', () async { + final client = MockClient( + (req) async => http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'no'}), + 200, + ), + ); + + expect( + () => authWith(client).pollForToken(_device), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'access_denied', + ), + ), + ); + }); + + test('pollForToken honors slow_down then succeeds', () async { + var calls = 0; + final client = MockClient((req) async { + calls++; + if (calls == 1) { + return http.Response( + jsonEncode({'error': 'slow_down', 'interval': 0}), + 200, + ); + } + return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200); + }); + + expect(await authWith(client).pollForToken(_device), 'gho_ok'); + expect(calls, 2); + }); + + test('pollForToken throws on an unexpected response shape', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'foo': 'bar'}), 200), + ); + expect( + () => authWith(client).pollForToken(_device), + throwsA(isA()), + ); + }); + + test('pollForToken throws when the device code has expired', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'access_token': 'x'}), 200), + ); + const expired = DeviceCodeResponse( + deviceCode: 'd', + userCode: 'u', + verificationUri: 'v', + interval: 1, + expiresIn: 0, // deadline is now → loop body never runs + ); + expect( + () => authWith(client).pollForToken(expired), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'expired_token', + ), + ), + ); + }); + + test('defaults to a real http client and delay when none are injected', () { + // Omitting httpClient/delay exercises the `?? http.Client()` and + // `?? Future.delayed` constructor fallbacks; no request is made. + final auth = GitHubDeviceAuth(clientId: 'c'); + addTearDown(auth.close); + }); + + test('DeviceAuthException.toString includes code and message', () { + final e = DeviceAuthException('access_denied', 'no'); + expect(e.toString(), 'DeviceAuthException(access_denied): no'); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/sync_settings_test.dart b/stronglift_replacement/workout_app/test/services/sync_settings_test.dart new file mode 100644 index 0000000..ac826cd --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/sync_settings_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/sync_settings.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + // installFakeSecureStorage touches the test binary messenger, which needs + // the binding up first (widget tests get this for free via testWidgets). + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('sync_settings_test_'); + BackupService.baseDirForTesting = tempDir.path; + }); + + tearDown(() { + BackupService.baseDirForTesting = kBackupDir; + tempDir.deleteSync(recursive: true); + }); + + test('load returns an empty token on a fresh install', () async { + installFakeSecureStorage(); + final s = await SyncSettings.load(); + expect(s.token, ''); + expect(s.isConfigured, isFalse); + }); + + test('load reads a previously saved token from the keystore', () async { + installFakeSecureStorage(initial: {'sync.token': 'fromKeystore'}); + final s = await SyncSettings.load(); + expect(s.token, 'fromKeystore'); + expect(s.isConfigured, isTrue); + }); + + test('save persists the token to the keystore', () async { + installFakeSecureStorage(); + final ok = await const SyncSettings(token: 'tok').save(); + expect(ok, isTrue); + + final s = await SyncSettings.load(); + expect(s.token, 'tok'); + }); + + test('save with an empty token deletes any existing entry', () async { + installFakeSecureStorage(initial: {'sync.token': 'stale'}); + final ok = await const SyncSettings(token: '').save(); + expect(ok, isTrue); + + final s = await SyncSettings.load(); + expect(s.token, ''); + }); + + test('load returns an empty token when the keystore is unavailable', () async { + installFakeSecureStorage(throwing: true); + final s = await SyncSettings.load(); + expect(s.token, ''); + }); + + test('save returns false when the keystore is unavailable', () async { + installFakeSecureStorage(throwing: true); + final ok = await const SyncSettings(token: 'tok').save(); + expect(ok, isFalse); + }); + + test('defaultClientId is baked in for the Connect GitHub button', () { + expect(SyncSettings.defaultClientId, isNotEmpty); + }); + + test('save mirrors the token to the external-storage backup', () async { + installFakeSecureStorage(); + await const SyncSettings(token: 'gho_backed_up').save(); + expect( + await BackupService.instance.readSyncToken(), + 'gho_backed_up', + ); + }); + + test( + 'save with an empty token clears the external-storage backup too', + () async { + installFakeSecureStorage(); + await const SyncSettings(token: 'gho_x').save(); + await const SyncSettings(token: '').save(); + expect(await BackupService.instance.readSyncToken(), isNull); + }, + ); + + test( + 'load recovers the token from the backup when the keystore is empty ' + '(e.g. right after a reinstall) and re-seeds the keystore', + () async { + installFakeSecureStorage(); + await BackupService.instance.exportSyncToken('gho_recovered'); + + final s = await SyncSettings.load(); + expect(s.token, 'gho_recovered'); + + // Re-seeded so the next load doesn't need the backup fallback. + final again = await SyncSettings.load(); + expect(again.token, 'gho_recovered'); + }, + ); + + test( + 'load recovers from the backup even when the keystore write fails', + () async { + installFakeSecureStorage(throwing: true); + await BackupService.instance.exportSyncToken('gho_recovered2'); + + final s = await SyncSettings.load(); + expect(s.token, 'gho_recovered2'); + }, + ); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart new file mode 100644 index 0000000..fcc1756 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/sync_settings.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +import '../fake_secure_storage.dart'; + +http.Response _response(int statusCode, [Object? jsonBody]) => + http.Response(jsonEncode(jsonBody ?? {}), statusCode); + +http.Response _fileContaining(String text) => + _response(200, {'content': base64.encode(utf8.encode(text))}); + +class _PutCall { + _PutCall(this.path, this.body); + final String path; + final Map body; +} + +/// Builds a mock HTTP client backed by an in-memory router, matching +/// `crdt_sync_dart`'s own `sync_test.dart` helper: GET +/// `.../contents/` returns [contentResponses]`[key]` (404 if absent), +/// the bare repo-existence GET always succeeds, and every PUT is recorded. +({http.Client httpClient, List<_PutCall> putCalls}) _mockGitHub({ + Map contentResponses = const {}, +}) { + final putCalls = <_PutCall>[]; + final client = http_testing.MockClient((request) async { + final path = request.url.path; + if (!path.contains('/contents/')) { + return _response(200); + } + final key = path.split('/contents/').last; + if (request.method == 'PUT') { + putCalls.add( + _PutCall(key, jsonDecode(request.body) as Map), + ); + return _response(200); + } + return contentResponses[key] ?? _response(404); + }); + return (httpClient: client, putCalls: putCalls); +} + +WorkoutSession _session({DateTime? startTime}) => WorkoutSession( + workoutType: 'A', + startTime: startTime ?? DateTime(2026, 7, 5, 9), + endTime: DateTime(2026, 7, 5, 10), + exercises: [ + ExerciseResult( + exercise: const Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20), + sets: List.generate( + 3, + (_) => const SetResult(targetReps: 5, doneReps: 5, weight: 20), + ), + ), + ], +); + +void main() { + // installFakeSecureStorage touches the test binary messenger, which needs + // the binding up first (widget tests get this for free via testWidgets). + TestWidgetsFlutterBinding.ensureInitialized(); + + test('does nothing when no token is configured', () async { + installFakeSecureStorage(); + final (:httpClient, :putCalls) = _mockGitHub(); + + await WorkoutSyncService(httpClient: httpClient).push(_session()); + + expect(putCalls, isEmpty); + }); + + test('pushes a new record when nothing has been synced yet', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final (:httpClient, :putCalls) = _mockGitHub( + contentResponses: {'screen-locker-sync/devices': _response(200, [])}, + ); + + final session = _session(); + await WorkoutSyncService(httpClient: httpClient).push(session); + + expect(putCalls, hasLength(1)); + expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json'); + final pushedLog = + jsonDecode( + utf8.decode( + base64.decode(putCalls.single.body['content'] as String), + ), + ) + as Map; + expect(pushedLog.keys, [session.startTime.toIso8601String()]); + }); + + test('merges the new session onto an already-pushed log', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final earlier = Record( + id: 'earlier', + fields: { + 'payload': ({'workout_type': 'B'}, Hlc.newTick('phone')), + }, + ); + final existingLog = jsonEncode({'earlier': earlier.toJson()}); + final (:httpClient, :putCalls) = _mockGitHub( + contentResponses: { + 'screen-locker-sync/devices': _response(200, []), + 'screen-locker-sync/devices/phone/log.json': _fileContaining( + existingLog, + ), + }, + ); + + final session = _session(); + await WorkoutSyncService(httpClient: httpClient).push(session); + + final pushedLog = + jsonDecode( + utf8.decode( + base64.decode(putCalls.single.body['content'] as String), + ), + ) + as Map; + expect( + pushedLog.keys, + containsAll(['earlier', session.startTime.toIso8601String()]), + ); + }); + + test('a GitHubSyncError is swallowed, not rethrown', () async { + installFakeSecureStorage(initial: {'sync.token': 'tok'}); + final httpClient = http_testing.MockClient( + (request) async => http.Response('', 500), + ); + + await expectLater( + WorkoutSyncService(httpClient: httpClient).push(_session()), + completes, + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart b/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart index 59d41ed..86342ec 100644 --- a/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart @@ -9,10 +9,10 @@ void main() { testWidgets('displays label and formatted time', (tester) async { await tester.pumpWidget( _wrap( - const BreakBanner( + BreakBanner( breakRemaining: 90, breakLabel: 'Rest', - onSkip: null, + onSkip: () {}, ), ), ); @@ -23,10 +23,10 @@ void main() { testWidgets('formats time below one minute correctly', (tester) async { await tester.pumpWidget( _wrap( - const BreakBanner( + BreakBanner( breakRemaining: 5, breakLabel: 'Warmup rest', - onSkip: null, + onSkip: () {}, ), ), ); @@ -51,10 +51,10 @@ void main() { testWidgets('zero seconds formats as 00:00', (tester) async { await tester.pumpWidget( _wrap( - const BreakBanner( + BreakBanner( breakRemaining: 0, breakLabel: 'Rest', - onSkip: null, + onSkip: () {}, ), ), ); diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart index b8b0430..7709120 100644 --- a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; +import 'package:workout_app/widgets/rep_circle.dart'; Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));