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 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:02:39 +01:00
parent 8a26387396
commit 25ee5ca9ad
8 changed files with 250 additions and 0 deletions

View File

@ -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<String> 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<WorkoutApp> 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;

View File

@ -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<int> 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.

View File

@ -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<GpsFix> 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();

View File

@ -34,6 +34,7 @@ class _SandboxSectionState extends State<_SandboxSection> {
Future<void> _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<void> _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<int>(
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(

View File

@ -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<void> setSandboxRestSecs(int secs) =>
_setSetting(kSandboxRestSecsKey, '$secs');
/// The saved GPS replay speed-up, or [fallback] when none has been saved.
Future<int> 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<void> setSandboxReplayFactor(int factor) =>
_setSetting(kSandboxReplayFactorKey, '$factor');
}

View File

@ -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<ScriptedPositionSource>());
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<ScriptedPositionSource>());
Sandbox.enabled = false;
expect(
positionSourceFor(RunSport.running),
isA<GeolocatorPositionSource>(),
);
});
}

View File

@ -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);
});
}

View File

@ -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);
});
}