From d0bb8d700e6653462d2adc9658b0ff2edf38068d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 18:13:26 +0100 Subject: [PATCH] Allow OSM tiles in the sandbox, log metres Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD --- .../workout_app/lib/sandbox/sandbox_http.dart | 24 +++- .../lib/screens/settings_screen_sandbox.dart | 2 +- .../lib/services/run_recorder.dart | 8 +- .../test/sandbox/sandbox_http_test.dart | 110 ++++++++++++++++-- .../test/services/run_recorder_test.dart | 30 +++++ 5 files changed, 161 insertions(+), 13 deletions(-) diff --git a/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart b/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart index 7b5f230..49e39f5 100644 --- a/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart +++ b/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart @@ -7,7 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:workout_app/services/pc_poke_wire.dart'; /// Makes every `dart:io` HTTP connection fail before a socket is opened -- -/// except the workout poke to the configured PC. +/// except the workout poke to the configured PC and OSM map tiles. /// /// Installed as `HttpOverrides.global` by `main` in the sandbox. Firebase and /// GitHub sync both go through `package:http`, whose IO client is built from @@ -19,6 +19,11 @@ import 'package:workout_app/services/pc_poke_wire.dart'; /// workout can time the PC round trip. The PC verifies the signed /// `"sandbox": true` and never writes or credits it. Firebase and GitHub are /// https on 443 and can never match. +/// +/// The other exception is `https://tile.openstreetmap.org/{z}/{x}/{y}.png` +/// (zoom 0-19) for the run map, under OSM's tile usage policy: the map sends +/// a real User-Agent, caches tiles on disk, never prefetches, and nothing else +/// in the app GETs that host. class SandboxHttpOverrides extends HttpOverrides { /// The PC host the poke may reach; set by `PcPairing` whenever the host is /// loaded or saved. Null means no exception at all. @@ -32,6 +37,19 @@ class SandboxHttpOverrides extends HttpOverrides { uri.port == kPcPokePort && uri.path == kPcPokePath; + /// The only tile host the sandbox may reach. + static const tileHost = 'tile.openstreetmap.org'; + + static final _tilePath = RegExp(r'^/(\d|1[0-9])/\d+/\d+\.png$'); + + /// Whether [uri] is an OSM raster tile (https, port 443, zoom <= 19). + static bool allowsTile(Uri uri) => + uri.scheme == 'https' && + uri.host == tileHost && + uri.port == 443 && + !uri.hasQuery && + _tilePath.hasMatch(uri.path); + @override HttpClient createHttpClient(SecurityContext? context) { return super.createHttpClient(context) @@ -42,6 +60,10 @@ class SandboxHttpOverrides extends HttpOverrides { ); return Socket.startConnect(uri.host, uri.port); } + if (proxyHost == null && allowsTile(uri)) { + debugPrint('WorkoutSandbox: allowing OSM tile ${uri.path}'); + return Socket.startConnect(uri.host, uri.port); + } debugPrint( 'WorkoutSandbox: refused network connection to ${uri.host} — the ' 'sandbox never talks to Firebase or GitHub.', 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 56dc300..353c70b 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart @@ -76,7 +76,7 @@ class _SandboxSectionState extends State<_SandboxSection> { const _SectionHeader('SANDBOX'), const SizedBox(height: 4), Text( - 'This install is the sandbox: its own data, no network (only the ' + 'This install is the sandbox: its own data, no sync (only the ' 'signed sandbox poke to the PC LINK address, which the PC never ' 'credits), no LAN server. Nothing here can reach the daily build.', style: TextStyle( diff --git a/stronglift_replacement/workout_app/lib/services/run_recorder.dart b/stronglift_replacement/workout_app/lib/services/run_recorder.dart index 501602f..9a12f33 100644 --- a/stronglift_replacement/workout_app/lib/services/run_recorder.dart +++ b/stronglift_replacement/workout_app/lib/services/run_recorder.dart @@ -102,7 +102,7 @@ class RunRecorder extends ChangeNotifier { if (_phase != RunPhase.recording) return; _filter!.breakSegment(); _phase = RunPhase.paused; - SandboxLog.event('run paused', {'km': distanceM}); + SandboxLog.event('run paused', {'m': distanceM}); notifyListeners(); } @@ -110,7 +110,7 @@ class RunRecorder extends ChangeNotifier { void resume() { if (_phase != RunPhase.paused) return; _phase = RunPhase.recording; - SandboxLog.event('run resumed', {'km': distanceM}); + SandboxLog.event('run resumed', {'m': distanceM}); notifyListeners(); } @@ -141,7 +141,7 @@ class RunRecorder extends ChangeNotifier { ); } SandboxLog.event('run finished', { - 'km': distanceM, + 'm': distanceM, 'secs': _moving, 'points': points.length, 'saved': track != null, @@ -223,7 +223,7 @@ class RunRecorder extends ChangeNotifier { 'finish the run to save what was captured.', level: 900, ); - SandboxLog.event('run stream ended', {'km': distanceM}); + SandboxLog.event('run stream ended', {'m': distanceM}); } @override diff --git a/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart b/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart index 263db89..8757b58 100644 --- a/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart +++ b/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart @@ -9,7 +9,8 @@ void main() { final printed = []; setUp(() { printed.clear(); - debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? ''); + debugPrint = (String? message, {int? wrapWidth}) => + printed.add(message ?? ''); }); tearDown(() { debugPrint = debugPrintThrottled; @@ -24,7 +25,10 @@ void main() { () async => client.getUrl(Uri.parse('http://firebase.example.invalid/x')), throwsA(isA()), ); - expect(printed.single, contains('refused network connection to firebase.example.invalid')); + expect( + printed.single, + contains('refused network connection to firebase.example.invalid'), + ); client.close(force: true); }); @@ -54,9 +58,8 @@ void main() { SandboxHttpOverrides.pokeHost = host; final client = SandboxHttpOverrides().createHttpClient(null); await expectLater( - () async => client.postUrl( - Uri.parse('https://$host:$kPcPokePort$kPcPokePath'), - ), + () async => + client.postUrl(Uri.parse('https://$host:$kPcPokePort$kPcPokePath')), throwsA(isA()), ); expect(printed.single, contains('refused network connection to $host')); @@ -72,7 +75,10 @@ void main() { ), throwsA(isA()), ); - expect(printed.single, contains('refused network connection to 10.0.0.9')); + expect( + printed.single, + contains('refused network connection to 10.0.0.9'), + ); client.close(force: true); }); @@ -92,8 +98,98 @@ void main() { if (outcome is SocketException) { expect(outcome.message, isNot(contains('sandbox'))); } - expect(printed.first, contains('allowing the PC poke to $host:$kPcPokePort')); + expect( + printed.first, + contains('allowing the PC poke to $host:$kPcPokePort'), + ); client.close(force: true); }); }); + + group('the OSM tile exception', () { + Uri tile([ + String path = '/17/70123/42650.png', + String host = 'tile.openstreetmap.org', + ]) => Uri.parse('https://$host$path'); + + test('allows https tiles on the one host, zoom 0 to 19', () { + for (final path in [ + '/0/0/0.png', + '/9/1/2.png', + '/17/70123/42650.png', + '/19/1/2.png', + ]) { + expect( + SandboxHttpOverrides.allowsTile(tile(path)), + isTrue, + reason: path, + ); + } + }); + + test('refuses everything else, including look-alike hosts', () { + for (final other in [ + tile('/20/1/2.png'), + tile('/17/1/2.png?x=1'), + tile('/17/1/2.jpg'), + tile('/'), + tile('/api/0.6/map'), + tile('/17/1/2.png', 'a.tile.openstreetmap.org'), + tile('/17/1/2.png', 'openstreetmap.org'), + tile('/17/1/2.png', 'tile.openstreetmap.org.evil.example'), + tile('/17/1/2.png').replace(scheme: 'http', port: 80), + tile('/17/1/2.png').replace(port: 8443), + Uri.parse('https://kuhy-cloud.duckdns.org/x.json'), + ]) { + expect( + SandboxHttpOverrides.allowsTile(other), + isFalse, + reason: '$other', + ); + } + }); + + test('a tile reaches the socket layer, not the sandbox refusal', () async { + final client = SandboxHttpOverrides().createHttpClient(null); + Object outcome = 'connected'; + try { + final request = await client.getUrl(tile()); + request.abort(); + } on SocketException catch (error) { + outcome = error; + } + if (outcome is SocketException) { + expect(outcome.message, isNot(contains('sandbox'))); + } + expect(printed.first, contains('allowing OSM tile /17/70123/42650.png')); + client.close(force: true); + }); + + test('kuhy-cloud and a tile-looking path elsewhere stay refused', () async { + final client = SandboxHttpOverrides().createHttpClient(null); + for (final uri in [ + Uri.parse('https://kuhy-cloud.duckdns.org/17/1/2.png'), + Uri.parse('https://kuhy-cloud.duckdns.org/x.json'), + ]) { + await expectLater( + () => client.getUrl(uri), + throwsA(isA()), + ); + } + expect(printed, hasLength(2)); + expect( + printed.first, + contains('refused network connection to kuhy-cloud.duckdns.org'), + ); + client.close(force: true); + }); + + test('the PC poke is unchanged next to the tile exception', () { + SandboxHttpOverrides.pokeHost = '127.0.0.1'; + final poke = Uri.parse('http://127.0.0.1:$kPcPokePort$kPcPokePath'); + expect(SandboxHttpOverrides.allows(poke), isTrue); + expect(SandboxHttpOverrides.allowsTile(poke), isFalse); + expect(SandboxHttpOverrides.allows(tile()), isFalse); + }); + }); } diff --git a/stronglift_replacement/workout_app/test/services/run_recorder_test.dart b/stronglift_replacement/workout_app/test/services/run_recorder_test.dart index e66e181..a25598b 100644 --- a/stronglift_replacement/workout_app/test/services/run_recorder_test.dart +++ b/stronglift_replacement/workout_app/test/services/run_recorder_test.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; 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/services/position_source_scripted.dart'; import 'package:workout_app/services/run_recorder.dart'; @@ -51,6 +53,34 @@ void main() { }, ); + test('the sandbox trace labels distance in metres, not km', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + final printed = []; + debugPrint = (message, {wrapWidth}) => printed.add(message ?? ''); + Sandbox.enabled = true; + addTearDown(() { + debugPrint = debugPrintThrottled; + Sandbox.enabled = false; + }); + final recorder = RunRecorder( + sourceFor: (_) => ScriptedPositionSource(straightRun(340)), + ); + await recorder.start(); + await pumpEventQueue(); + await recorder.finish(); + final lines = printed.where( + (l) => l.contains('run stream ended') || l.contains('run finished'), + ); + expect(lines, hasLength(2)); + expect( + lines.first, + startsWith('WorkoutSandbox: run stream ended {m: 20'), + ); + expect(lines.last, startsWith('WorkoutSandbox: run finished {m: 20')); + expect(lines.join(), isNot(contains('km:'))); + recorder.dispose(); + }); + test('a stream closing after finish is ignored', () async { final source = ManualPositionSource(); final recorder = _recorder(source);