diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 8f7d81c4..ecea43a0 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import '../services/service_policy.dart'; + /// A specific tile type within a provider class TileType { final String id; @@ -76,6 +78,14 @@ class TileType { /// Check if this tile type needs an API key bool get requiresApiKey => urlTemplate.contains('{api_key}'); + /// Whether this tile server's usage policy permits offline/bulk downloading. + /// Resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => + ServicePolicyResolver.resolve(urlTemplate).allowsOfflineDownload; + + /// The service policy that applies to this tile type's server. + ServicePolicy get servicePolicy => ServicePolicyResolver.resolve(urlTemplate); + Map toJson() => { 'id': id, 'name': name, diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 723c16ba..9f51d58d 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -7,6 +7,7 @@ import 'package:xml/xml.dart'; import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; +import '../service_policy.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). @@ -56,28 +57,36 @@ Future> _fetchFromOsmApi({ try { debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); debugPrint('[fetchOsmApiNodes] URL: $url'); - - final response = await http.get(Uri.parse(url)); - + + // Enforce max 2 concurrent download threads per OSM API usage policy + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + final http.Response response; + try { + response = await http.get(Uri.parse(url)); + } finally { + ServiceRateLimiter.release(ServiceType.osmEditingApi); + } + if (response.statusCode != 200) { debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); throw Exception('OSM API error: ${response.statusCode} - ${response.body}'); } - + // Parse XML response final document = XmlDocument.parse(response.body); final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults); - + if (nodes.isNotEmpty) { debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes'); } - + // Don't report success here - let the top level handle it return nodes; - + } catch (e) { debugPrint('[fetchOsmApiNodes] Exception: $e'); - + // Don't report status here - let the top level handle it rethrow; // Re-throw to let caller handle } diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 95e46ca6..4facd5fd 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -5,13 +5,29 @@ import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; +import 'service_policy.dart'; + +/// Cached search result with expiry. +class _CachedResult { + final List results; + final DateTime cachedAt; + + _CachedResult(this.results) : cachedAt = DateTime.now(); + + bool get isExpired => + DateTime.now().difference(cachedAt) > const Duration(minutes: 5); +} class SearchService { static const String _baseUrl = 'https://nominatim.openstreetmap.org'; static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; static const int _maxResults = 5; static const Duration _timeout = Duration(seconds: 10); - + + /// Client-side result cache, keyed by normalized query + viewbox. + /// Required by Nominatim usage policy. + final Map _resultCache = {}; + /// Search for places using Nominatim geocoding service Future> search(String query, {LatLngBounds? viewbox}) async { if (query.trim().isEmpty) { @@ -27,23 +43,23 @@ class SearchService { // Otherwise, use Nominatim API return await _searchNominatim(query.trim(), viewbox: viewbox); } - + /// Try to parse various coordinate formats SearchResult? _tryParseCoordinates(String query) { // Remove common separators and normalize final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim(); final parts = normalized.split(RegExp(r'\s+')); - + if (parts.length != 2) return null; - + final lat = double.tryParse(parts[0]); final lon = double.tryParse(parts[1]); - + if (lat == null || lon == null) return null; - + // Basic validation for Earth coordinates if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; - + return SearchResult( displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}', coordinates: LatLng(lat, lon), @@ -51,9 +67,23 @@ class SearchService { type: 'point', ); } - - /// Search using Nominatim API + + /// Search using Nominatim API with rate limiting and result caching. + /// + /// Nominatim usage policy requires: + /// - Max 1 request per second + /// - Client-side result caching + /// - No auto-complete / typeahead Future> _searchNominatim(String query, {LatLngBounds? viewbox}) async { + final cacheKey = _buildCacheKey(query, viewbox); + + // Check cache first (Nominatim policy requires client-side caching) + final cached = _resultCache[cacheKey]; + if (cached != null && !cached.isExpired) { + debugPrint('[SearchService] Cache hit for "$query"'); + return cached.results; + } + final params = { 'q': query, 'format': 'json', @@ -84,32 +114,65 @@ class SearchService { } final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params); - + debugPrint('[SearchService] Searching Nominatim: $uri'); - + try { + // Rate limit: max 1 request/sec per Nominatim policy + await ServiceRateLimiter.acquire(ServiceType.nominatim); + final response = await http.get( uri, headers: { 'User-Agent': _userAgent, }, ).timeout(_timeout); - + + ServiceRateLimiter.release(ServiceType.nominatim); + if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); } - + final List jsonResults = json.decode(response.body); final results = jsonResults .map((json) => SearchResult.fromNominatim(json as Map)) .toList(); - + + // Cache the results + _resultCache[cacheKey] = _CachedResult(results); + _pruneCache(); + debugPrint('[SearchService] Found ${results.length} results'); return results; - + } catch (e) { + // Release the semaphore on error too + ServiceRateLimiter.release(ServiceType.nominatim); debugPrint('[SearchService] Search failed: $e'); throw Exception('Search failed: $e'); } } -} \ No newline at end of file + + /// Build a cache key from the query and viewbox. + String _buildCacheKey(String query, LatLngBounds? viewbox) { + final normalizedQuery = query.trim().toLowerCase(); + if (viewbox == null) return normalizedQuery; + // Round viewbox to 1 decimal place to group nearby viewboxes + double round1(double v) => (v * 10).round() / 10; + return '$normalizedQuery|${round1(viewbox.west)},${round1(viewbox.south)},${round1(viewbox.east)},${round1(viewbox.north)}'; + } + + /// Remove expired entries and limit cache size. + void _pruneCache() { + _resultCache.removeWhere((_, cached) => cached.isExpired); + // Limit cache to 50 entries to prevent unbounded growth + if (_resultCache.length > 50) { + final sortedKeys = _resultCache.keys.toList() + ..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt)); + for (final key in sortedKeys.take(_resultCache.length - 50)) { + _resultCache.remove(key); + } + } + } +} diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart new file mode 100644 index 00000000..f05d0770 --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,363 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Identifies the type of external service being accessed. +/// Used by [ServicePolicyResolver] to determine the correct compliance policy. +enum ServiceType { + // OSMF official services + osmEditingApi, // api.openstreetmap.org — editing & data queries + osmTileServer, // tile.openstreetmap.org — raster tiles + nominatim, // nominatim.openstreetmap.org — geocoding + overpass, // overpass-api.de — read-only data queries + tagInfo, // taginfo.openstreetmap.org — tag metadata + + // Third-party tile services + bingTiles, // *.tiles.virtualearth.net + mapboxTiles, // api.mapbox.com + + // Everything else + custom, // user's own infrastructure / unknown +} + +/// Defines the compliance rules for a specific service. +/// +/// Each policy captures the rate limits, caching requirements, offline +/// permissions, and attribution obligations mandated by the service operator. +/// When the app talks to official OSMF infrastructure the strict policies +/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom] +/// provides permissive defaults. +class ServicePolicy { + /// Max concurrent HTTP connections to this service. + /// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114). + final int maxConcurrentRequests; + + /// Minimum interval between consecutive requests. Null means no rate limit. + final Duration? minRequestInterval; + + /// Whether this endpoint permits offline/bulk downloading of tiles. + final bool allowsOfflineDownload; + + /// Whether the client must cache responses (e.g., Nominatim policy). + final bool requiresClientCaching; + + /// Minimum cache TTL to enforce regardless of server headers. + /// Null means "use server-provided max-age as-is". + final Duration? minCacheTtl; + + /// License/attribution URL to display in the attribution dialog. + /// Null means no special attribution link is needed. + final String? attributionUrl; + + const ServicePolicy({ + this.maxConcurrentRequests = 8, + this.minRequestInterval, + this.allowsOfflineDownload = true, + this.requiresClientCaching = false, + this.minCacheTtl, + this.attributionUrl, + }); + + /// OSM editing API (api.openstreetmap.org) + /// Policy: max 2 concurrent download threads. + /// https://operations.osmfoundation.org/policies/api/ + const ServicePolicy.osmEditingApi() + : maxConcurrentRequests = 2, + minRequestInterval = null, + allowsOfflineDownload = true, // n/a for API + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// OSM tile server (tile.openstreetmap.org) + /// Policy: no offline/bulk downloading, min 7-day cache, must honor cache headers. + /// Concurrency managed by flutter_map's NetworkTileProvider. + /// https://operations.osmfoundation.org/policies/tiles/ + const ServicePolicy.osmTileServer() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = false, + requiresClientCaching = true, + minCacheTtl = const Duration(days: 7), + attributionUrl = 'https://www.openstreetmap.org/copyright'; + + /// Nominatim geocoding (nominatim.openstreetmap.org) + /// Policy: max 1 req/sec, single machine only, results must be cached. + /// https://operations.osmfoundation.org/policies/nominatim/ + const ServicePolicy.nominatim() + : maxConcurrentRequests = 1, + minRequestInterval = const Duration(seconds: 1), + allowsOfflineDownload = true, // n/a for geocoding + requiresClientCaching = true, + minCacheTtl = null, + attributionUrl = 'https://www.openstreetmap.org/copyright'; + + /// Overpass API (overpass-api.de) + /// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore. + const ServicePolicy.overpass() + : maxConcurrentRequests = 0, // managed by NodeDataManager + minRequestInterval = null, // managed by NodeDataManager + allowsOfflineDownload = true, // n/a for data queries + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// TagInfo API (taginfo.openstreetmap.org) + const ServicePolicy.tagInfo() + : maxConcurrentRequests = 2, + minRequestInterval = null, + allowsOfflineDownload = true, // n/a + requiresClientCaching = true, // already cached in NSIService + minCacheTtl = null, + attributionUrl = null; + + /// Bing Maps tiles (*.tiles.virtualearth.net) + const ServicePolicy.bingTiles() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, // check Bing ToS separately + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// Mapbox tiles (api.mapbox.com) + const ServicePolicy.mapboxTiles() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, // permitted with valid token + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// Custom/self-hosted service — permissive defaults. + const ServicePolicy.custom({ + int maxConcurrent = 8, + bool allowsOffline = true, + Duration? minInterval, + String? attribution, + }) : maxConcurrentRequests = maxConcurrent, + minRequestInterval = minInterval, + allowsOfflineDownload = allowsOffline, + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = attribution; + + @override + String toString() => 'ServicePolicy(' + 'maxConcurrent: $maxConcurrentRequests, ' + 'minInterval: $minRequestInterval, ' + 'offlineDownload: $allowsOfflineDownload, ' + 'clientCaching: $requiresClientCaching, ' + 'minCacheTtl: $minCacheTtl, ' + 'attributionUrl: $attributionUrl)'; +} + +/// Resolves URLs and tile providers to their applicable [ServicePolicy]. +/// +/// Built-in patterns cover all OSMF official services and common third-party +/// tile providers. Custom overrides can be registered for self-hosted endpoints +/// via [registerCustomPolicy]. +class ServicePolicyResolver { + /// Host → ServiceType mapping for known services. + static final Map _hostPatterns = { + 'api.openstreetmap.org': ServiceType.osmEditingApi, + 'api06.dev.openstreetmap.org': ServiceType.osmEditingApi, + 'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi, + 'tile.openstreetmap.org': ServiceType.osmTileServer, + 'nominatim.openstreetmap.org': ServiceType.nominatim, + 'overpass-api.de': ServiceType.overpass, + 'taginfo.openstreetmap.org': ServiceType.tagInfo, + 'tiles.virtualearth.net': ServiceType.bingTiles, + 'api.mapbox.com': ServiceType.mapboxTiles, + }; + + /// ServiceType → policy mapping. + static final Map _policies = { + ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(), + ServiceType.osmTileServer: const ServicePolicy.osmTileServer(), + ServiceType.nominatim: const ServicePolicy.nominatim(), + ServiceType.overpass: const ServicePolicy.overpass(), + ServiceType.tagInfo: const ServicePolicy.tagInfo(), + ServiceType.bingTiles: const ServicePolicy.bingTiles(), + ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(), + ServiceType.custom: const ServicePolicy(), + }; + + /// Custom host overrides registered at runtime (for self-hosted services). + static final Map _customOverrides = {}; + + /// Resolve a URL to its applicable [ServicePolicy]. + /// + /// Checks custom overrides first, then built-in host patterns. Falls back + /// to [ServicePolicy.custom] for unrecognized hosts. + static ServicePolicy resolve(String url) { + final host = _extractHost(url); + if (host == null) return const ServicePolicy(); + + // Check custom overrides first + for (final entry in _customOverrides.entries) { + if (host.contains(entry.key)) { + return entry.value; + } + } + + // Check built-in patterns (support subdomain matching) + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return _policies[entry.value] ?? const ServicePolicy(); + } + } + + return const ServicePolicy(); + } + + /// Resolve a URL to its [ServiceType]. + /// + /// Returns [ServiceType.custom] for unrecognized hosts. + static ServiceType resolveType(String url) { + final host = _extractHost(url); + if (host == null) return ServiceType.custom; + + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return entry.value; + } + } + + return ServiceType.custom; + } + + /// Register a custom policy override for a host pattern. + /// + /// Use this to configure self-hosted services: + /// ```dart + /// ServicePolicyResolver.registerCustomPolicy( + /// 'tiles.myserver.com', + /// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20), + /// ); + /// ``` + static void registerCustomPolicy(String hostPattern, ServicePolicy policy) { + _customOverrides[hostPattern] = policy; + } + + /// Remove a custom policy override. + static void removeCustomPolicy(String hostPattern) { + _customOverrides.remove(hostPattern); + } + + /// Clear all custom policy overrides (useful for testing). + static void clearCustomPolicies() { + _customOverrides.clear(); + } + + /// Extract the host from a URL or URL template. + static String? _extractHost(String url) { + // Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + // and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...' + try { + // Strip template variables from subdomain part for parsing + final cleaned = url + .replaceAll(RegExp(r'\{0_3\}'), '0') + .replaceAll(RegExp(r'\{1_4\}'), '1') + .replaceAll(RegExp(r'\{quadkey\}'), 'quadkey') + .replaceAll(RegExp(r'\{z\}'), '0') + .replaceAll(RegExp(r'\{x\}'), '0') + .replaceAll(RegExp(r'\{y\}'), '0') + .replaceAll(RegExp(r'\{api_key\}'), 'key'); + return Uri.parse(cleaned).host.toLowerCase(); + } catch (_) { + return null; + } + } +} + +/// Reusable per-service rate limiter and concurrency controller. +/// +/// Enforces the rate limits and concurrency constraints defined in each +/// service's [ServicePolicy]. Call [acquire] before making a request and +/// [release] after the request completes. +/// +/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0 +/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere +/// (flutter_map, PR #114) are passed through without blocking. +class ServiceRateLimiter { + /// Per-service timestamps of last completed request (for rate limiting). + static final Map _lastRequestTime = {}; + + /// Per-service concurrency semaphores. + static final Map _semaphores = {}; + + /// Acquire a slot: wait for rate limit compliance, then take a connection slot. + /// + /// Blocks if: + /// 1. The minimum interval between requests hasn't elapsed yet, or + /// 2. All concurrent connection slots are in use. + static Future acquire(ServiceType service) async { + final policy = ServicePolicyResolver._policies[service] ?? const ServicePolicy(); + + // Rate limit: wait if we sent a request too recently + if (policy.minRequestInterval != null) { + final lastTime = _lastRequestTime[service]; + if (lastTime != null) { + final elapsed = DateTime.now().difference(lastTime); + final remaining = policy.minRequestInterval! - elapsed; + if (remaining > Duration.zero) { + debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms'); + await Future.delayed(remaining); + } + } + } + + // Concurrency: acquire semaphore slot + if (policy.maxConcurrentRequests > 0) { + final semaphore = _semaphores.putIfAbsent( + service, + () => _Semaphore(policy.maxConcurrentRequests), + ); + await semaphore.acquire(); + } + + // Record request time + _lastRequestTime[service] = DateTime.now(); + } + + /// Release a connection slot after request completes. + static void release(ServiceType service) { + _semaphores[service]?.release(); + } + + /// Reset all rate limiter state (for testing). + @visibleForTesting + static void reset() { + _lastRequestTime.clear(); + _semaphores.clear(); + } +} + +/// Simple async counting semaphore for concurrency limiting. +class _Semaphore { + final int _maxCount; + int _currentCount = 0; + final List> _waiters = []; + + _Semaphore(this._maxCount); + + Future acquire() async { + if (_currentCount < _maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _waiters.add(completer); + await completer.future; + } + + void release() { + if (_waiters.isNotEmpty) { + final next = _waiters.removeAt(0); + next.complete(); + } else if (_currentCount > 0) { + _currentCount--; + } + } +} diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 0adbdb28..b7f8e211 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -262,16 +262,46 @@ class _DownloadAreaDialogState extends State { ElevatedButton( onPressed: isOfflineMode ? null : () async { try { + // Get current tile provider info + final appState = context.read(); + final selectedProvider = appState.selectedTileProvider; + final selectedTileType = appState.selectedTileType; + + // Check if the tile provider allows offline downloads + if (selectedTileType != null && !selectedTileType.allowsOfflineDownload) { + if (!context.mounted) return; + Navigator.pop(context); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.block, color: Colors.orange), + const SizedBox(width: 10), + Text(locService.t('download.title')), + ], + ), + content: Text( + 'The ${selectedProvider?.name ?? 'current tile'} server does not permit offline downloads. ' + 'Switch to a tile provider that allows offline use ' + '(e.g., Bing Maps, Mapbox, or a self-hosted tile server).', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.ok')), + ), + ], + ), + ); + return; + } + final id = DateTime.now().toIso8601String().replaceAll(':', '-'); final appDocDir = await OfflineAreaService().getOfflineAreaDir(); if (!context.mounted) return; final dir = "${appDocDir.path}/$id"; - // Get current tile provider info - final appState = context.read(); - final selectedProvider = appState.selectedTileProvider; - final selectedTileType = appState.selectedTileType; - // Fire and forget: don't await download, so dialog closes immediately // ignore: unawaited_futures OfflineAreaService().downloadArea( diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 0d9772f9..5cae6031 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; import '../../services/localization_service.dart'; +import '../../services/service_policy.dart'; import '../compass_indicator.dart'; import 'layer_selector_button.dart'; @@ -26,16 +28,47 @@ class MapOverlays extends StatelessWidget { this.onSearchPressed, }); - /// Show full attribution text in a dialog + /// Show full attribution text in a dialog with license link. void _showAttributionDialog(BuildContext context, String attribution) { final locService = LocalizationService.instance; + + // Get the license URL from the current tile provider's service policy + final appState = AppState.instance; + final tileType = appState.selectedTileType; + final attributionUrl = tileType?.servicePolicy.attributionUrl; + showDialog( context: context, builder: (context) => AlertDialog( title: Text(locService.t('mapTiles.attribution')), - content: SelectableText( - attribution, - style: const TextStyle(fontSize: 14), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + attribution, + style: const TextStyle(fontSize: 14), + ), + if (attributionUrl != null) ...[ + const SizedBox(height: 12), + GestureDetector( + onTap: () async { + final uri = Uri.parse(attributionUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + }, + child: Text( + attributionUrl, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ], ), actions: [ TextButton( diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart new file mode 100644 index 00000000..f5275982 --- /dev/null +++ b/test/services/service_policy_test.dart @@ -0,0 +1,349 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:deflock/services/service_policy.dart'; + +void main() { + group('ServicePolicyResolver', () { + setUp(() { + ServicePolicyResolver.clearCustomPolicies(); + }); + + group('resolveType', () { + test('resolves OSM editing API from production URL', () { + expect( + ServicePolicyResolver.resolveType('https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM editing API from sandbox URL', () { + expect( + ServicePolicyResolver.resolveType('https://api06.dev.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM editing API from dev URL', () { + expect( + ServicePolicyResolver.resolveType('https://master.apis.dev.openstreetmap.org/api/0.6/user/details'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM tile server from tile URL', () { + expect( + ServicePolicyResolver.resolveType('https://tile.openstreetmap.org/12/1234/5678.png'), + ServiceType.osmTileServer, + ); + }); + + test('resolves Nominatim from geocoding URL', () { + expect( + ServicePolicyResolver.resolveType('https://nominatim.openstreetmap.org/search?q=London'), + ServiceType.nominatim, + ); + }); + + test('resolves Overpass API', () { + expect( + ServicePolicyResolver.resolveType('https://overpass-api.de/api/interpreter'), + ServiceType.overpass, + ); + }); + + test('resolves TagInfo', () { + expect( + ServicePolicyResolver.resolveType('https://taginfo.openstreetmap.org/api/4/key/values'), + ServiceType.tagInfo, + ); + }); + + test('resolves Bing tiles from virtualearth URL', () { + expect( + ServicePolicyResolver.resolveType('https://ecn.t0.tiles.virtualearth.net/tiles/a12345.jpeg'), + ServiceType.bingTiles, + ); + }); + + test('resolves Mapbox tiles', () { + expect( + ServicePolicyResolver.resolveType('https://api.mapbox.com/v4/mapbox.satellite/12/1234/5678@2x.jpg90'), + ServiceType.mapboxTiles, + ); + }); + + test('returns custom for unknown host', () { + expect( + ServicePolicyResolver.resolveType('https://tiles.myserver.com/12/1234/5678.png'), + ServiceType.custom, + ); + }); + + test('returns custom for empty string', () { + expect( + ServicePolicyResolver.resolveType(''), + ServiceType.custom, + ); + }); + + test('returns custom for malformed URL', () { + expect( + ServicePolicyResolver.resolveType('not-a-url'), + ServiceType.custom, + ); + }); + }); + + group('resolve', () { + test('OSM tile server policy disallows offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, false); + }); + + test('OSM tile server policy requires 7-day min cache TTL', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.minCacheTtl, const Duration(days: 7)); + }); + + test('OSM tile server has attribution URL', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('Nominatim policy enforces 1-second rate limit', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.minRequestInterval, const Duration(seconds: 1)); + }); + + test('Nominatim policy requires client caching', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.requiresClientCaching, true); + }); + + test('Nominatim has attribution URL', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('OSM editing API allows max 2 concurrent requests', () { + final policy = ServicePolicyResolver.resolve( + 'https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4', + ); + expect(policy.maxConcurrentRequests, 2); + }); + + test('Bing tiles allow offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('Mapbox tiles allow offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('custom/unknown host gets permissive defaults', () { + final policy = ServicePolicyResolver.resolve( + 'https://tiles.myserver.com/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + expect(policy.minRequestInterval, isNull); + expect(policy.requiresClientCaching, false); + expect(policy.attributionUrl, isNull); + }); + }); + + group('resolve with URL templates', () { + test('handles {z}/{x}/{y} template variables', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, false); + }); + + test('handles {quadkey} template variable', () { + final policy = ServicePolicyResolver.resolve( + 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('handles {0_3} subdomain template', () { + final type = ServicePolicyResolver.resolveType( + 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg', + ); + expect(type, ServiceType.bingTiles); + }); + + test('handles {api_key} template variable', () { + final type = ServicePolicyResolver.resolveType( + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + ); + expect(type, ServiceType.mapboxTiles); + }); + }); + + group('custom policy overrides', () { + test('custom override takes precedence over built-in', () { + ServicePolicyResolver.registerCustomPolicy( + 'overpass-api.de', + const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true), + ); + + final policy = ServicePolicyResolver.resolve( + 'https://overpass-api.de/api/interpreter', + ); + expect(policy.maxConcurrentRequests, 20); + }); + + test('custom policy for self-hosted tiles allows offline', () { + ServicePolicyResolver.registerCustomPolicy( + 'tiles.myserver.com', + const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16), + ); + + final policy = ServicePolicyResolver.resolve( + 'https://tiles.myserver.com/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + expect(policy.maxConcurrentRequests, 16); + }); + + test('removing custom override restores built-in policy', () { + ServicePolicyResolver.registerCustomPolicy( + 'overpass-api.de', + const ServicePolicy.custom(maxConcurrent: 20), + ); + expect( + ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, + 20, + ); + + ServicePolicyResolver.removeCustomPolicy('overpass-api.de'); + // Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere) + expect( + ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, + 0, + ); + }); + + test('clearCustomPolicies removes all overrides', () { + ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1)); + ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2)); + + ServicePolicyResolver.clearCustomPolicies(); + + // Both should now return custom (default) policy + expect( + ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests, + 8, // default custom maxConcurrent + ); + }); + }); + }); + + group('ServiceRateLimiter', () { + setUp(() { + ServiceRateLimiter.reset(); + }); + + test('acquire and release work for editing API (2 concurrent)', () async { + // Should be able to acquire 2 slots without blocking + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + // Release both + ServiceRateLimiter.release(ServiceType.osmEditingApi); + ServiceRateLimiter.release(ServiceType.osmEditingApi); + }); + + test('Nominatim rate limiting delays rapid requests', () async { + final start = DateTime.now(); + + // First request should be immediate + await ServiceRateLimiter.acquire(ServiceType.nominatim); + ServiceRateLimiter.release(ServiceType.nominatim); + + // Second request should be delayed by ~1 second + await ServiceRateLimiter.acquire(ServiceType.nominatim); + ServiceRateLimiter.release(ServiceType.nominatim); + + final elapsed = DateTime.now().difference(start); + // Should have taken at least 900ms (allowing some tolerance) + expect(elapsed.inMilliseconds, greaterThanOrEqualTo(900)); + }); + + test('services with no rate limit pass through immediately', () async { + // Overpass has maxConcurrentRequests: 0, so acquire should be immediate + final start = DateTime.now(); + await ServiceRateLimiter.acquire(ServiceType.overpass); + ServiceRateLimiter.release(ServiceType.overpass); + await ServiceRateLimiter.acquire(ServiceType.overpass); + ServiceRateLimiter.release(ServiceType.overpass); + final elapsed = DateTime.now().difference(start); + expect(elapsed.inMilliseconds, lessThan(100)); + }); + }); + + group('ServicePolicy', () { + test('osmTileServer policy has correct values', () { + const policy = ServicePolicy.osmTileServer(); + expect(policy.allowsOfflineDownload, false); + expect(policy.minCacheTtl, const Duration(days: 7)); + expect(policy.requiresClientCaching, true); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + expect(policy.maxConcurrentRequests, 0); // managed by flutter_map + }); + + test('nominatim policy has correct values', () { + const policy = ServicePolicy.nominatim(); + expect(policy.minRequestInterval, const Duration(seconds: 1)); + expect(policy.maxConcurrentRequests, 1); + expect(policy.requiresClientCaching, true); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('osmEditingApi policy has correct values', () { + const policy = ServicePolicy.osmEditingApi(); + expect(policy.maxConcurrentRequests, 2); + expect(policy.minRequestInterval, isNull); + }); + + test('custom policy uses permissive defaults', () { + const policy = ServicePolicy(); + expect(policy.maxConcurrentRequests, 8); + expect(policy.allowsOfflineDownload, true); + expect(policy.minRequestInterval, isNull); + expect(policy.requiresClientCaching, false); + expect(policy.minCacheTtl, isNull); + expect(policy.attributionUrl, isNull); + }); + + test('custom policy accepts overrides', () { + const policy = ServicePolicy.custom( + maxConcurrent: 20, + allowsOffline: false, + attribution: 'https://example.com/license', + ); + expect(policy.maxConcurrentRequests, 20); + expect(policy.allowsOfflineDownload, false); + expect(policy.attributionUrl, 'https://example.com/license'); + }); + }); +}