Sync workouts through Firebase, mirroring to GitHub during the cutover
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Both surfaces of this repo move together, because they share one prefix:
screen_locker (the PC, which reads what the phone logged) and workout_app (the
phone, which writes).

Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet is still seen. MirrorSyncClient reads
the union of both, so a workout logged against either backend still counts --
which is what lets the PC and the phone cut over on different days.

The PC's two pulls are read-only, so they need only the client swapped.
_manual_push does a real sync_log and now carries a revision cache, as does
every push in workout_app: without it each push re-uploads the whole log and
each pull re-downloads every peer's, regardless of change.

remote_client is public rather than _private because _manual_push imports it;
one decision point beats two copies that can drift.

The config file is checked before constructing anything, so an unconfigured
machine never reaches the network -- without that guard the PC's sync tests
asserted against live Firebase data instead of their own fakes, which is how
this was caught.

screen_locker: 904 tests at 100% branch coverage.
workout_app: 299 tests at 100% line coverage; pinned v0.1.0 -> v0.6.0 across
three minor versions with no API drift. Four assertions moved off `.single`,
which now finds two writes: the log, then the revision published after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
This commit is contained in:
Krzysztof Rudnicki 2026-08-05 08:12:40 +02:00
parent e3cd68dc21
commit eb4ff01e04
15 changed files with 567 additions and 88 deletions

View File

@ -100,7 +100,7 @@ repos:
additional_dependencies: additional_dependencies:
- pytest - pytest
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync
- mcp>=2,<3 - mcp>=2,<3
- pydantic - pydantic

View File

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

View File

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

View File

@ -130,6 +130,13 @@ SYNC_PHONE_DEVICE_ID: str = "phone"
# committed — this path is outside the repo entirely. Unlike diet_guard, # committed — this path is outside the repo entirely. Unlike diet_guard,
# this file being absent is a normal, expected state (sync is optional here). # 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_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
# Revision cache for cross-device sync. Without it every push re-uploads and
# every pull re-downloads regardless of change, which is the traffic the
# Firebase free tier's monthly budget depends on not happening.
SYNC_STATE_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / "sync_state.json"
)
SYNC_TIMEOUT_SECONDS: float = 10.0 SYNC_TIMEOUT_SECONDS: float = 10.0
# Sync runs from the morning routine, which is started right after boot and # Sync runs from the morning routine, which is started right after boot and

View File

@ -28,10 +28,12 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from crdt_sync import ( from crdt_sync import (
FileSyncStateStore,
GitHubSyncClient, GitHubSyncClient,
GitHubSyncError, GitHubSyncError,
Hlc, Hlc,
Record, Record,
RemoteSyncError,
RepoNotFoundError, RepoNotFoundError,
sync_log, sync_log,
) )
@ -39,6 +41,7 @@ from crdt_sync import (
from screen_locker._constants import ( from screen_locker._constants import (
SYNC_REPO_NAME, SYNC_REPO_NAME,
SYNC_REPO_OWNER, SYNC_REPO_OWNER,
SYNC_STATE_FILE,
SYNC_TIMEOUT_SECONDS, SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE, SYNC_TOKEN_FILE,
) )
@ -46,7 +49,7 @@ from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _derive_workout_id from screen_locker._log_mixin import _derive_workout_id
from screen_locker._sync_retry import with_sync_retry from screen_locker._sync_retry import with_sync_retry
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@ -175,11 +178,13 @@ def push_pc_workouts(log_file: Path) -> PushResult:
_logger.warning("Workouts NOT synced: %s", reason) _logger.warning("Workouts NOT synced: %s", reason)
return PushResult(pushed=False, record_count=0, reason=reason) return PushResult(pushed=False, record_count=0, reason=reason)
client = GitHubSyncClient( client = remote_client(
SYNC_REPO_OWNER, GitHubSyncClient(
SYNC_REPO_NAME, SYNC_REPO_OWNER,
token, SYNC_REPO_NAME,
timeout_seconds=SYNC_TIMEOUT_SECONDS, token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
) )
try: try:
with_sync_retry( with_sync_retry(
@ -190,10 +195,11 @@ def push_pc_workouts(log_file: Path) -> PushResult:
local_log=log, local_log=log,
encode=_encode_log, encode=_encode_log,
decode=_decode_log, decode=_decode_log,
state_store=FileSyncStateStore(SYNC_STATE_FILE),
), ),
description="push PC workouts", description="push PC workouts",
) )
except GitHubSyncError as exc: except (GitHubSyncError, RemoteSyncError) as exc:
reason = f"sync error: {exc}" reason = f"sync error: {exc}"
# Report what actually happened. This used to assert "a 403 here means # Report what actually happened. This used to assert "a 403 here means
# the token lacks contents:write" for *every* failure, including a # the token lacks contents:write" for *every* failure, including a

