From 25ee5ca9ad7cb1e7d43c6223e33308dd6d1632c2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 16:02:39 +0100 Subject: [PATCH] Add sandbox GPS replay and speed setting Synthetic 4 km loop generated in Dart, replayed at 1x/10x/60x; main imports the dufs login and retries pending uploads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../workout_app/lib/main.dart | 11 +++ .../workout_app/lib/sandbox/sandbox.dart | 10 +++ .../lib/sandbox/sandbox_run_replay.dart | 70 +++++++++++++++ .../lib/screens/settings_screen_sandbox.dart | 28 ++++++ .../lib/services/storage_service_sandbox.dart | 13 +++ .../test/sandbox/sandbox_run_replay_test.dart | 89 +++++++++++++++++++ .../screens/settings_screen_sandbox_test.dart | 22 +++++ .../storage_service_sandbox_test.dart | 7 ++ 8 files changed, 250 insertions(+) create mode 100644 stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart create mode 100644 stronglift_replacement/workout_app/test/sandbox/sandbox_run_replay_test.dart diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index c6445c6..5697e27 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -14,6 +14,8 @@ import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/lock_mode.dart'; import 'package:workout_app/services/pc_pairing.dart'; import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/run_upload_credentials.dart'; +import 'package:workout_app/services/run_uploader.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_device_id.dart'; import 'package:workout_app/services/sync_service.dart'; @@ -80,7 +82,14 @@ void main(List args) async { Sandbox.defaultRestSecs, ); SandboxLog.event('rest override loaded', {'secs': Sandbox.restSecs}); + Sandbox.runReplayFactor = await StorageService.instance + .getSandboxReplayFactor(Sandbox.defaultReplayFactor); } + // Run uploads: absorb a login pushed by scripts/provision_run_upload.sh + // (the sandbox never uploads, so it never imports one), then retry any run + // still pending from an earlier launch. + if (!Sandbox.enabled) await RunCredentialStore().importProvisionFile(); + unawaited(RunUploader.instance.uploadPending()); // Before the UI exists: a service left over from a crashed or force-stopped // workout must not sit in the status bar counting down nothing. await stopStaleBreakService( @@ -167,6 +176,8 @@ class _WorkoutAppState extends State with WidgetsBindingObserver { unawaited(HttpServerService.instance.stop()); case AppLifecycleState.resumed: unawaited(HttpServerService.instance.start()); + // The nearest thing to "connectivity came back" without a plugin. + unawaited(RunUploader.instance.uploadPending()); case AppLifecycleState.inactive: case AppLifecycleState.hidden: break; diff --git a/stronglift_replacement/workout_app/lib/sandbox/sandbox.dart b/stronglift_replacement/workout_app/lib/sandbox/sandbox.dart index 657484b..e93be91 100644 --- a/stronglift_replacement/workout_app/lib/sandbox/sandbox.dart +++ b/stronglift_replacement/workout_app/lib/sandbox/sandbox.dart @@ -37,6 +37,16 @@ abstract final class Sandbox { /// the Settings screen, loaded by `main`. static int restSecs = defaultRestSecs; + /// Replay speed-ups the Settings control offers. + static const List replayFactors = [1, 10, 60]; + + /// Replay speed-up used when no override has been saved yet. + static const int defaultReplayFactor = 60; + + /// How many times faster than real time the sandbox replays a GPS run; + /// persisted by the Settings screen, loaded by `main`. + static int runReplayFactor = defaultReplayFactor; + /// Environment variable that turns the sandbox on where there is no /// flavor to ask — the Linux desktop build, run under Xvfb to check a /// change on the PC before it goes anywhere near the phone. diff --git a/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart b/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart new file mode 100644 index 0000000..70b42d8 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/sandbox/sandbox_run_replay.dart @@ -0,0 +1,70 @@ +/// The sandbox's GPS: a synthetic loop replayed faster than real time. +/// +/// There is no bundled asset (that would need a pubspec entry): the track is a +/// pure function of the start time and sport, so every replay gets its own +/// `RunTrack.id` and two replays never collide in the `runs` table. +library; + +import 'dart:math' as math; + +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/services/gps_filter.dart'; +import 'package:workout_app/services/position_source.dart'; +import 'package:workout_app/services/position_source_geolocator.dart'; +import 'package:workout_app/services/position_source_scripted.dart'; + +/// Length of the synthetic loop in metres. +const double kLoopLengthM = 4000; + +/// Seconds between replayed fixes (the real request interval). +const int kFixStepSeconds = 2; + +// A neutral spot: nobody's home, any tile server has it. +const double _centreLat = 50; +const double _centreLon = 10; + +/// Pace of the replay: 10 km/h for a run, a stroll for a walk (a run's speed +/// would trip the walking jump cap and credit nothing). +double replaySpeedMps(RunSport sport) => switch (sport) { + RunSport.running => 10 / 3.6, + RunSport.walking => 5 / 3.6, +}; + +/// Fixes for one lap of a [kLoopLengthM] circle starting at [start], one every +/// [kFixStepSeconds], 5 m accuracy. +List sandboxLoopFixes(DateTime start, RunSport sport) { + final radius = kLoopLengthM / (2 * math.pi); + final steps = (kLoopLengthM / replaySpeedMps(sport) / kFixStepSeconds) + .round(); + const metresPerDegree = 111194.9; + final lonScale = metresPerDegree * math.cos(_centreLat * math.pi / 180); + return [ + for (var i = 0; i <= steps; i++) + GpsFix( + time: start.add(Duration(seconds: i * kFixStepSeconds)), + latitude: + _centreLat + + radius * math.sin(2 * math.pi * i / steps) / metresPerDegree, + longitude: + _centreLon + + radius * (1 - math.cos(2 * math.pi * i / steps)) / lonScale, + accuracyM: 5, + ), + ]; +} + +/// The sandbox's [PositionSource]: the loop at [Sandbox.runReplayFactor] times +/// real speed, starting now. +PositionSource sandboxRunSource(RunSport sport, {DateTime? now}) => + ScriptedPositionSource( + sandboxLoopFixes(now ?? DateTime.now(), sport), + fixDelay: Duration( + microseconds: kFixStepSeconds * 1000000 ~/ Sandbox.runReplayFactor, + ), + ); + +/// The source a recording uses: the replay in the sandbox, the phone's GPS +/// otherwise. +PositionSource positionSourceFor(RunSport sport) => + Sandbox.enabled ? sandboxRunSource(sport) : GeolocatorPositionSource(); diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart index 634d238..56dc300 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart @@ -34,6 +34,7 @@ class _SandboxSectionState extends State<_SandboxSection> { Future _wipe() async { await StorageService.instance.wipeAll(); Sandbox.restSecs = Sandbox.defaultRestSecs; + Sandbox.runReplayFactor = Sandbox.defaultReplayFactor; _rest.text = '${Sandbox.restSecs}'; _report('Wiped — the sandbox is back to a fresh install.'); } @@ -60,6 +61,12 @@ class _SandboxSectionState extends State<_SandboxSection> { _report('Every rest now lasts $secs s.'); } + Future _saveReplay(int factor) async { + await StorageService.instance.setSandboxReplayFactor(factor); + setState(() => Sandbox.runReplayFactor = factor); + _report('GPS runs replay at $factor×.'); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -118,6 +125,27 @@ class _SandboxSectionState extends State<_SandboxSection> { ), ], ), + const SizedBox(height: 12), + Row( + children: [ + const Padding( + padding: EdgeInsets.only(right: 12), + child: Icon(Icons.directions_run, semanticLabel: 'GPS replay'), + ), + Expanded( + child: SegmentedButton( + key: const Key('sandbox-replay-factor'), + showSelectedIcon: false, + segments: [ + for (final f in Sandbox.replayFactors) + ButtonSegment(value: f, label: Text('$f×')), + ], + selected: {Sandbox.runReplayFactor}, + onSelectionChanged: (s) => unawaited(_saveReplay(s.single)), + ), + ), + ], + ), if (_status != null) ...[ const SizedBox(height: 8), Text( diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart b/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart index c21043c..d3a41ee 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart @@ -8,6 +8,9 @@ part of 'storage_service.dart'; /// Settings key the sandbox's rest length is stored under. const String kSandboxRestSecsKey = 'sandbox_rest_secs'; +/// Settings key the sandbox's GPS replay speed-up is stored under. +const String kSandboxReplayFactorKey = 'sandbox_run_replay_x'; + /// Sandbox-only maintenance on the (sandbox's own) database. /// /// Nothing here checks `Sandbox.enabled`: the Settings screen only offers @@ -54,4 +57,14 @@ extension StorageServiceSandbox on StorageService { /// Persists the rest override used by every sandbox break. Future setSandboxRestSecs(int secs) => _setSetting(kSandboxRestSecsKey, '$secs'); + + /// The saved GPS replay speed-up, or [fallback] when none has been saved. + Future getSandboxReplayFactor(int fallback) async { + final raw = await _getSetting(kSandboxReplayFactorKey); + return raw == null ? fallback : int.parse(raw); + } + + /// Persists the speed-up the sandbox replays its GPS run at. + Future setSandboxReplayFactor(int factor) => + _setSetting(kSandboxReplayFactorKey, '$factor'); } diff --git a/stronglift_replacement/workout_app/test/sandbox/sandbox_run_replay_test.dart b/stronglift_replacement/workout_app/test/sandbox/sandbox_run_replay_test.dart new file mode 100644 index 0000000..aae6ffe --- /dev/null +++ b/stronglift_replacement/workout_app/test/sandbox/sandbox_run_replay_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/sandbox/sandbox_run_replay.dart'; +import 'package:workout_app/services/gps_filter.dart'; +import 'package:workout_app/services/position_source_geolocator.dart'; +import 'package:workout_app/services/position_source_scripted.dart'; +import 'package:workout_app/services/run_math.dart'; + +void main() { + tearDown(() { + Sandbox.enabled = false; + Sandbox.runReplayFactor = Sandbox.defaultReplayFactor; + }); + + final start = DateTime.utc(2026, 11, 10, 8); + + for (final sport in RunSport.values) { + test('the ${sport.tcxName} loop is a legal ~4 km track for the filter', () { + final fixes = sandboxLoopFixes(start, sport); + final filter = GpsFilter(sport: sport); + for (final fix in fixes) { + expect(filter.add(fix), FixVerdict.accepted); + } + expect(filter.distanceM, closeTo(kLoopLengthM, 20)); + expect(movingSeconds(filter.points, sport), fixes.length * 2 - 2); + expect(fixes.first.time, start); + // A closed loop: the lap ends where it began. + expect( + haversineM( + fixes.first.latitude, + fixes.first.longitude, + fixes.last.latitude, + fixes.last.longitude, + ), + lessThan(5), + ); + }); + } + + test('a run laps at 10 km/h (24 minutes of fixes), a walk at 5 km/h', () { + final run = sandboxLoopFixes(start, RunSport.running); + final walk = sandboxLoopFixes(start, RunSport.walking); + expect(run.last.time.difference(start), const Duration(minutes: 24)); + expect(walk.last.time.difference(start), const Duration(minutes: 48)); + }); + + test('replay times follow the start, so two replays never share an id', () { + final a = sandboxLoopFixes(start, RunSport.running); + final b = sandboxLoopFixes( + start.add(const Duration(seconds: 1)), + RunSport.running, + ); + expect(RunTrack.idFor(a.first.time), isNot(RunTrack.idFor(b.first.time))); + }); + + test('the speed-up divides the 2 s fix interval', () async { + for (final (factor, micros) in [(1, 2000000), (10, 200000), (60, 33333)]) { + Sandbox.runReplayFactor = factor; + final source = sandboxRunSource(RunSport.running, now: start); + expect(source, isA()); + expect( + (source as ScriptedPositionSource).fixDelay, + Duration(microseconds: micros), + ); + } + }); + + test('the default speed-up replays a whole run in under a minute', () { + expect(Sandbox.runReplayFactor, 60); + final fixes = sandboxLoopFixes(start, RunSport.running).length; + expect(fixes * 2 / Sandbox.runReplayFactor, lessThan(60)); + expect( + sandboxLoopFixes(start, RunSport.walking).length * 2 / 60, + lessThan(60), + ); + expect(Sandbox.replayFactors, [1, 10, 60]); + }); + + test('the sandbox replays; the daily build reads the real GPS', () async { + Sandbox.enabled = true; + expect(positionSourceFor(RunSport.running), isA()); + Sandbox.enabled = false; + expect( + positionSourceFor(RunSport.running), + isA(), + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart index e673ac8..a0858fd 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart @@ -24,10 +24,12 @@ void main() { installFakeSecureStorage(); Sandbox.enabled = true; Sandbox.restSecs = Sandbox.defaultRestSecs; + Sandbox.runReplayFactor = Sandbox.defaultReplayFactor; }); tearDown(() { Sandbox.enabled = false; Sandbox.restSecs = Sandbox.defaultRestSecs; + Sandbox.runReplayFactor = Sandbox.defaultReplayFactor; }); Widget wrap() => MaterialApp( @@ -144,4 +146,24 @@ void main() { ); expect(saved, 45); }); + + testWidgets('the GPS replay speed is chosen, saved and wiped', ( + tester, + ) async { + await pump(tester); + final tenX = find.descendant( + of: find.byKey(const Key('sandbox-replay-factor')), + matching: find.text('10×'), + ); + await tapReal(tester, tenX); + expect(find.text('GPS runs replay at 10×.'), findsOneWidget); + expect(Sandbox.runReplayFactor, 10); + final saved = await tester.runAsync( + () => StorageService.instance.getSandboxReplayFactor(60), + ); + expect(saved, 10); + + await tapReal(tester, find.text('Wipe sandbox data')); + expect(Sandbox.runReplayFactor, Sandbox.defaultReplayFactor); + }); } diff --git a/stronglift_replacement/workout_app/test/services/storage_service_sandbox_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_sandbox_test.dart index cfbc6bb..521634e 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_sandbox_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_sandbox_test.dart @@ -57,4 +57,11 @@ void main() { await storage.setSandboxRestSecs(42); expect(await storage.getSandboxRestSecs(5), 42); }); + + test('replay factor round-trips and falls back when unset', () async { + final svc = StorageService.instance; + expect(await svc.getSandboxReplayFactor(60), 60); + await svc.setSandboxReplayFactor(10); + expect(await svc.getSandboxReplayFactor(60), 10); + }); }