fix: Prefer cached map tiles offline ref:

This commit is contained in:
Janez T
2026-04-08 09:46:31 +02:00
parent 4bd7f36b98
commit b863cda139
3 changed files with 137 additions and 34 deletions

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.355123"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408391">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="126.848356"> <testcase classname="fastlane.lanes" name="2: build_app" time="120.175289">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="300.595005"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="1035.867128">
</testcase> </testcase>

View File

@@ -1,19 +1,15 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'offline_tile_cache_service.dart'; import 'offline_tile_cache_service.dart';
import 'tile_sharing_service.dart';
/// A [MapCachingProvider] that checks the offline AVIF tile cache (and /// A [MapCachingProvider] that checks the offline AVIF tile cache before
/// optionally peers) before falling through to the built-in cache. /// falling through to the built-in cache/network path.
/// ///
/// This allows preloaded tiles to be served during normal map browsing. /// This allows preloaded tiles to be served during normal map browsing.
class OfflineMapCachingProvider implements MapCachingProvider { class OfflineMapCachingProvider implements MapCachingProvider {
final MapCachingProvider _delegate; final MapCachingProvider _delegate;
final OfflineTileCacheService _cache = OfflineTileCacheService.instance; final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final TileSharingService _sharing = TileSharingService.instance;
OfflineMapCachingProvider(this._delegate); OfflineMapCachingProvider(this._delegate);
@@ -40,28 +36,6 @@ class OfflineMapCachingProvider implements MapCachingProvider {
), ),
); );
} }
// Try peers
if (_sharing.discoveredPeers.isNotEmpty) {
final avifBytes = await _sharing.fetchFromAnyPeer(
styleHash, coords.z, coords.x, coords.y);
if (avifBytes != null) {
// Cache locally for next time
await _cache.putRawTile(
styleHash, coords.z, coords.x, coords.y, avifBytes);
final decoded = await OfflineTileCacheService.getTileAsPngStatic(avifBytes);
if (decoded != null) {
return (
bytes: decoded,
metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null,
etag: null,
),
);
}
}
}
} }
// Fall through to delegate (built-in cache) // Fall through to delegate (built-in cache)
@@ -78,9 +52,54 @@ class OfflineMapCachingProvider implements MapCachingProvider {
return _delegate.putTile(url: url, metadata: metadata, bytes: bytes); return _delegate.putTile(url: url, metadata: metadata, bytes: bytes);
} }
@visibleForTesting
static ({int z, int x, int y})? parseTileUrlForTesting(String url) {
final coords = _parseTileUrl(url);
if (coords == null) {
return null;
}
return (z: coords.z, x: coords.x, y: coords.y);
}
@visibleForTesting
static String extractUrlTemplateForTesting(String url) {
return _extractUrlTemplate(url);
}
/// Parse z/x/y from a tile URL. /// Parse z/x/y from a tile URL.
static _TileCoords? _parseTileUrl(String url) { static _TileCoords? _parseTileUrl(String url) {
// Match common patterns: /{z}/{x}/{y}.png, /tile/{z}/{y}/{x}, etc. final queryStyleMatch = RegExp(
r'(?:\?|&|/)(?:[^#]*&)?x=(\d+)&y=(\d+)&z=(\d+)(?:&|$)',
).firstMatch(url);
if (queryStyleMatch != null) {
return _TileCoords(
z: int.parse(queryStyleMatch.group(3)!),
x: int.parse(queryStyleMatch.group(1)!),
y: int.parse(queryStyleMatch.group(2)!),
);
}
final uri = Uri.tryParse(url);
if (uri != null) {
final z = int.tryParse(uri.queryParameters['z'] ?? '');
final x = int.tryParse(uri.queryParameters['x'] ?? '');
final y = int.tryParse(uri.queryParameters['y'] ?? '');
if (z != null && x != null && y != null) {
return _TileCoords(z: z, x: x, y: y);
}
}
// Match known path formats.
final yxTilePattern = RegExp(r'/tile/(\d+)/(\d+)/(\d+)$');
final yxTileMatch = yxTilePattern.firstMatch(url);
if (yxTileMatch != null) {
return _TileCoords(
z: int.parse(yxTileMatch.group(1)!),
x: int.parse(yxTileMatch.group(3)!),
y: int.parse(yxTileMatch.group(2)!),
);
}
final patterns = [ final patterns = [
RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'), RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'),
RegExp(r'/(\d+)/(\d+)/(\d+)$'), RegExp(r'/(\d+)/(\d+)/(\d+)$'),
@@ -101,7 +120,53 @@ class OfflineMapCachingProvider implements MapCachingProvider {
/// Extract a URL template from a concrete URL by replacing coordinates. /// Extract a URL template from a concrete URL by replacing coordinates.
static String _extractUrlTemplate(String url) { static String _extractUrlTemplate(String url) {
// Replace the last three numeric path segments with placeholders final queryStylePattern = RegExp(r'([?&])x=\d+&y=\d+&z=\d+');
if (queryStylePattern.hasMatch(url)) {
return url.replaceAllMapped(
queryStylePattern,
(match) => '${match.group(1)}x={x}&y={y}&z={z}',
);
}
final pathStyleQueryPattern = RegExp(r'(&)x=\d+&y=\d+&z=\d+');
if (pathStyleQueryPattern.hasMatch(url)) {
return url.replaceAllMapped(
pathStyleQueryPattern,
(match) => '${match.group(1)}x={x}&y={y}&z={z}',
);
}
final uri = Uri.tryParse(url);
if (uri != null) {
final query = Map<String, String>.from(uri.queryParameters);
var replacedQuery = false;
if (query.containsKey('z')) {
query['z'] = '{z}';
replacedQuery = true;
}
if (query.containsKey('x')) {
query['x'] = '{x}';
replacedQuery = true;
}
if (query.containsKey('y')) {
query['y'] = '{y}';
replacedQuery = true;
}
if (replacedQuery) {
return uri.replace(queryParameters: query).toString();
}
}
final yxTilePattern = RegExp(r'/tile/(\d+)/(\d+)/(\d+)$');
final yxTileMatch = yxTilePattern.firstMatch(url);
if (yxTileMatch != null) {
return url.replaceRange(
yxTileMatch.start,
yxTileMatch.end,
'/tile/{z}/{y}/{x}',
);
}
return url.replaceAllMapped( return url.replaceAllMapped(
RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'), RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'),
(m) => '/{z}/{x}/{y}${m.group(4) ?? ''}', (m) => '/{z}/{x}/{y}${m.group(4) ?? ''}',

View File

@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart';
void main() {
test('parses query-style tile URLs used by Google layers', () {
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
);
expect(coords, isNotNull);
expect(coords!.z, 13);
expect(coords.x, 4312);
expect(coords.y, 2810);
expect(
OfflineMapCachingProvider.extractUrlTemplateForTesting(
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
),
'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}',
);
});
test('parses Esri tile URLs and preserves y/x order in template', () {
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267',
);
expect(coords, isNotNull);
expect(coords!.z, 9);
expect(coords.x, 267);
expect(coords.y, 173);
expect(
OfflineMapCachingProvider.extractUrlTemplateForTesting(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267',
),
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
);
});
}