View File

@ -13,7 +13,18 @@ from __future__ import annotations
import json import json
import logging import logging
from crdt_sync import GitHubSyncClient, GitHubSyncError, Hlc, Record from crdt_sync import (
CONFIG_FILE,
ConfigError,
FirebaseAuthError,
GitHubSyncClient,
GitHubSyncError,
Hlc,
Record,
RemoteStore,
RemoteSyncError,
mirror_client_for,
)
from screen_locker._constants import ( from screen_locker._constants import (
SYNC_PHONE_DEVICE_ID, SYNC_PHONE_DEVICE_ID,
@ -84,6 +95,32 @@ def _latest_payload(log_json: str) -> dict | None:
return payload return payload
def remote_client(github: RemoteStore) -> RemoteStore:
"""Return the backend to read the phone's workout log from.
Firebase when ``~/.config/crdt-sync/`` is set up, with GitHub kept as a
mirror so a phone that has not moved yet is still seen; GitHub alone
otherwise. Both callers here are read-only pulls -- this side never pushes
-- and :class:`MirrorSyncClient` reads the union of both, so a workout
logged against either backend still counts.
The config file is checked before constructing anything, so an
unconfigured machine never reaches the network.
Rolling back is deleting this function and passing ``github`` straight
through: no data moves either way.
"""
if not CONFIG_FILE.is_file():
return github
try:
return mirror_client_for("screen_locker", github)
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
_logger.warning(
"Firebase unavailable, reading workouts via GitHub only: %s", exc
)
return github
def pull_synced_workout() -> tuple[dict | None, str | None]: def pull_synced_workout() -> tuple[dict | None, str | None]:
"""Return ``(data, error)``: the phone's last-synced workout, if any. """Return ``(data, error)``: the phone's last-synced workout, if any.
@ -107,11 +144,13 @@ def pull_synced_workout() -> tuple[dict | None, str | None]:
) )
return None, None return None, None
client = GitHubSyncClient( client = remote_client(
SYNC_REPO_OWNER, GitHubSyncClient(
SYNC_REPO_NAME, SYNC_REPO_OWNER,
token, SYNC_REPO_NAME,
timeout_seconds=SYNC_TIMEOUT_SECONDS, token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
) )
try: try:
text = with_sync_retry( text = with_sync_retry(
@ -186,11 +225,13 @@ def pull_all_manual_records() -> list[tuple[str, dict]]:
) )
return [] return []
client = GitHubSyncClient( client = remote_client(
SYNC_REPO_OWNER, GitHubSyncClient(
SYNC_REPO_NAME, SYNC_REPO_OWNER,
token, SYNC_REPO_NAME,
timeout_seconds=SYNC_TIMEOUT_SECONDS, token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
) )
try: try:
devices = with_sync_retry( devices = with_sync_retry(

View File

@ -13,6 +13,7 @@ from __future__ import annotations
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime, timezone from datetime import datetime, timezone
import json import json
from pathlib import Path
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -34,7 +35,6 @@ from screen_locker.tests._locker_factories import (
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator, Iterator from collections.abc import Generator, Iterator
from pathlib import Path
# Every module that imports ``tkinter as tk`` and calls it directly. The UI is # Every module that imports ``tkinter as tk`` and calls it directly. The UI is
@ -300,3 +300,20 @@ def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock:
def temp_log_file(tmp_path: Path) -> Path: def temp_log_file(tmp_path: Path) -> Path:
"""Create a temporary log file path.""" """Create a temporary log file path."""
return tmp_path / "workout_log.json" return tmp_path / "workout_log.json"
@pytest.fixture(autouse=True)
def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""Point the Firebase config at a path that does not exist.
``remote_client`` reads ``crdt_sync.CONFIG_FILE`` to decide whether to
build a Firebase-primary mirror. On a developer machine that file *does*
exist, so without this the sync tests would reach the real database and
assert against live data instead of their own fakes. Tests that want the
Firebase path point it back at a file they control.
"""
from screen_locker import _workout_sync
monkeypatch.setattr(
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
)

View File

@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from crdt_sync import GitHubSyncError, Hlc, Record from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record
import pytest import pytest
from screen_locker import _workout_sync from screen_locker import _workout_sync
@ -292,3 +293,51 @@ class TestPullAllManualRecords:
result = _workout_sync.pull_all_manual_records() result = _workout_sync.pull_all_manual_records()
assert len(result) == 1 assert len(result) == 1
assert result[0][1]["cost"] == "NEW" assert result[0][1]["cost"] == "NEW"
class TestRemoteClient:
"""Which backend the workout pull reads from during the cutover."""
def test_stays_on_github_without_firebase_config(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unconfigured machine must not reach the network at all."""
monkeypatch.setattr(
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
)
github = object()
assert _workout_sync.remote_client(github) is github
def test_mirrors_to_github_when_configured(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Configured: reads union both, so either backend's workout counts."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
monkeypatch.setattr(
_workout_sync,
"mirror_client_for",
lambda _app, client: ("mirror", client),
)
github = object()
assert _workout_sync.remote_client(github) == ("mirror", github)
def test_falls_back_when_firebase_is_unusable(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken Firebase must degrade to GitHub, never fail the pull."""
config = tmp_path / "firebase.json"
config.write_text("{}", encoding="utf-8")
monkeypatch.setattr(_workout_sync, "CONFIG_FILE", config)
def _boom(*_args: object, **_kwargs: object) -> None:
message = "no password"
raise ConfigError(message)
monkeypatch.setattr(_workout_sync, "mirror_client_for", _boom)
github = object()
assert _workout_sync.remote_client(github) is github

View File

@ -0,0 +1,84 @@
/// Wiring for the Firebase backend, during and after the GitHub cutover.
///
/// Split by what is safe to publish, because this repo is public:
///
/// * [kProject] holds the Web API key and database URL. Both are public
/// identifiers that already ship inside the APK; the security rules, not
/// their secrecy, are what protect the data.
/// * The account email and password are entered once per device and kept in
/// the OS keystore, next to the GitHub token this app already stores there.
///
/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python
/// half. On Android there is no such file.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// The shared `kuhy-syncs` project.
///
/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form
/// answers 404 with a `correctUrl` body rather than an obvious error, which
/// reads like an auth failure and wastes a debugging session.
const kProject = FirebaseProject(
apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
databaseUrl:
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
);
/// Same options the GitHub token uses: off Android's deprecated
/// `encryptedSharedPreferences` path, and libsecret on Linux.
const _secure = FlutterSecureStorage();
// Everything below reaches the OS keystore through a platform channel, which
// `flutter test` has no binding for -- the same reason `main.dart` and
// `openRepository()` are excluded. The logic these wrap (parsing, the
// public/private split, sign-in) lives in `crdt_sync` and is covered there at
// 100%; what is left here is the two-line adapter.
// coverage:ignore-start
/// The keystore-backed home for the Firebase refresh token.
SecureCredentialStore credentialStore() => SecureCredentialStore(
read: (key) => _secure.read(key: key),
write: (key, value) => _secure.write(key: key, value: value),
delete: (key) => _secure.delete(key: key),
);
/// Reads the per-device account, or null when sync has not been set up.
Future<FirebaseAccount?> loadAccount() async {
try {
return FirebaseAccount.tryParse(
await _secure.read(key: kFirebaseAccountKey),
);
} on Exception {
// No secret service available: behave as "not configured" rather than
// crashing the settings screen.
return null;
}
}
/// Stores the per-device account. Keystore only — never prefs, never source.
Future<void> saveAccount(FirebaseAccount account) =>
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
/// Forgets the account and any cached session.
Future<void> clearAccount() async {
await _secure.delete(key: kFirebaseAccountKey);
await credentialStore().clear();
}
/// Returns a signed-in Firebase client, or null when not configured.
///
/// Signs in with the stored password only when there is no cached refresh
/// token, so the usual path costs no authentication round trip.
Future<FirebaseRestClient?> openFirebase() async {
final account = await loadAccount();
if (account == null) return null;
return firebaseClientFor(
config: kProject.configFor(account.email),
store: credentialStore(),
password: account.password,
);
}
// coverage:ignore-end

View File

@ -0,0 +1,34 @@
/// Where the sync revision cache lives on this device.
///
/// Android only — this app has no web target — so unlike the other consumers
/// it needs no conditional export.
library;
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync/crdt_sync_io.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// File holding the revision cache, beside the workout log it describes.
const kSyncStateFileName = 'sync_state.json';
/// Opens the revision cache in the app's support directory.
// coverage:ignore-start
// Resolves the real per-platform directory, so it cannot run under test;
// [openSyncStateStoreIn] holds the logic and is covered.
Future<SyncStateStore> openSyncStateStore() async {
final dir = await getApplicationSupportDirectory();
return openSyncStateStoreIn(dir.path);
}
// coverage:ignore-end
/// Opens the revision cache rooted at [dirPath].
///
/// Must be cleared with the log it describes: skipping an unchanged peer is
/// only sound because that peer's records are already merged locally, so
/// state that outlived its log would skip peers whose data had been lost.
SyncStateStore openSyncStateStoreIn(String dirPath) => PersistedSyncStateStore(
FileLogPersistence(File(p.join(dirPath, kSyncStateFileName))),
);

View File

@ -8,7 +8,9 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:workout_app/models/manual_workout.dart'; import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/firebase_backend.dart';
import 'package:workout_app/services/sync_settings.dart'; import 'package:workout_app/services/sync_settings.dart';
import 'package:workout_app/services/sync_state_factory.dart';
const _deviceId = 'phone'; const _deviceId = 'phone';
const _pathPrefix = 'screen-locker-sync/devices'; const _pathPrefix = 'screen-locker-sync/devices';
@ -36,11 +38,29 @@ class WorkoutSyncService {
this.owner = syncRepoOwner, this.owner = syncRepoOwner,
this.repo = syncRepoName, this.repo = syncRepoName,
http.Client? httpClient, http.Client? httpClient,
this.firebaseFactory,
this.stateStore,
// Dart forbids private named params, so this can't be an initializing // Dart forbids private named params, so this can't be an initializing
// formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient). // formal; assign it explicitly (mirrors crdt_sync_dart's GitHubClient).
// ignore: prefer_initializing_formals // ignore: prefer_initializing_formals
}) : _httpClient = httpClient; }) : _httpClient = httpClient;
/// Builds the Firebase backend. Injected so tests can supply a fake, or null
/// to assert the pre-migration GitHub-only path still works.
final Future<FirebaseRestClient?> Function()? firebaseFactory;
/// Revision cache. Injected so tests need no application-support directory.
final SyncStateStore? stateStore;
/// Firebase when this device is set up for it, else null (a normal state
/// during the cutover: sync then runs over GitHub exactly as before).
Future<FirebaseRestClient?> _openFirebase() =>
(firebaseFactory ?? openFirebase)();
/// The revision cache, defaulting to a file beside the app's data.
Future<SyncStateStore> _openStateStore() async =>
stateStore ?? await openSyncStateStore();
/// The repo owner/org to push to. /// The repo owner/org to push to.
final String owner; final String owner;
@ -55,12 +75,16 @@ class WorkoutSyncService {
final settings = await SyncSettings.load(); final settings = await SyncSettings.load();
if (!settings.isConfigured) return; if (!settings.isConfigured) return;
final client = GitHubClient( final github = GitHubClient(
owner: owner, owner: owner,
repo: repo, repo: repo,
token: settings.token, token: settings.token,
httpClient: _httpClient, httpClient: _httpClient,
); );
final firebase = await _openFirebase();
final client = firebase == null
? github
: MirrorStore(primary: firebase, mirror: github);
try { try {
const path = '$_pathPrefix/$_deviceId/$_logFilename'; const path = '$_pathPrefix/$_deviceId/$_logFilename';
final existingText = await client.getFileText(path); final existingText = await client.getFileText(path);
@ -79,11 +103,16 @@ class WorkoutSyncService {
localLog: localLog, localLog: localLog,
encode: _encode, encode: _encode,
decode: _decode, decode: _decode,
// Without this every push re-uploads the whole log and every pull
// re-downloads every peer's, regardless of change -- the traffic the
// Firebase free tier's monthly budget depends on not happening.
stateStore: await _openStateStore(),
); );
} on GitHubSyncError catch (error) { } on GitHubSyncError catch (error) {
debugPrint('WorkoutSyncService.push failed: $error'); debugPrint('WorkoutSyncService.push failed: $error');
} finally { } finally {
client.close(); github.close();
firebase?.close();
} }
} }
@ -94,12 +123,16 @@ class WorkoutSyncService {
final settings = await SyncSettings.load(); final settings = await SyncSettings.load();
if (!settings.isConfigured) return; if (!settings.isConfigured) return;
final client = GitHubClient( final github = GitHubClient(
owner: owner, owner: owner,
repo: repo, repo: repo,
token: settings.token, token: settings.token,
httpClient: _httpClient, httpClient: _httpClient,
); );
final firebase = await _openFirebase();
final client = firebase == null
? github
: MirrorStore(primary: firebase, mirror: github);
try { try {
const path = '$_pathPrefix/$_deviceId/$_logFilename'; const path = '$_pathPrefix/$_deviceId/$_logFilename';
final existingText = await client.getFileText(path); final existingText = await client.getFileText(path);
@ -114,11 +147,16 @@ class WorkoutSyncService {
localLog: localLog, localLog: localLog,
encode: _encode, encode: _encode,
decode: _decode, decode: _decode,
// Without this every push re-uploads the whole log and every pull
// re-downloads every peer's, regardless of change -- the traffic the
// Firebase free tier's monthly budget depends on not happening.
stateStore: await _openStateStore(),
); );
} on GitHubSyncError catch (error) { } on GitHubSyncError catch (error) {
debugPrint('WorkoutSyncService.pushManual failed: $error'); debugPrint('WorkoutSyncService.pushManual failed: $error');
} finally { } finally {
client.close(); github.close();
firebase?.close();
} }
} }
@ -181,16 +219,24 @@ class WorkoutSyncService {
String token, String token,
String? kind, String? kind,
) async { ) async {
final client = GitHubClient( final github = GitHubClient(
owner: owner, owner: owner,
repo: repo, repo: repo,
token: token, token: token,
httpClient: _httpClient, httpClient: _httpClient,
); );
// Read-only: MirrorStore reads the union of both, so a workout logged
// against either backend still shows up during the cutover.
final firebase = await _openFirebase();
final client = firebase == null
? github
: MirrorStore(primary: firebase, mirror: github);
try { try {
final merged = <String, Record>{}; final merged = <String, Record>{};
for (final device in await client.listDirectory(_pathPrefix)) { for (final device in await client.listDirectory(_pathPrefix)) {
final text = await client.getFileText('$_pathPrefix/$device/$_logFilename'); final text = await client.getFileText(
'$_pathPrefix/$device/$_logFilename',
);
if (text == null) continue; if (text == null) continue;
_mergeRecords(_decode(text), merged, kind: kind); _mergeRecords(_decode(text), merged, kind: kind);
} }
@ -198,7 +244,8 @@ class WorkoutSyncService {
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>()) .map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
.toList(); .toList();
} finally { } finally {
client.close(); github.close();
firebase?.close();
} }
} }

View File

@ -141,11 +141,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: crdt_sync_dart path: crdt_sync_dart
ref: "crdt_sync_dart-v0.1.0" ref: "crdt_sync_dart-v0.6.0"
resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6" resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d
url: "https://github.com/kuhyx/utils" url: "https://github.com/kuhyx/utils"
source: git source: git
version: "0.1.0" version: "0.6.0"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:

View File

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

View File

@ -0,0 +1,54 @@
/// Tests for the file-backed revision cache.
///
/// The cache is what keeps a sync tick from re-downloading every peer's whole
/// log when nothing has changed, so "does it actually survive a restart" is
/// the behaviour worth pinning.
@TestOn('vm')
library;
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/sync_state_factory.dart';
import 'package:path/path.dart' as p;
void main() {
late Directory dir;
setUp(() async {
dir = await Directory.systemTemp.createTemp('wo_state_');
});
tearDown(() async {
if (dir.existsSync()) await dir.delete(recursive: true);
});
test('persists revisions across a restart', () async {
// A fresh store instance stands in for the next app launch: an
// in-memory cache would forget every peer and re-download everything.
await openSyncStateStoreIn(dir.path).save(
const SyncState(pushedRev: 'mine', peerRevs: {'phone': 'theirs'}),
);
final reloaded = await openSyncStateStoreIn(dir.path).load();
expect(reloaded.pushedRev, 'mine');
expect(reloaded.peerRevs, {'phone': 'theirs'});
});
test('writes beside the log it describes', () async {
await openSyncStateStoreIn(
dir.path,
).save(const SyncState(pushedRev: 'x'));
expect(File(p.join(dir.path, kSyncStateFileName)).existsSync(), isTrue);
});
test('reports nothing remembered before the first sync', () async {
final state = await openSyncStateStoreIn(dir.path).load();
expect(state.pushedRev, isNull);
expect(state.peerRevs, isEmpty);
});
}

View File

@ -67,7 +67,35 @@ WorkoutSession _session({DateTime? startTime}) => WorkoutSession(
], ],
); );
/// Builds the service with the platform kept out of it.
///
/// The real factories want the OS keystore and an application-support
/// directory, neither of which exists under `flutter test`; passing null for
/// Firebase also asserts the pre-migration GitHub-only path still works.
/// The PUT that carries the log itself.
///
/// Each push now writes twice: the log, then this device's revision (which is
/// what lets a later tick skip an unchanged peer). These assertions are about
/// the log, so select it rather than assuming a single write.
_PutCall _logPut(List<_PutCall> calls) =>
calls.firstWhere((c) => c.path.endsWith('log.json'));
WorkoutSyncService _service({
http.Client? httpClient,
String? owner,
String? repo,
Future<FirebaseRestClient?> Function()? firebaseFactory,
}) => WorkoutSyncService(
httpClient: httpClient,
owner: owner ?? syncRepoOwner,
repo: repo ?? syncRepoName,
firebaseFactory: firebaseFactory ?? () async => null,
stateStore: InMemorySyncStateStore(),
);
void main() { void main() {
_cutoverTests();
// installFakeSecureStorage touches the test binary messenger, which needs // installFakeSecureStorage touches the test binary messenger, which needs
// the binding up first (widget tests get this for free via testWidgets). // the binding up first (widget tests get this for free via testWidgets).
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@ -76,7 +104,7 @@ void main() {
installFakeSecureStorage(); installFakeSecureStorage();
final (:httpClient, :putCalls) = _mockGitHub(); final (:httpClient, :putCalls) = _mockGitHub();
await WorkoutSyncService(httpClient: httpClient).push(_session()); await _service(httpClient: httpClient).push(_session());
expect(putCalls, isEmpty); expect(putCalls, isEmpty);
}); });
@ -88,14 +116,15 @@ void main() {
); );
final session = _session(); final session = _session();
await WorkoutSyncService(httpClient: httpClient).push(session); await _service(httpClient: httpClient).push(session);
expect(putCalls, hasLength(1)); // Two writes: the log, then the revision published after it.
expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json'); expect(putCalls, hasLength(2));
expect(_logPut(putCalls).path, 'screen-locker-sync/devices/phone/log.json');
final pushedLog = final pushedLog =
jsonDecode( jsonDecode(
utf8.decode( utf8.decode(
base64.decode(putCalls.single.body['content'] as String), base64.decode(_logPut(putCalls).body['content'] as String),
), ),
) )
as Map<String, dynamic>; as Map<String, dynamic>;
@ -121,12 +150,12 @@ void main() {
); );
final session = _session(); final session = _session();
await WorkoutSyncService(httpClient: httpClient).push(session); await _service(httpClient: httpClient).push(session);
final pushedLog = final pushedLog =
jsonDecode( jsonDecode(
utf8.decode( utf8.decode(
base64.decode(putCalls.single.body['content'] as String), base64.decode(_logPut(putCalls).body['content'] as String),
), ),
) )
as Map<String, dynamic>; as Map<String, dynamic>;
@ -143,7 +172,7 @@ void main() {
); );
await expectLater( await expectLater(
WorkoutSyncService(httpClient: httpClient).push(_session()), _service(httpClient: httpClient).push(_session()),
completes, completes,
); );
}); });
@ -161,7 +190,7 @@ void main() {
test('pushManual does nothing without a token', () async { test('pushManual does nothing without a token', () async {
installFakeSecureStorage(); installFakeSecureStorage();
final (:httpClient, :putCalls) = _mockGitHub(); final (:httpClient, :putCalls) = _mockGitHub();
await WorkoutSyncService( await _service(
httpClient: httpClient, httpClient: httpClient,
).pushManual(_manual('manual:x', '40', 1000)); ).pushManual(_manual('manual:x', '40', 1000));
expect(putCalls, isEmpty); expect(putCalls, isEmpty);
@ -172,10 +201,10 @@ void main() {
final (:httpClient, :putCalls) = _mockGitHub( final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {'screen-locker-sync/devices': _response(200, [])}, contentResponses: {'screen-locker-sync/devices': _response(200, [])},
); );
await WorkoutSyncService( await _service(
httpClient: httpClient, httpClient: httpClient,
).pushManual(_manual('manual:2026-07-13T18:00', '40', 1000)); ).pushManual(_manual('manual:2026-07-13T18:00', '40', 1000));
expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json'); expect(_logPut(putCalls).path, 'screen-locker-sync/devices/phone/log.json');
}); });
test('pushManual merges onto an existing log', () async { test('pushManual merges onto an existing log', () async {
@ -189,13 +218,13 @@ void main() {
'screen-locker-sync/devices/phone/log.json': _fileContaining(existing), 'screen-locker-sync/devices/phone/log.json': _fileContaining(existing),
}, },
); );
await WorkoutSyncService( await _service(
httpClient: httpClient, httpClient: httpClient,
).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000)); ).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000));
final pushedLog = final pushedLog =
jsonDecode( jsonDecode(
utf8.decode( utf8.decode(
base64.decode(putCalls.single.body['content'] as String), base64.decode(_logPut(putCalls).body['content'] as String),
), ),
) )
as Map<String, dynamic>; as Map<String, dynamic>;
@ -211,7 +240,7 @@ void main() {
(request) async => http.Response('', 500), (request) async => http.Response('', 500),
); );
await expectLater( await expectLater(
WorkoutSyncService( _service(
httpClient: httpClient, httpClient: httpClient,
).pushManual(_manual('manual:x', '40', 1000)), ).pushManual(_manual('manual:x', '40', 1000)),
completes, completes,
@ -222,7 +251,7 @@ void main() {
installFakeSecureStorage(); installFakeSecureStorage();
final (:httpClient, putCalls: _) = _mockGitHub(); final (:httpClient, putCalls: _) = _mockGitHub();
expect( expect(
await WorkoutSyncService( await _service(
httpClient: httpClient, httpClient: httpClient,
).readMergedManualPayloads(), ).readMergedManualPayloads(),
isEmpty, isEmpty,
@ -234,12 +263,17 @@ void main() {
final session = Record( final session = Record(
id: 's', id: 's',
fields: { fields: {
'payload': ({'workout_type': 'A'}, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')), 'payload': (
{'workout_type': 'A'},
Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'),
),
}, },
); );
final noPayload = Record( final noPayload = Record(
id: 'np', id: 'np',
fields: {'other': (1, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'))}, fields: {
'other': (1, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')),
},
); );
final phoneLog = jsonEncode({ final phoneLog = jsonEncode({
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(), 'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
@ -298,7 +332,7 @@ void main() {
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog), 'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
}, },
); );
final service = WorkoutSyncService(httpClient: httpClient); final service = _service(httpClient: httpClient);
final all = await service.readMergedWorkoutPayloads(); final all = await service.readMergedWorkoutPayloads();
expect(all, hasLength(2)); expect(all, hasLength(2));
@ -308,37 +342,39 @@ void main() {
); );
}); });
test('readMergedManualPayloads still excludes runs (budget stays manual)', test(
() async { 'readMergedManualPayloads still excludes runs (budget stays manual)',
// A verified run must never consume the manual self-report budget. () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'}); // A verified run must never consume the manual self-report budget.
final run = Record( installFakeSecureStorage(initial: {'sync.token': 'tok'});
id: 'runnerup_verified:2026-07-13', final run = Record(
fields: { id: 'runnerup_verified:2026-07-13',
'payload': ( fields: {
{'kind': 'runnerup_verified', 'date': '2026-07-13'}, 'payload': (
Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'), {'kind': 'runnerup_verified', 'date': '2026-07-13'},
), Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'pc'),
}, ),
); },
final pcLog = jsonEncode({ );
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(), final pcLog = jsonEncode({
'runnerup_verified:2026-07-13': run.toJson(), 'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
}); 'runnerup_verified:2026-07-13': run.toJson(),
final (:httpClient, putCalls: _) = _mockGitHub( });
contentResponses: { final (:httpClient, putCalls: _) = _mockGitHub(
'screen-locker-sync/devices': _response(200, [ contentResponses: {
{'name': 'pc'}, 'screen-locker-sync/devices': _response(200, [
]), {'name': 'pc'},
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog), ]),
}, 'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
); },
final manuals = await WorkoutSyncService( );
httpClient: httpClient, final manuals = await WorkoutSyncService(
).readMergedManualPayloads(); httpClient: httpClient,
expect(manuals, hasLength(1)); ).readMergedManualPayloads();
expect(manuals.single['kind'], 'manual_workout'); expect(manuals, hasLength(1));
}); expect(manuals.single['kind'], 'manual_workout');
},
);
test('readMergedManualPayloads swallows a sync error', () async { test('readMergedManualPayloads swallows a sync error', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'}); installFakeSecureStorage(initial: {'sync.token': 'tok'});
@ -346,7 +382,7 @@ void main() {
(request) async => http.Response('', 500), (request) async => http.Response('', 500),
); );
expect( expect(
await WorkoutSyncService( await _service(
httpClient: httpClient, httpClient: httpClient,
).readMergedManualPayloads(), ).readMergedManualPayloads(),
isEmpty, isEmpty,
@ -375,9 +411,12 @@ void main() {
return http.Response('Bad credentials', 401); return http.Response('Bad credentials', 401);
} }
if (req.url.path.endsWith('screen-locker-sync/devices')) { if (req.url.path.endsWith('screen-locker-sync/devices')) {
return http.Response(jsonEncode([ return http.Response(
{'name': 'pc', 'type': 'dir'}, jsonEncode([
]), 200); {'name': 'pc', 'type': 'dir'},
]),
200,
);
} }
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
@ -414,8 +453,109 @@ void main() {
); );
expect( expect(
await WorkoutSyncService(httpClient: httpClient).readMergedManualPayloads(), await _service(httpClient: httpClient).readMergedManualPayloads(),
isEmpty, isEmpty,
); );
}); });
} }
/// A Firebase client wired to an in-memory mock, for the cutover tests.
///
/// The session's expiry uses the real clock: the token provider compares
/// against `DateTime.now()`, so a fixture-dated session looks expired and
/// triggers a refresh the mock never answers.
({FirebaseRestClient client, List<String> puts}) _fakeFirebase() {
final puts = <String>[];
final client = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
),
httpClient: http_testing.MockClient((request) async {
if (request.method == 'PUT') {
puts.add(request.url.path);
return http.Response(request.body, 200);
}
return http.Response('null', 200);
}),
);
return (client: client, puts: puts);
}
void _cutoverTests() {
group('Firebase cutover', () {
test('pushes to Firebase and still mirrors to GitHub', () async {
// The cutover guarantee: both backends receive the write, so a PC that
// has not moved yet still sees the workout.
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final (:httpClient, :putCalls) = _mockGitHub();
final firebase = _fakeFirebase();
await _service(
httpClient: httpClient,
firebaseFactory: () async => firebase.client,
).push(_session());
expect(
firebase.puts.any((p) => p.contains('screen-locker-sync')),
isTrue,
reason: 'Firebase is primary and must receive the write',
);
expect(
putCalls,
isNotEmpty,
reason: 'GitHub must still be mirrored during the cutover',
);
});
test('pushManual also mirrors to both backends', () async {
// pushManual is a separate path from push(); it needs its own proof
// that Firebase became primary rather than being skipped.
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final (:httpClient, :putCalls) = _mockGitHub();
final firebase = _fakeFirebase();
await _service(
httpClient: httpClient,
firebaseFactory: () async => firebase.client,
).pushManual(
Record(
id: 'manual:x',
fields: {
'payload': (
{'kind': 'manual_workout', 'date': '2026-07-13', 'cost': '40'},
Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'),
),
},
),
);
expect(
firebase.puts.any((p) => p.contains('screen-locker-sync')),
isTrue,
);
expect(putCalls, isNotEmpty);
});
test('reads merge both backends', () async {
// Read-only path: a workout logged against either backend must count.
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final firebase = _fakeFirebase();
final (:httpClient, :putCalls) = _mockGitHub();
final payloads = await _service(
httpClient: httpClient,
firebaseFactory: () async => firebase.client,
).readMergedWorkoutPayloads();
expect(payloads, isEmpty);
});
});
}