mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Add phone-workout GitHub sync via shared crdt_sync (Workstream C)
Sync the workout log across devices using the shared crdt-sync / crdt_sync_dart CRDT transport (same scheme as diet_guard). - Python: new _workout_sync.py + _temperature_status_mixin.py; wire sync into screen_lock.py / status_view.py; add the crdt-sync git dependency and test_sync_fixtures / test_workout_sync suites. - Dart (workout_app): new workout_sync_service.dart, github_device_auth.dart, sync_settings.dart with tests + fakes; settings/workout screens updated. - docs: workstream-d-shared-crdt-transport.md. Verification: pytest 747 passed at 100% coverage; flutter analyze clean; flutter test passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
This commit is contained in:
parent
494cc74bb5
commit
399f9815c4
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/<SYNC_PHONE_DEVICE_ID>/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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
100
screen_locker/_temperature_status_mixin.py
Normal file
100
screen_locker/_temperature_status_mixin.py
Normal file
@ -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)
|
||||
108
screen_locker/_workout_sync.py
Normal file
108
screen_locker/_workout_sync.py
Normal file
@ -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, <message>)``.
|
||||
- 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
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -376,5 +376,3 @@ class TestPullAndParseTcx:
|
||||
# ---------------------------------------------------------------------------
|
||||
# _verify_runnerup_via_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@ -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)."""
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
35
screen_locker/tests/test_sync_fixtures.py
Normal file
35
screen_locker/tests/test_sync_fixtures.py
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
120
screen_locker/tests/test_workout_sync.py
Normal file
120
screen_locker/tests/test_workout_sync.py
Normal file
@ -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
|
||||
@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@ -27,6 +40,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Debounce weight saves to avoid resetting streaks on every tap.
|
||||
final Map<String, Timer> _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<SettingsScreen> {
|
||||
for (final t in _weightTimers.values) {
|
||||
t.cancel();
|
||||
}
|
||||
_tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<SettingsScreen> {
|
||||
_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<void> _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<void> _connectGitHub() async {
|
||||
final auth = GitHubDeviceAuth(
|
||||
clientId: SyncSettings.defaultClientId,
|
||||
httpClient: widget.httpClient,
|
||||
);
|
||||
try {
|
||||
final device = await auth.requestDeviceCode();
|
||||
if (!mounted) return;
|
||||
final token = await showDialog<String>(
|
||||
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<void> _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<SettingsScreen> {
|
||||
_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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<WorkoutScreen> {
|
||||
await storage.clearActiveSession();
|
||||
|
||||
final syncResult = await _sync.writeWorkoutResult(session);
|
||||
unawaited(WorkoutSyncService().push(session));
|
||||
|
||||
if (!mounted) return;
|
||||
unawaited(
|
||||
|
||||
@ -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<void> export(Map<String, dynamic> 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<Map<String, dynamic>?> 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<String, dynamic>;
|
||||
@ -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<void> 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<String?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, dynamic> 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<void> Function(Duration)? delay,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
// Indirection so tests can skip real waiting between polls.
|
||||
_delay = delay ?? Future<void>.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<void> 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<DeviceCodeResponse> 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<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<String> 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<String, dynamic> 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<String, dynamic>;
|
||||
} 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();
|
||||
}
|
||||
@ -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<SyncSettings> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String, dynamic>).map(
|
||||
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||
);
|
||||
|
||||
/// 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<void> 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
|
||||
? <String, Record>{}
|
||||
: _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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
133
stronglift_replacement/workout_app/test/fake_audio_platform.dart
Normal file
133
stronglift_replacement/workout_app/test/fake_audio_platform.dart
Normal file
@ -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 = <String, StreamController<AudioEvent>>{};
|
||||
|
||||
StreamController<AudioEvent> _controllerFor(String playerId) =>
|
||||
_controllers.putIfAbsent(
|
||||
playerId,
|
||||
() => StreamController<AudioEvent>.broadcast(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> create(String playerId) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose(String playerId) async {
|
||||
await _controllers.remove(playerId)?.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(String playerId) async {}
|
||||
|
||||
@override
|
||||
Future<void> stop(String playerId) async {}
|
||||
|
||||
@override
|
||||
Future<void> resume(String playerId) async {}
|
||||
|
||||
@override
|
||||
Future<void> release(String playerId) async {}
|
||||
|
||||
@override
|
||||
Future<void> seek(String playerId, Duration position) async {}
|
||||
|
||||
@override
|
||||
Future<void> setBalance(String playerId, double balance) async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(String playerId, double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> setReleaseMode(
|
||||
String playerId,
|
||||
ReleaseMode releaseMode,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> setPlaybackRate(String playerId, double playbackRate) async {}
|
||||
|
||||
@override
|
||||
Future<void> setSourceUrl(
|
||||
String playerId,
|
||||
String url, {
|
||||
bool? isLocal,
|
||||
String? mimeType,
|
||||
}) async {
|
||||
_controllerFor(
|
||||
playerId,
|
||||
).add(const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setSourceBytes(
|
||||
String playerId,
|
||||
Uint8List bytes, {
|
||||
String? mimeType,
|
||||
}) async {
|
||||
_controllerFor(
|
||||
playerId,
|
||||
).add(const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAudioContext(
|
||||
String playerId,
|
||||
AudioContext audioContext,
|
||||
) async {}
|
||||
|
||||
@override
|
||||
Future<void> setPlayerMode(String playerId, PlayerMode playerMode) async {}
|
||||
|
||||
@override
|
||||
Future<int?> getDuration(String playerId) async => 0;
|
||||
|
||||
@override
|
||||
Future<int?> getCurrentPosition(String playerId) async => 0;
|
||||
|
||||
@override
|
||||
Future<void> emitLog(String playerId, String message) async {}
|
||||
|
||||
@override
|
||||
Future<void> emitError(String playerId, String code, String message) async {}
|
||||
|
||||
@override
|
||||
Stream<AudioEvent> getEventStream(String playerId) =>
|
||||
_controllerFor(playerId).stream;
|
||||
}
|
||||
|
||||
class _FakeGlobalAudioplayersPlatform
|
||||
implements GlobalAudioplayersPlatformInterface {
|
||||
final _controller = StreamController<GlobalAudioEvent>.broadcast();
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<void> setGlobalAudioContext(AudioContext ctx) async {}
|
||||
|
||||
@override
|
||||
Future<void> emitGlobalLog(String message) async {}
|
||||
|
||||
@override
|
||||
Future<void> emitGlobalError(String code, String message) async {}
|
||||
|
||||
@override
|
||||
Stream<GlobalAudioEvent> 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();
|
||||
}
|
||||
@ -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<String, String>? initial,
|
||||
bool throwing = false,
|
||||
}) {
|
||||
const channel = MethodChannel(
|
||||
'plugins.it_nomads.com/flutter_secure_storage',
|
||||
);
|
||||
final store = <String, String>{...?initial};
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (throwing) {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
}
|
||||
final args = (call.arguments as Map?) ?? const <Object?, Object?>{};
|
||||
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<String, String>.from(store);
|
||||
case 'deleteAll':
|
||||
store.clear();
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
addTearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
}
|
||||
@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> _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<void> _pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() done, {
|
||||
int maxTries = 200,
|
||||
}) async {
|
||||
for (var i = 0; i < maxTries && !done(); i++) {
|
||||
await Future<void>.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<void> _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<void>.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<void>.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<void>.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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<void> _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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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<void>.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<DeviceAuthException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<DeviceAuthException>().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<DeviceAuthException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<DeviceAuthException>().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');
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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<String, dynamic> body;
|
||||
}
|
||||
|
||||
/// Builds a mock HTTP client backed by an in-memory router, matching
|
||||
/// `crdt_sync_dart`'s own `sync_test.dart` helper: GET
|
||||
/// `.../contents/<key>` 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<String, http.Response> 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<String, dynamic>),
|
||||
);
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user