diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 966a1cb..bd8342f 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/services/offline_map_caching_provider.dart b/lib/services/offline_map_caching_provider.dart index 80c7126..f1f745a 100644 --- a/lib/services/offline_map_caching_provider.dart +++ b/lib/services/offline_map_caching_provider.dart @@ -1,19 +1,15 @@ -import 'dart:typed_data'; - import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; import 'offline_tile_cache_service.dart'; -import 'tile_sharing_service.dart'; -/// A [MapCachingProvider] that checks the offline AVIF tile cache (and -/// optionally peers) before falling through to the built-in cache. +/// A [MapCachingProvider] that checks the offline AVIF tile cache before +/// falling through to the built-in cache/network path. /// /// This allows preloaded tiles to be served during normal map browsing. class OfflineMapCachingProvider implements MapCachingProvider { final MapCachingProvider _delegate; final OfflineTileCacheService _cache = OfflineTileCacheService.instance; - final TileSharingService _sharing = TileSharingService.instance; 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) @@ -78,9 +52,54 @@ class OfflineMapCachingProvider implements MapCachingProvider { 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. 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 = [ RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'), RegExp(r'/(\d+)/(\d+)/(\d+)$'), @@ -101,7 +120,53 @@ class OfflineMapCachingProvider implements MapCachingProvider { /// Extract a URL template from a concrete URL by replacing coordinates. 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.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( RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'), (m) => '/{z}/{x}/{y}${m.group(4) ?? ''}', diff --git a/test/services/offline_map_caching_provider_test.dart b/test/services/offline_map_caching_provider_test.dart new file mode 100644 index 0000000..9b8d3fa --- /dev/null +++ b/test/services/offline_map_caching_provider_test.dart @@ -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}', + ); + }); +}