mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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
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:
parent
e3cd68dc21
commit
eb4ff01e04
@ -100,7 +100,7 @@ repos:
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- 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
|
||||
- pydantic
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ requires-python = ">=3.10"
|
||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.3.0#subdirectory=crdt-sync",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.5.1#subdirectory=crdt-sync",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
bandit>=1.7.0
|
||||
codespell>=2.2.0
|
||||
coverage>=7.4.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.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
|
||||
mcp>=2,<3
|
||||
mypy>=1.8.0
|
||||
|
||||
@ -130,6 +130,13 @@ SYNC_PHONE_DEVICE_ID: str = "phone"
|
||||
# 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"
|
||||
|
||||
# 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 runs from the morning routine, which is started right after boot and
|
||||
|
||||
@ -28,10 +28,12 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import (
|
||||
FileSyncStateStore,
|
||||
GitHubSyncClient,
|
||||
GitHubSyncError,
|
||||
Hlc,
|
||||
Record,
|
||||
RemoteSyncError,
|
||||
RepoNotFoundError,
|
||||
sync_log,
|
||||
)
|
||||
@ -39,6 +41,7 @@ from crdt_sync import (
|
||||
from screen_locker._constants import (
|
||||
SYNC_REPO_NAME,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_STATE_FILE,
|
||||
SYNC_TIMEOUT_SECONDS,
|
||||
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._sync_retry import with_sync_retry
|
||||
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:
|
||||
from pathlib import Path
|
||||
@ -175,11 +178,13 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
||||
_logger.warning("Workouts NOT synced: %s", reason)
|
||||
return PushResult(pushed=False, record_count=0, reason=reason)
|
||||
|
||||
client = GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
client = remote_client(
|
||||
GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
try:
|
||||
with_sync_retry(
|
||||
@ -190,10 +195,11 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
||||
local_log=log,
|
||||
encode=_encode_log,
|
||||
decode=_decode_log,
|
||||
state_store=FileSyncStateStore(SYNC_STATE_FILE),
|
||||
),
|
||||
description="push PC workouts",
|
||||
)
|
||||
except GitHubSyncError as exc:
|
||||
except (GitHubSyncError, RemoteSyncError) as exc:
|
||||
reason = f"sync error: {exc}"
|
||||
# Report what actually happened. This used to assert "a 403 here means
|
||||
# the token lacks contents:write" for *every* failure, including a
|
||||
|
||||
@ -13,7 +13,18 @@ from __future__ import annotations
|
||||
import json
|
||||
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 (
|
||||
SYNC_PHONE_DEVICE_ID,
|
||||
@ -84,6 +95,32 @@ def _latest_payload(log_json: str) -> dict | None:
|
||||
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]:
|
||||
"""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
|
||||
|
||||
client = GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
client = remote_client(
|
||||
GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
try:
|
||||
text = with_sync_retry(
|
||||
@ -186,11 +225,13 @@ def pull_all_manual_records() -> list[tuple[str, dict]]:
|
||||
)
|
||||
return []
|
||||
|
||||
client = GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
client = remote_client(
|
||||
GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
try:
|
||||
devices = with_sync_retry(
|
||||
|
||||
@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
@ -34,7 +35,6 @@ from screen_locker.tests._locker_factories import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 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:
|
||||
"""Create a temporary log file path."""
|
||||
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")
|
||||
)
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from crdt_sync import GitHubSyncError, Hlc, Record
|
||||
from crdt_sync import ConfigError, GitHubSyncError, Hlc, Record
|
||||
import pytest
|
||||
|
||||
from screen_locker import _workout_sync
|
||||
@ -292,3 +293,51 @@ class TestPullAllManualRecords:
|
||||
result = _workout_sync.pull_all_manual_records()
|
||||
assert len(result) == 1
|
||||
assert result[0][1]["cost"] == "NEW"
|
||||
|
||||
|
||||
class TestRemoteClient:
|
||||
"""Which backend the workout pull reads from during the cutover."""
|
||||
|
||||
def test_stays_on_github_without_firebase_config(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> 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
|
||||
|
||||
@ -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
|
||||
@ -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))),
|
||||
);
|
||||
@ -8,7 +8,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:workout_app/models/manual_workout.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_state_factory.dart';
|
||||
|
||||
const _deviceId = 'phone';
|
||||
const _pathPrefix = 'screen-locker-sync/devices';
|
||||
@ -36,11 +38,29 @@ class WorkoutSyncService {
|
||||
this.owner = syncRepoOwner,
|
||||
this.repo = syncRepoName,
|
||||
http.Client? httpClient,
|
||||
this.firebaseFactory,
|
||||
this.stateStore,
|
||||
// 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;
|
||||
|
||||
/// 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.
|
||||
final String owner;
|
||||
|
||||
@ -55,12 +75,16 @@ class WorkoutSyncService {
|
||||
final settings = await SyncSettings.load();
|
||||
if (!settings.isConfigured) return;
|
||||
|
||||
final client = GitHubClient(
|
||||
final github = GitHubClient(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
token: settings.token,
|
||||
httpClient: _httpClient,
|
||||
);
|
||||
final firebase = await _openFirebase();
|
||||
final client = firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
try {
|
||||
const path = '$_pathPrefix/$_deviceId/$_logFilename';
|
||||
final existingText = await client.getFileText(path);
|
||||
@ -79,11 +103,16 @@ class WorkoutSyncService {
|
||||
localLog: localLog,
|
||||
encode: _encode,
|
||||
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) {
|
||||
debugPrint('WorkoutSyncService.push failed: $error');
|
||||
} finally {
|
||||
client.close();
|
||||
github.close();
|
||||
firebase?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,12 +123,16 @@ class WorkoutSyncService {
|
||||
final settings = await SyncSettings.load();
|
||||
if (!settings.isConfigured) return;
|
||||
|
||||
final client = GitHubClient(
|
||||
final github = GitHubClient(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
token: settings.token,
|
||||
httpClient: _httpClient,
|
||||
);
|
||||
final firebase = await _openFirebase();
|
||||
final client = firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
try {
|
||||
const path = '$_pathPrefix/$_deviceId/$_logFilename';
|
||||
final existingText = await client.getFileText(path);
|
||||
@ -114,11 +147,16 @@ class WorkoutSyncService {
|
||||
localLog: localLog,
|
||||
encode: _encode,
|
||||
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) {
|
||||
debugPrint('WorkoutSyncService.pushManual failed: $error');
|
||||
} finally {
|
||||
client.close();
|
||||
github.close();
|
||||
firebase?.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,16 +219,24 @@ class WorkoutSyncService {
|
||||
String token,
|
||||
String? kind,
|
||||
) async {
|
||||
final client = GitHubClient(
|
||||
final github = GitHubClient(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
token: token,
|
||||
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 {
|
||||
final merged = <String, Record>{};
|
||||
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;
|
||||
_mergeRecords(_decode(text), merged, kind: kind);
|
||||
}
|
||||
@ -198,7 +244,8 @@ class WorkoutSyncService {
|
||||
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
|
||||
.toList();
|
||||
} finally {
|
||||
client.close();
|
||||
github.close();
|
||||
firebase?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -141,11 +141,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.1.0"
|
||||
resolved-ref: "6b14e6d36d137d90851f00d1e282a2069c13d3a6"
|
||||
ref: "crdt_sync_dart-v0.6.0"
|
||||
resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
version: "0.6.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -12,7 +12,7 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.1.0
|
||||
ref: crdt_sync_dart-v0.6.0
|
||||
path: crdt_sync_dart
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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() {
|
||||
_cutoverTests();
|
||||
// installFakeSecureStorage touches the test binary messenger, which needs
|
||||
// the binding up first (widget tests get this for free via testWidgets).
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
@ -76,7 +104,7 @@ void main() {
|
||||
installFakeSecureStorage();
|
||||
final (:httpClient, :putCalls) = _mockGitHub();
|
||||
|
||||
await WorkoutSyncService(httpClient: httpClient).push(_session());
|
||||
await _service(httpClient: httpClient).push(_session());
|
||||
|
||||
expect(putCalls, isEmpty);
|
||||
});
|
||||
@ -88,14 +116,15 @@ void main() {
|
||||
);
|
||||
|
||||
final session = _session();
|
||||
await WorkoutSyncService(httpClient: httpClient).push(session);
|
||||
await _service(httpClient: httpClient).push(session);
|
||||
|
||||
expect(putCalls, hasLength(1));
|
||||
expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json');
|
||||
// Two writes: the log, then the revision published after it.
|
||||
expect(putCalls, hasLength(2));
|
||||
expect(_logPut(putCalls).path, 'screen-locker-sync/devices/phone/log.json');
|
||||
final pushedLog =
|
||||
jsonDecode(
|
||||
utf8.decode(
|
||||
base64.decode(putCalls.single.body['content'] as String),
|
||||
base64.decode(_logPut(putCalls).body['content'] as String),
|
||||
),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
@ -121,12 +150,12 @@ void main() {
|
||||
);
|
||||
|
||||
final session = _session();
|
||||
await WorkoutSyncService(httpClient: httpClient).push(session);
|
||||
await _service(httpClient: httpClient).push(session);
|
||||
|
||||
final pushedLog =
|
||||
jsonDecode(
|
||||
utf8.decode(
|
||||
base64.decode(putCalls.single.body['content'] as String),
|
||||
base64.decode(_logPut(putCalls).body['content'] as String),
|
||||
),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
@ -143,7 +172,7 @@ void main() {
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
WorkoutSyncService(httpClient: httpClient).push(_session()),
|
||||
_service(httpClient: httpClient).push(_session()),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
@ -161,7 +190,7 @@ void main() {
|
||||
test('pushManual does nothing without a token', () async {
|
||||
installFakeSecureStorage();
|
||||
final (:httpClient, :putCalls) = _mockGitHub();
|
||||
await WorkoutSyncService(
|
||||
await _service(
|
||||
httpClient: httpClient,
|
||||
).pushManual(_manual('manual:x', '40', 1000));
|
||||
expect(putCalls, isEmpty);
|
||||
@ -172,10 +201,10 @@ void main() {
|
||||
final (:httpClient, :putCalls) = _mockGitHub(
|
||||
contentResponses: {'screen-locker-sync/devices': _response(200, [])},
|
||||
);
|
||||
await WorkoutSyncService(
|
||||
await _service(
|
||||
httpClient: httpClient,
|
||||
).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 {
|
||||
@ -189,13 +218,13 @@ void main() {
|
||||
'screen-locker-sync/devices/phone/log.json': _fileContaining(existing),
|
||||
},
|
||||
);
|
||||
await WorkoutSyncService(
|
||||
await _service(
|
||||
httpClient: httpClient,
|
||||
).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000));
|
||||
final pushedLog =
|
||||
jsonDecode(
|
||||
utf8.decode(
|
||||
base64.decode(putCalls.single.body['content'] as String),
|
||||
base64.decode(_logPut(putCalls).body['content'] as String),
|
||||
),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
@ -211,7 +240,7 @@ void main() {
|
||||
(request) async => http.Response('', 500),
|
||||
);
|
||||
await expectLater(
|
||||
WorkoutSyncService(
|
||||
_service(
|
||||
httpClient: httpClient,
|
||||
).pushManual(_manual('manual:x', '40', 1000)),
|
||||
completes,
|
||||
@ -222,7 +251,7 @@ void main() {
|
||||
installFakeSecureStorage();
|
||||
final (:httpClient, putCalls: _) = _mockGitHub();
|
||||
expect(
|
||||
await WorkoutSyncService(
|
||||
await _service(
|
||||
httpClient: httpClient,
|
||||
).readMergedManualPayloads(),
|
||||
isEmpty,
|
||||
@ -234,12 +263,17 @@ void main() {
|
||||
final session = Record(
|
||||
id: 's',
|
||||
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(
|
||||
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({
|
||||
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
|
||||
@ -298,7 +332,7 @@ void main() {
|
||||
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
||||
},
|
||||
);
|
||||
final service = WorkoutSyncService(httpClient: httpClient);
|
||||
final service = _service(httpClient: httpClient);
|
||||
|
||||
final all = await service.readMergedWorkoutPayloads();
|
||||
expect(all, hasLength(2));
|
||||
@ -308,37 +342,39 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('readMergedManualPayloads still excludes runs (budget stays manual)',
|
||||
() async {
|
||||
// A verified run must never consume the manual self-report budget.
|
||||
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
||||
final run = Record(
|
||||
id: 'runnerup_verified:2026-07-13',
|
||||
fields: {
|
||||
'payload': (
|
||||
{'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(),
|
||||
'runnerup_verified:2026-07-13': run.toJson(),
|
||||
});
|
||||
final (:httpClient, putCalls: _) = _mockGitHub(
|
||||
contentResponses: {
|
||||
'screen-locker-sync/devices': _response(200, [
|
||||
{'name': 'pc'},
|
||||
]),
|
||||
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
||||
},
|
||||
);
|
||||
final manuals = await WorkoutSyncService(
|
||||
httpClient: httpClient,
|
||||
).readMergedManualPayloads();
|
||||
expect(manuals, hasLength(1));
|
||||
expect(manuals.single['kind'], 'manual_workout');
|
||||
});
|
||||
test(
|
||||
'readMergedManualPayloads still excludes runs (budget stays manual)',
|
||||
() async {
|
||||
// A verified run must never consume the manual self-report budget.
|
||||
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
||||
final run = Record(
|
||||
id: 'runnerup_verified:2026-07-13',
|
||||
fields: {
|
||||
'payload': (
|
||||
{'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(),
|
||||
'runnerup_verified:2026-07-13': run.toJson(),
|
||||
});
|
||||
final (:httpClient, putCalls: _) = _mockGitHub(
|
||||
contentResponses: {
|
||||
'screen-locker-sync/devices': _response(200, [
|
||||
{'name': 'pc'},
|
||||
]),
|
||||
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
|
||||
},
|
||||
);
|
||||
final manuals = await WorkoutSyncService(
|
||||
httpClient: httpClient,
|
||||
).readMergedManualPayloads();
|
||||
expect(manuals, hasLength(1));
|
||||
expect(manuals.single['kind'], 'manual_workout');
|
||||
},
|
||||
);
|
||||
|
||||
test('readMergedManualPayloads swallows a sync error', () async {
|
||||
installFakeSecureStorage(initial: {'sync.token': 'tok'});
|
||||
@ -346,7 +382,7 @@ void main() {
|
||||
(request) async => http.Response('', 500),
|
||||
);
|
||||
expect(
|
||||
await WorkoutSyncService(
|
||||
await _service(
|
||||
httpClient: httpClient,
|
||||
).readMergedManualPayloads(),
|
||||
isEmpty,
|
||||
@ -375,9 +411,12 @@ void main() {
|
||||
return http.Response('Bad credentials', 401);
|
||||
}
|
||||
if (req.url.path.endsWith('screen-locker-sync/devices')) {
|
||||
return http.Response(jsonEncode([
|
||||
{'name': 'pc', 'type': 'dir'},
|
||||
]), 200);
|
||||
return http.Response(
|
||||
jsonEncode([
|
||||
{'name': 'pc', 'type': 'dir'},
|
||||
]),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
@ -414,8 +453,109 @@ void main() {
|
||||
);
|
||||
|
||||
expect(
|
||||
await WorkoutSyncService(httpClient: httpClient).readMergedManualPayloads(),
|
||||
await _service(httpClient: httpClient).readMergedManualPayloads(),
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user