diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 47b41da1..128fb521 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -144,7 +144,9 @@ "offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.", "areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.", "downloadStarted": "Download gestartet! Lade Kacheln und Knoten...", - "downloadFailed": "Download konnte nicht gestartet werden: {}" + "downloadFailed": "Download konnte nicht gestartet werden: {}", + "offlineNotPermitted": "Der {}-Server erlaubt keine Offline-Downloads. Wechseln Sie zu einem Kachelanbieter, der Offline-Nutzung unterstützt (z. B. Bing Maps, Mapbox oder ein selbst gehosteter Kachelserver).", + "currentTileProvider": "aktuelle Kachel" }, "downloadStarted": { "title": "Download gestartet", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index fa62994e..f01fdb89 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.", "areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.", "downloadStarted": "Download started! Fetching tiles and nodes...", - "downloadFailed": "Failed to start download: {}" + "downloadFailed": "Failed to start download: {}", + "offlineNotPermitted": "The {} 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).", + "currentTileProvider": "current tile" }, "downloadStarted": { "title": "Download Started", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 8cfe386e..343b3fb8 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.", "areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.", "downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...", - "downloadFailed": "Error al iniciar la descarga: {}" + "downloadFailed": "Error al iniciar la descarga: {}", + "offlineNotPermitted": "El servidor {} no permite descargas sin conexión. Cambie a un proveedor de mosaicos que permita el uso sin conexión (p. ej., Bing Maps, Mapbox o un servidor de mosaicos propio).", + "currentTileProvider": "mosaico actual" }, "downloadStarted": { "title": "Descarga Iniciada", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 2dcb8951..5faa2145 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.", "areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.", "downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...", - "downloadFailed": "Échec du démarrage du téléchargement: {}" + "downloadFailed": "Échec du démarrage du téléchargement: {}", + "offlineNotPermitted": "Le serveur {} ne permet pas les téléchargements hors ligne. Passez à un fournisseur de tuiles qui autorise l'utilisation hors ligne (par ex., Bing Maps, Mapbox ou un serveur de tuiles auto-hébergé).", + "currentTileProvider": "tuile actuelle" }, "downloadStarted": { "title": "Téléchargement Démarré", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index c61fe7f1..2d4fba9f 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.", "areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.", "downloadStarted": "Download avviato! Recupero tile e nodi...", - "downloadFailed": "Impossibile avviare il download: {}" + "downloadFailed": "Impossibile avviare il download: {}", + "offlineNotPermitted": "Il server {} non consente i download offline. Passa a un fornitore di tile che consenta l'uso offline (ad es., Bing Maps, Mapbox o un server di tile auto-ospitato).", + "currentTileProvider": "tile attuale" }, "downloadStarted": { "title": "Download Avviato", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 558cdbad..d21bd0b7 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Downloads uitgeschakeld in offline modus. Schakel offline modus uit om nieuwe gebieden te downloaden.", "areaTooBigMessage": "Zoom in tot ten minste niveau {} om offline gebieden te downloaden. Grote gebied downloads kunnen ervoor zorgen dat de app niet meer reageert.", "downloadStarted": "Download gestart! Tiles en nodes ophalen...", - "downloadFailed": "Download starten mislukt: {}" + "downloadFailed": "Download starten mislukt: {}", + "offlineNotPermitted": "De {}-server staat geen offline downloads toe. Schakel over naar een tegelserver die offline gebruik toestaat (bijv. Bing Maps, Mapbox of een zelf gehoste tegelserver).", + "currentTileProvider": "huidige tegel" }, "downloadStarted": { "title": "Download Gestart", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 3513368e..86626d9d 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Pobieranie wyłączone w trybie offline. Wyłącz tryb offline, aby pobierać nowe obszary.", "areaTooBigMessage": "Przybliż do co najmniej poziomu {}, aby pobierać obszary offline. Duże pobieranie obszarów może sprawić, że aplikacja przestanie odpowiadać.", "downloadStarted": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...", - "downloadFailed": "Nie udało się rozpocząć pobierania: {}" + "downloadFailed": "Nie udało się rozpocząć pobierania: {}", + "offlineNotPermitted": "Serwer {} nie zezwala na pobieranie offline. Przełącz się na dostawcę kafelków, który obsługuje tryb offline (np. Bing Maps, Mapbox lub samodzielnie hostowany serwer kafelków).", + "currentTileProvider": "bieżący kafelek" }, "downloadStarted": { "title": "Pobieranie Rozpoczęte", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 38e611b7..05ec7e5b 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.", "areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.", "downloadStarted": "Download iniciado! Buscando tiles e nós...", - "downloadFailed": "Falha ao iniciar o download: {}" + "downloadFailed": "Falha ao iniciar o download: {}", + "offlineNotPermitted": "O servidor {} não permite downloads offline. Mude para um provedor de tiles que permita uso offline (por ex., Bing Maps, Mapbox ou um servidor de tiles próprio).", + "currentTileProvider": "tile atual" }, "downloadStarted": { "title": "Download Iniciado", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index f2934682..038cb89c 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Çevrimdışı moddayken indirmeler devre dışı. Yeni alanları indirmek için çevrimdışı modu devre dışı bırakın.", "areaTooBigMessage": "Çevrimdışı alanları indirmek için en az {} seviyesine yakınlaştırın. Büyük alan indirmeleri uygulamanın yanıt vermemesine neden olabilir.", "downloadStarted": "İndirme başladı! Döşemeler ve düğümler getiriliyor...", - "downloadFailed": "İndirme başlatılamadı: {}" + "downloadFailed": "İndirme başlatılamadı: {}", + "offlineNotPermitted": "{} sunucusu çevrimdışı indirmelere izin vermiyor. Çevrimdışı kullanıma izin veren bir döşeme sağlayıcısına geçin (ör. Bing Maps, Mapbox veya kendi barındırdığınız bir döşeme sunucusu).", + "currentTileProvider": "mevcut döşeme" }, "downloadStarted": { "title": "İndirme Başladı", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index e8f208e9..59a271b4 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -181,7 +181,9 @@ "offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.", "areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.", "downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...", - "downloadFailed": "Не вдалося почати завантаження: {}" + "downloadFailed": "Не вдалося почати завантаження: {}", + "offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).", + "currentTileProvider": "поточна плитка" }, "downloadStarted": { "title": "Завантаження Почалося", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index ab446840..01ce1fe3 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -181,7 +181,9 @@ "offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。", "areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。", "downloadStarted": "下载已开始!正在获取瓦片和节点...", - "downloadFailed": "启动下载失败:{}" + "downloadFailed": "启动下载失败:{}", + "offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。", + "currentTileProvider": "当前瓦片" }, "downloadStarted": { "title": "下载已开始", diff --git a/lib/main.dart b/lib/main.dart index 9bd2d565..ca0445b8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'screens/release_notes_screen.dart'; import 'screens/osm_account_screen.dart'; import 'screens/upload_queue_screen.dart'; import 'services/localization_service.dart'; +import 'services/provider_tile_cache_manager.dart'; import 'services/version_service.dart'; import 'services/deep_link_service.dart'; @@ -21,13 +22,16 @@ import 'services/deep_link_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - + // Initialize version service await VersionService().init(); - + // Initialize localization service await LocalizationService.instance.init(); + // Resolve platform cache directory for per-provider tile caching + await ProviderTileCacheManager.init(); + // Initialize deep link service await DeepLinkService().init(); DeepLinkService().setNavigatorKey(_navigatorKey); diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 8f7d81c4..5304d33e 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; @@ -10,7 +12,7 @@ class TileType { final Uint8List? previewTile; // Single tile image data for preview final int maxZoom; // Maximum zoom level for this tile type - const TileType({ + TileType({ required this.id, required this.name, required this.urlTemplate, @@ -76,6 +78,15 @@ class TileType { /// Check if this tile type needs an API key bool get requiresApiKey => urlTemplate.contains('{api_key}'); + /// The service policy that applies to this tile type's server. + /// Cached because [urlTemplate] is immutable. + late final ServicePolicy servicePolicy = + ServicePolicyResolver.resolve(urlTemplate); + + /// Whether this tile server's usage policy permits offline/bulk downloading. + /// Resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload; + Map toJson() => { 'id': id, 'name': name, diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 707f0155..79acec59 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart'; import 'package:http/retry.dart'; import '../app_state.dart'; +import '../models/tile_provider.dart' as models; import 'http_client.dart'; import 'map_data_submodules/tiles_from_local.dart'; import 'offline_area_service.dart'; @@ -16,6 +17,10 @@ import 'offline_area_service.dart'; /// built-in disk cache, RetryClient, ETag revalidation, and abort support, /// while routing URLs through our TileType logic and supporting offline tiles. /// +/// Each instance is configured for a specific tile provider/type combination +/// with frozen config — no AppState lookups at request time (except for the +/// global offlineMode toggle). +/// /// Two runtime paths: /// 1. **Common path** (no offline areas for current provider): delegates to /// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider @@ -29,34 +34,46 @@ class DeflockTileProvider extends NetworkTileProvider { /// will be false (we passed it in), so super.dispose() won't close it. final Client _sharedHttpClient; - DeflockTileProvider._({required Client httpClient}) - : _sharedHttpClient = httpClient, + /// Frozen config for this provider instance. + final String providerId; + final models.TileType tileType; + final String? apiKey; + + DeflockTileProvider._({ + required Client httpClient, + required this.providerId, + required this.tileType, + this.apiKey, + super.cachingProvider, + }) : _sharedHttpClient = httpClient, super( httpClient: httpClient, silenceExceptions: true, ); - factory DeflockTileProvider() { + factory DeflockTileProvider({ + required String providerId, + required models.TileType tileType, + String? apiKey, + MapCachingProvider? cachingProvider, + }) { final client = UserAgentClient(RetryClient(Client())); - return DeflockTileProvider._(httpClient: client); + return DeflockTileProvider._( + httpClient: client, + providerId: providerId, + tileType: tileType, + apiKey: apiKey, + cachingProvider: cachingProvider, + ); } @override String getTileUrl(TileCoordinates coordinates, TileLayer options) { - final appState = AppState.instance; - final selectedTileType = appState.selectedTileType; - final selectedProvider = appState.selectedTileProvider; - - if (selectedTileType == null || selectedProvider == null) { - // Fallback to base implementation if no provider configured - return super.getTileUrl(coordinates, options); - } - - return selectedTileType.getTileUrl( + return tileType.getTileUrl( coordinates.z, coordinates.x, coordinates.y, - apiKey: selectedProvider.apiKey, + apiKey: apiKey, ); } @@ -77,19 +94,15 @@ class DeflockTileProvider extends NetworkTileProvider { } // Offline-first path: check local tiles first, fall back to network. - final appState = AppState.instance; - final providerId = appState.selectedTileProvider?.id ?? 'unknown'; - final tileTypeId = appState.selectedTileType?.id ?? 'unknown'; - return DeflockOfflineTileImageProvider( coordinates: coordinates, options: options, httpClient: _sharedHttpClient, headers: headers, cancelLoading: cancelLoading, - isOfflineOnly: appState.offlineMode, + isOfflineOnly: AppState.instance.offlineMode, providerId: providerId, - tileTypeId: tileTypeId, + tileTypeId: tileType.id, tileUrl: getTileUrl(coordinates, options), ); } @@ -102,25 +115,16 @@ class DeflockTileProvider extends NetworkTileProvider { /// This avoids the offline-first path (and its filesystem searches) when /// browsing online with providers that have no offline areas. bool _shouldCheckOfflineCache() { - final appState = AppState.instance; - // Always use offline path in offline mode - if (appState.offlineMode) { + if (AppState.instance.offlineMode) { return true; } // For online mode, only use offline path if we have relevant offline data - final currentProvider = appState.selectedTileProvider; - final currentTileType = appState.selectedTileType; - - if (currentProvider == null || currentTileType == null) { - return false; - } - final offlineService = OfflineAreaService(); return offlineService.hasOfflineAreasForProvider( - currentProvider.id, - currentTileType.id, + providerId, + tileType.id, ); } @@ -263,9 +267,11 @@ class DeflockOfflineTileImageProvider return other is DeflockOfflineTileImageProvider && other.coordinates == coordinates && other.providerId == providerId && - other.tileTypeId == tileTypeId; + other.tileTypeId == tileTypeId && + other.isOfflineOnly == isOfflineOnly; } @override - int get hashCode => Object.hash(coordinates, providerId, tileTypeId); + int get hashCode => + Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly); } 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 342abf6a..0c21dada 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:xml/xml.dart'; @@ -7,6 +8,7 @@ import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../http_client.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). @@ -58,28 +60,36 @@ Future> _fetchFromOsmApi({ try { debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); debugPrint('[fetchOsmApiNodes] URL: $url'); - - final response = await _client.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 _client.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/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart new file mode 100644 index 00000000..54e99d3b --- /dev/null +++ b/lib/services/provider_tile_cache_manager.dart @@ -0,0 +1,103 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'provider_tile_cache_store.dart'; +import 'service_policy.dart'; + +/// Factory and registry for per-provider [ProviderTileCacheStore] instances. +/// +/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`. +/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to +/// resolve the platform cache directory. After init, [getOrCreate] is +/// synchronous — the cache store lazily creates its directory on first write. +class ProviderTileCacheManager { + static final Map _stores = {}; + static String? _baseCacheDir; + + /// Resolve the platform cache directory. Call once at startup. + static Future init() async { + if (_baseCacheDir != null) return; + final cacheDir = await getApplicationCacheDirectory(); + _baseCacheDir = p.join(cacheDir.path, 'tile_cache'); + } + + /// Whether the manager has been initialized. + static bool get isInitialized => _baseCacheDir != null; + + /// Get or create a cache store for a specific provider/tile type combination. + /// + /// Synchronous after [init] has been called. The cache store lazily creates + /// its directory on first write. + static ProviderTileCacheStore getOrCreate({ + required String providerId, + required String tileTypeId, + required ServicePolicy policy, + int? maxCacheBytes, + }) { + assert(_baseCacheDir != null, + 'ProviderTileCacheManager.init() must be called before getOrCreate()'); + + final key = '$providerId/$tileTypeId'; + if (_stores.containsKey(key)) return _stores[key]!; + + final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId); + + final store = ProviderTileCacheStore( + cacheDirectory: cacheDir, + maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024, + overrideFreshAge: policy.minCacheTtl, + ); + + _stores[key] = store; + return store; + } + + /// Delete a specific provider's cache directory and remove the store. + static Future deleteCache(String providerId, String tileTypeId) async { + final key = '$providerId/$tileTypeId'; + final store = _stores.remove(key); + if (store != null) { + await store.clear(); + } else if (_baseCacheDir != null) { + final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + } + } + } + + /// Get estimated cache sizes for all active stores. + /// + /// Returns a map of `providerId/tileTypeId` → size in bytes. + static Future> getCacheSizes() async { + final sizes = {}; + for (final entry in _stores.entries) { + sizes[entry.key] = await entry.value.estimatedSizeBytes; + } + return sizes; + } + + /// Remove a store from the registry (e.g., when a provider is disposed). + static void unregister(String providerId, String tileTypeId) { + _stores.remove('$providerId/$tileTypeId'); + } + + /// Clear all stores and reset the registry (for testing). + @visibleForTesting + static Future resetAll() async { + for (final store in _stores.values) { + await store.clear(); + } + _stores.clear(); + _baseCacheDir = null; + } + + /// Set the base cache directory directly (for testing). + @visibleForTesting + static void setBaseCacheDir(String dir) { + _baseCacheDir = dir; + } +} diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart new file mode 100644 index 00000000..c3b5e718 --- /dev/null +++ b/lib/services/provider_tile_cache_store.dart @@ -0,0 +1,291 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +/// Per-provider tile cache implementing flutter_map's [MapCachingProvider]. +/// +/// Each instance manages an isolated cache directory with: +/// - Deterministic UUID v5 key generation from tile URLs +/// - Optional TTL override from [ServicePolicy.minCacheTtl] +/// - Configurable max cache size with LRU eviction +/// +/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON +/// metadata containing staleAt, lastModified, etag). +class ProviderTileCacheStore implements MapCachingProvider { + final String cacheDirectory; + final int maxCacheBytes; + final Duration? overrideFreshAge; + + static const _uuid = Uuid(); + + /// Running estimate of cache size in bytes. Initialized lazily on first + /// [putTile] call to avoid blocking construction. + int? _estimatedSize; + + /// Throttle: don't re-scan more than once per minute. + DateTime? _lastPruneCheck; + + /// One-shot latch for lazy directory creation (safe under concurrent calls). + Completer? _directoryReady; + + /// Guard against concurrent eviction runs. + bool _isEvicting = false; + + ProviderTileCacheStore({ + required this.cacheDirectory, + this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default + this.overrideFreshAge, + }); + + @override + bool get isSupported => true; + + @override + Future getTile(String url) async { + final key = _keyFor(url); + final tileFile = File(p.join(cacheDirectory, '$key.tile')); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + if (!await tileFile.exists() || !await metaFile.exists()) return null; + + try { + final bytes = await tileFile.readAsBytes(); + final metaJson = json.decode(await metaFile.readAsString()) + as Map; + + final metadata = CachedMapTileMetadata( + staleAt: DateTime.fromMillisecondsSinceEpoch( + metaJson['staleAt'] as int, + isUtc: true, + ), + lastModified: metaJson['lastModified'] != null + ? DateTime.fromMillisecondsSinceEpoch( + metaJson['lastModified'] as int, + isUtc: true, + ) + : null, + etag: metaJson['etag'] as String?, + ); + + return (bytes: bytes, metadata: metadata); + } catch (e) { + throw CachedMapTileReadFailure( + url: url, + description: 'Failed to read cached tile', + originalError: e, + ); + } + } + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) async { + await _ensureDirectory(); + + final key = _keyFor(url); + final tileFile = File(p.join(cacheDirectory, '$key.tile')); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + // Apply minimum TTL override if configured (e.g., OSM 7-day minimum). + // Use the later of server-provided staleAt and our minimum to avoid + // accidentally shortening a longer server-provided freshness lifetime. + final effectiveMetadata = overrideFreshAge != null + ? (() { + final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!); + final staleAt = metadata.staleAt.isAfter(overrideStaleAt) + ? metadata.staleAt + : overrideStaleAt; + return CachedMapTileMetadata( + staleAt: staleAt, + lastModified: metadata.lastModified, + etag: metadata.etag, + ); + })() + : metadata; + + final metaJson = json.encode({ + 'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch, + 'lastModified': + effectiveMetadata.lastModified?.millisecondsSinceEpoch, + 'etag': effectiveMetadata.etag, + }); + + await metaFile.writeAsString(metaJson); + if (bytes != null) { + await tileFile.writeAsBytes(bytes); + } + + // Keep running size estimate up to date so eviction triggers promptly. + if (_estimatedSize != null) { + _estimatedSize = _estimatedSize! + metaJson.length + (bytes?.length ?? 0); + } + + // Schedule lazy size check + _scheduleEvictionCheck(); + } + + /// Ensure the cache directory exists (lazy creation on first write). + /// + /// Uses a Completer latch so concurrent callers share a single create(). + /// Safe under Dart's single-threaded event loop: the null check and + /// assignment happen in the same synchronous block with no `await` + /// between them, so no other microtask can interleave. + Future _ensureDirectory() { + if (_directoryReady == null) { + final completer = Completer(); + _directoryReady = completer; + Directory(cacheDirectory).create(recursive: true).then( + (_) => completer.complete(), + onError: completer.completeError, + ); + } + return _directoryReady!.future; + } + + /// Generate a cache key from URL using UUID v5 (same as flutter_map built-in). + static String _keyFor(String url) => _uuid.v5(Namespace.url.value, url); + + /// Estimate total cache size (lazy, first call scans directory). + Future _getEstimatedSize() async { + if (_estimatedSize != null) return _estimatedSize!; + + final dir = Directory(cacheDirectory); + if (!await dir.exists()) { + _estimatedSize = 0; + return 0; + } + + var total = 0; + await for (final entity in dir.list()) { + if (entity is File) { + total += await entity.length(); + } + } + _estimatedSize = total; + return total; + } + + /// Schedule eviction if we haven't checked recently. + void _scheduleEvictionCheck() { + final now = DateTime.now(); + if (_lastPruneCheck != null && + now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) { + return; + } + _lastPruneCheck = now; + + // Fire-and-forget: eviction is best-effort background work. + // _estimatedSize may be momentarily stale between eviction start and + // completion, but this is acceptable — the guard only needs to be + // approximately correct to prevent unbounded growth, and the throttle + // ensures we re-check within a minute. + // ignore: discarded_futures + _evictIfNeeded(); + } + + /// Evict oldest tiles if cache exceeds size limit. + /// Guarded by [_isEvicting] to prevent concurrent runs from corrupting + /// [_estimatedSize]. + Future _evictIfNeeded() async { + if (_isEvicting) return; + _isEvicting = true; + try { + final currentSize = await _getEstimatedSize(); + if (currentSize <= maxCacheBytes) return; + + final dir = Directory(cacheDirectory); + if (!await dir.exists()) return; + + // Collect all files, separating .tile and .meta for eviction + orphan cleanup. + final tileFiles = []; + final metaFiles = {}; + await for (final entity in dir.list()) { + if (entity is File) { + if (entity.path.endsWith('.tile')) { + tileFiles.add(entity); + } else if (entity.path.endsWith('.meta')) { + metaFiles.add(p.basenameWithoutExtension(entity.path)); + } + } + } + + if (tileFiles.isEmpty) return; + + // Sort by modification time, oldest first + final stats = await Future.wait( + tileFiles.map((f) async => (file: f, stat: await f.stat())), + ); + stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified)); + + var freedBytes = 0; + final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80% + + for (final entry in stats) { + if (currentSize - freedBytes <= targetSize) break; + + final key = p.basenameWithoutExtension(entry.file.path); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + try { + await entry.file.delete(); + freedBytes += entry.stat.size; + if (await metaFile.exists()) { + final metaStat = await metaFile.stat(); + await metaFile.delete(); + freedBytes += metaStat.size; + } + } catch (e) { + debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e'); + } + } + + // Clean up orphan .meta files (no matching .tile file). + final tileKeys = tileFiles + .map((f) => p.basenameWithoutExtension(f.path)) + .toSet(); + for (final metaKey in metaFiles) { + if (!tileKeys.contains(metaKey)) { + try { + final orphan = File(p.join(cacheDirectory, '$metaKey.meta')); + final orphanStat = await orphan.stat(); + await orphan.delete(); + freedBytes += orphanStat.size; + } catch (_) { + // Best-effort cleanup + } + } + } + + _estimatedSize = currentSize - freedBytes; + debugPrint( + '[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB ' + 'from $cacheDirectory', + ); + } catch (e) { + debugPrint('[ProviderTileCacheStore] Eviction error: $e'); + } finally { + _isEvicting = false; + } + } + + /// Delete all cached tiles in this store's directory. + Future clear() async { + final dir = Directory(cacheDirectory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _estimatedSize = null; + _directoryReady = null; // Allow lazy re-creation + } + + /// Get the current estimated cache size in bytes. + Future get estimatedSizeBytes => _getEstimatedSize(); +} diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 8ba0e40e..afb7b348 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -5,13 +5,31 @@ import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; import 'http_client.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 int _maxResults = 5; static const Duration _timeout = Duration(seconds: 10); final _client = UserAgentClient(); - + + /// Client-side result cache, keyed by normalized query + viewbox. + /// Required by Nominatim usage policy. Static so all SearchService + /// instances share the cache and don't generate redundant requests. + static final Map _resultCache = {}; + + /// Search for places using Nominatim geocoding service Future> search(String query, {LatLngBounds? viewbox}) async { if (query.trim().isEmpty) { @@ -27,23 +45,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 +69,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,27 +116,56 @@ class SearchService { } final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params); - + debugPrint('[SearchService] Searching Nominatim: $uri'); - + + // Rate limit: max 1 request/sec per Nominatim policy + await ServiceRateLimiter.acquire(ServiceType.nominatim); try { final response = await _client.get(uri).timeout(_timeout); - + 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) { debugPrint('[SearchService] Search failed: $e'); throw Exception('Search failed: $e'); + } finally { + ServiceRateLimiter.release(ServiceType.nominatim); + } + } + + /// 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); + } } } -} \ No newline at end of file +} diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart new file mode 100644 index 00000000..8e95663c --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,392 @@ +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 (exact or subdomain matching) + for (final entry in _customOverrides.entries) { + if (host == entry.key || host.endsWith('.${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; + + // Check custom overrides first — a registered custom policy means + // the host is treated as ServiceType.custom with custom rules. + for (final entry in _customOverrides.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return ServiceType.custom; + } + } + + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return entry.value; + } + } + + return ServiceType.custom; + } + + /// Look up the [ServicePolicy] for a known [ServiceType]. + static ServicePolicy resolveByType(ServiceType type) => + _policies[type] ?? const ServicePolicy(); + + /// 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 the last acquired request slot / request start + /// (used for rate limiting in [acquire], not updated on completion). + 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.resolveByType(service); + + // Concurrency: acquire semaphore slot first, so only one caller at a + // time proceeds to the rate-limit check. This prevents concurrent + // callers from bypassing the min interval when _lastRequestTime is + // still null or stale. + _Semaphore? semaphore; + if (policy.maxConcurrentRequests > 0) { + semaphore = _semaphores.putIfAbsent( + service, + () => _Semaphore(policy.maxConcurrentRequests), + ); + await semaphore.acquire(); + } + + try { + // 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); + } + } + } + + // Record request time + _lastRequestTime[service] = DateTime.now(); + } catch (_) { + // Release the semaphore slot if the rate-limit delay fails, + // to avoid permanently leaking a slot. + semaphore?.release(); + rethrow; + } + } + + /// 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--; + } else { + throw StateError( + 'Semaphore.release() called more times than acquire(); ' + 'currentCount is already zero.', + ); + } + } +} diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 0adbdb28..43995047 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -262,16 +262,52 @@ 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; + // Capture navigator before popping, since context is + // deactivated after Navigator.pop. + final navigator = Navigator.of(context); + navigator.pop(); + showDialog( + context: navigator.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( + locService.t( + 'download.offlineNotPermitted', + params: [ + selectedProvider?.name ?? locService.t('download.currentTileProvider'), + ], + ), + ), + 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..804a023a 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,6 +1,7 @@ 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'; @@ -26,16 +27,59 @@ 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 { + try { + final uri = Uri.parse(attributionUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open link')), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open link')), + ); + } + } + }, + child: Text( + attributionUrl, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ], ), actions: [ TextButton( diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index 75acc72b..7e842a8a 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -1,28 +1,47 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import '../../models/tile_provider.dart' as models; import '../../services/deflock_tile_provider.dart'; - -/// Manages tile layer creation, caching, and provider switching. -/// Uses DeFlock's custom tile provider for clean integration. +import '../../services/provider_tile_cache_manager.dart'; + +/// Manages tile layer creation with per-provider caching and provider switching. +/// +/// Each tile provider/type combination gets its own [DeflockTileProvider] +/// instance with isolated caching (separate cache directory, configurable size +/// limit, and policy-driven TTL enforcement). Providers are created lazily on +/// first use and cached for instant switching. class TileLayerManager { - DeflockTileProvider? _tileProvider; + final Map _providers = {}; int _mapRebuildKey = 0; String? _lastTileTypeId; bool? _lastOfflineMode; - /// Get the current map rebuild key for cache busting + /// Get the current map rebuild key for cache busting. int get mapRebuildKey => _mapRebuildKey; - /// Initialize the tile layer manager + /// Initialize the tile layer manager. + /// + /// [ProviderTileCacheManager.init] is called in main() before any widgets + /// build, so this is a no-op retained for API compatibility. void initialize() { - // Don't create tile provider here - create it fresh for each build + // Cache directory is already resolved in main(). } - /// Dispose of resources + /// Dispose of all provider resources. + /// + /// Synchronous to match Flutter's [State.dispose] contract. Each provider's + /// async cleanup (closing HTTP clients) runs in the background; errors are + /// caught to avoid unhandled exceptions from dropped futures. void dispose() { - _tileProvider?.dispose(); + for (final provider in _providers.values) { + unawaited(provider.dispose().catchError( + (Object e) => debugPrint('[TileLayerManager] Provider dispose error: $e'), + )); + } + _providers.clear(); } /// Check if cache should be cleared and increment rebuild key if needed. @@ -43,11 +62,9 @@ class TileLayerManager { } if (shouldClear) { - // Force map rebuild with new key to bust flutter_map cache + // Force map rebuild with new key to bust flutter_map cache. + // We don't dispose providers here — they're reusable across switches. _mapRebuildKey++; - // Dispose old provider before creating a fresh one (closes HTTP client) - _tileProvider?.dispose(); - _tileProvider = null; debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey'); } @@ -57,10 +74,8 @@ class TileLayerManager { return shouldClear; } - /// Clear the tile request queue (call after cache clear) + /// Clear the tile request queue (call after cache clear). void clearTileQueue() { - // With NetworkTileProvider, clearing is handled by FlutterMap's internal cache - // We just need to increment the rebuild key to bust the cache _mapRebuildKey++; debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey'); } @@ -70,19 +85,23 @@ class TileLayerManager { // No immediate clearing needed — NetworkTileProvider aborts obsolete requests } - /// Clear only tiles that are no longer visible in the current bounds + /// Clear only tiles that are no longer visible in the current bounds. void clearStaleRequests({required LatLngBounds currentBounds}) { // No selective clearing needed — NetworkTileProvider aborts obsolete requests } /// Build tile layer widget with current provider and type. - /// Uses DeFlock's custom tile provider for clean integration with our offline/online system. + /// + /// Gets or creates a [DeflockTileProvider] for the given provider/type + /// combination, each with its own isolated cache. Widget buildTileLayer({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, }) { - // Create a fresh tile provider instance if we don't have one or cache was cleared - _tileProvider ??= DeflockTileProvider(); + final tileProvider = _getOrCreateProvider( + selectedProvider: selectedProvider, + selectedTileType: selectedTileType, + ); // Use the actual urlTemplate from the selected tile type. Our getTileUrl() // override handles the real URL generation; flutter_map uses urlTemplate @@ -94,7 +113,52 @@ class TileLayerManager { urlTemplate: urlTemplate, userAgentPackageName: 'me.deflock.deflockapp', maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0, - tileProvider: _tileProvider!, + tileProvider: tileProvider, ); } + + /// Get or create a [DeflockTileProvider] for the given provider/type. + DeflockTileProvider _getOrCreateProvider({ + required models.TileProvider? selectedProvider, + required models.TileType? selectedTileType, + }) { + if (selectedProvider == null || selectedTileType == null) { + // No provider configured — return a fallback with default config. + return _providers.putIfAbsent( + '_fallback', + () => DeflockTileProvider( + providerId: 'unknown', + tileType: models.TileType( + id: 'unknown', + name: 'Unknown', + urlTemplate: 'unknown/unknown/{z}/{x}/{y}', + attribution: '', + ), + ), + ); + } + + final key = '${selectedProvider.id}/${selectedTileType.id}'; + return _providers.putIfAbsent(key, () { + final cachingProvider = ProviderTileCacheManager.isInitialized + ? ProviderTileCacheManager.getOrCreate( + providerId: selectedProvider.id, + tileTypeId: selectedTileType.id, + policy: selectedTileType.servicePolicy, + ) + : null; + + debugPrint( + '[TileLayerManager] Creating provider for $key ' + '(cache: ${cachingProvider != null ? "enabled" : "disabled"})', + ); + + return DeflockTileProvider( + providerId: selectedProvider.id, + tileType: selectedTileType, + apiKey: selectedProvider.apiKey, + cachingProvider: cachingProvider, + ); + }); + } } diff --git a/test/services/deflock_tile_provider_test.dart b/test/services/deflock_tile_provider_test.dart index ee4cd36b..f30428c0 100644 --- a/test/services/deflock_tile_provider_test.dart +++ b/test/services/deflock_tile_provider_test.dart @@ -13,30 +13,33 @@ void main() { late DeflockTileProvider provider; late MockAppState mockAppState; + final osmTileType = models.TileType( + id: 'osm_street', + name: 'Street Map', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap', + maxZoom: 19, + ); + + final mapboxTileType = models.TileType( + id: 'mapbox_satellite', + name: 'Satellite', + urlTemplate: + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + attribution: '© Mapbox', + ); + setUp(() { mockAppState = MockAppState(); AppState.instance = mockAppState; - // Default stubs: online, OSM provider selected, no offline areas + // Default stubs: online, no offline areas when(() => mockAppState.offlineMode).thenReturn(false); - when(() => mockAppState.selectedTileProvider).thenReturn( - const models.TileProvider( - id: 'openstreetmap', - name: 'OpenStreetMap', - tileTypes: [], - ), - ); - when(() => mockAppState.selectedTileType).thenReturn( - const models.TileType( - id: 'osm_street', - name: 'Street Map', - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: '© OpenStreetMap', - maxZoom: 19, - ), - ); - provider = DeflockTileProvider(); + provider = DeflockTileProvider( + providerId: 'openstreetmap', + tileType: osmTileType, + ); }); tearDown(() async { @@ -49,7 +52,7 @@ void main() { expect(provider.supportsCancelLoading, isTrue); }); - test('getTileUrl() delegates to TileType.getTileUrl()', () { + test('getTileUrl() uses frozen tileType config', () { const coords = TileCoordinates(1, 2, 3); final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}'); @@ -58,23 +61,12 @@ void main() { expect(url, equals('https://tile.openstreetmap.org/3/1/2.png')); }); - test('getTileUrl() includes API key when present', () { - when(() => mockAppState.selectedTileProvider).thenReturn( - const models.TileProvider( - id: 'mapbox', - name: 'Mapbox', - apiKey: 'test_key_123', - tileTypes: [], - ), - ); - when(() => mockAppState.selectedTileType).thenReturn( - const models.TileType( - id: 'mapbox_satellite', - name: 'Satellite', - urlTemplate: - 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', - attribution: '© Mapbox', - ), + test('getTileUrl() includes API key when present', () async { + await provider.dispose(); + provider = DeflockTileProvider( + providerId: 'mapbox', + tileType: mapboxTileType, + apiKey: 'test_key_123', ); const coords = TileCoordinates(1, 2, 10); @@ -86,19 +78,6 @@ void main() { expect(url, contains('/10/1/2@2x')); }); - test('getTileUrl() falls back to super when no provider selected', () { - when(() => mockAppState.selectedTileProvider).thenReturn(null); - when(() => mockAppState.selectedTileType).thenReturn(null); - - const coords = TileCoordinates(1, 2, 3); - final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}'); - - final url = provider.getTileUrl(coords, options); - - // Super implementation uses the urlTemplate from TileLayer options - expect(url, equals('https://example.com/3/1/2')); - }); - test('routes to network path when no offline areas exist', () { // offlineMode = false, OfflineAreaService not initialized → no offline areas const coords = TileCoordinates(5, 10, 12); @@ -136,10 +115,19 @@ void main() { expect(offlineProvider.providerId, equals('openstreetmap')); expect(offlineProvider.tileTypeId, equals('osm_street')); }); + + test('frozen config is independent of AppState', () { + // Provider was created with OSM config — changing AppState should not affect it + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}'); + + final url = provider.getTileUrl(coords, options); + expect(url, equals('https://tile.openstreetmap.org/3/1/2.png')); + }); }); group('DeflockOfflineTileImageProvider', () { - test('equal for same coordinates and provider/type', () { + test('equal for same coordinates, provider/type, and offlineOnly', () { const coords = TileCoordinates(1, 2, 3); final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); final cancel = Future.value(); @@ -161,7 +149,7 @@ void main() { httpClient: http.Client(), headers: const {}, cancelLoading: cancel, - isOfflineOnly: true, // different — but not in == + isOfflineOnly: false, providerId: 'prov_a', tileTypeId: 'type_1', tileUrl: 'https://other.com/3/1/2', // different — but not in == @@ -171,6 +159,37 @@ void main() { expect(a.hashCode, equals(b.hashCode)); }); + test('not equal for different isOfflineOnly', () { + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancel = Future.value(); + + final online = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + final offline = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: true, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + + expect(online, isNot(equals(offline))); + }); + test('not equal for different coordinates', () { const coords1 = TileCoordinates(1, 2, 3); const coords2 = TileCoordinates(1, 2, 4); diff --git a/test/services/provider_tile_cache_store_test.dart b/test/services/provider_tile_cache_store_test.dart new file mode 100644 index 00000000..0580284a --- /dev/null +++ b/test/services/provider_tile_cache_store_test.dart @@ -0,0 +1,332 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import 'package:deflockapp/services/provider_tile_cache_store.dart'; +import 'package:deflockapp/services/provider_tile_cache_manager.dart'; +import 'package:deflockapp/services/service_policy.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('tile_cache_test_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + await ProviderTileCacheManager.resetAll(); + }); + + group('ProviderTileCacheStore', () { + late ProviderTileCacheStore store; + + setUp(() { + store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + ); + }); + + test('isSupported is true', () { + expect(store.isSupported, isTrue); + }); + + test('getTile returns null for uncached URL', () async { + final result = await store.getTile('https://tile.example.com/1/2/3.png'); + expect(result, isNull); + }); + + test('putTile and getTile round-trip', () async { + const url = 'https://tile.example.com/1/2/3.png'; + final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final staleAt = DateTime.utc(2026, 3, 1); + final metadata = CachedMapTileMetadata( + staleAt: staleAt, + lastModified: DateTime.utc(2026, 2, 20), + etag: '"abc123"', + ); + + await store.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); + expect( + cached.metadata.staleAt.millisecondsSinceEpoch, + equals(staleAt.millisecondsSinceEpoch), + ); + expect(cached.metadata.etag, equals('"abc123"')); + expect(cached.metadata.lastModified, isNotNull); + }); + + test('putTile without bytes updates metadata only', () async { + const url = 'https://tile.example.com/1/2/3.png'; + final bytes = Uint8List.fromList([1, 2, 3]); + final metadata1 = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: '"v1"', + ); + + // Write with bytes first + await store.putTile(url: url, metadata: metadata1, bytes: bytes); + + // Update metadata only + final metadata2 = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 4, 1), + lastModified: null, + etag: '"v2"', + ); + await store.putTile(url: url, metadata: metadata2); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); // bytes unchanged + expect(cached.metadata.etag, equals('"v2"')); // metadata updated + }); + + test('handles null lastModified and etag', () async { + const url = 'https://tile.example.com/simple.png'; + final bytes = Uint8List.fromList([10, 20, 30]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + + await store.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.metadata.lastModified, isNull); + expect(cached.metadata.etag, isNull); + }); + + test('creates cache directory lazily on first putTile', () async { + final subDir = p.join(tempDir.path, 'lazy', 'nested'); + final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir); + + // Directory should not exist yet + expect(await Directory(subDir).exists(), isFalse); + + await lazyStore.putTile( + url: 'https://example.com/tile.png', + metadata: CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ), + bytes: Uint8List.fromList([1]), + ); + + // Directory should now exist + expect(await Directory(subDir).exists(), isTrue); + }); + + test('clear deletes all cached tiles', () async { + // Write some tiles + for (var i = 0; i < 5; i++) { + await store.putTile( + url: 'https://example.com/$i.png', + metadata: CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ), + bytes: Uint8List.fromList([i]), + ); + } + + // Verify tiles exist + expect(await store.getTile('https://example.com/0.png'), isNotNull); + + // Clear + await store.clear(); + + // Directory should be gone + expect(await Directory(tempDir.path).exists(), isFalse); + + // getTile should return null (directory gone) + expect(await store.getTile('https://example.com/0.png'), isNull); + }); + }); + + group('ProviderTileCacheStore TTL override', () { + test('overrideFreshAge bumps staleAt forward', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + overrideFreshAge: const Duration(days: 7), + ); + + const url = 'https://tile.example.com/osm.png'; + // Server says stale in 1 hour, but policy requires 7 days + final serverMetadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: null, + etag: null, + ); + + await store.putTile( + url: url, + metadata: serverMetadata, + bytes: Uint8List.fromList([1, 2, 3]), + ); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + + // staleAt should be ~7 days from now, not 1 hour + final expectedMin = DateTime.timestamp().add(const Duration(days: 6)); + expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue); + }); + + test('without overrideFreshAge, server staleAt is preserved', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + // No overrideFreshAge + ); + + const url = 'https://tile.example.com/bing.png'; + final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0); + final serverMetadata = CachedMapTileMetadata( + staleAt: serverStaleAt, + lastModified: null, + etag: null, + ); + + await store.putTile( + url: url, + metadata: serverMetadata, + bytes: Uint8List.fromList([1, 2, 3]), + ); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect( + cached!.metadata.staleAt.millisecondsSinceEpoch, + equals(serverStaleAt.millisecondsSinceEpoch), + ); + }); + }); + + group('ProviderTileCacheStore isolation', () { + test('separate directories do not interfere', () async { + final dirA = p.join(tempDir.path, 'provider_a', 'type_1'); + final dirB = p.join(tempDir.path, 'provider_b', 'type_1'); + + final storeA = ProviderTileCacheStore(cacheDirectory: dirA); + final storeB = ProviderTileCacheStore(cacheDirectory: dirB); + + const url = 'https://tile.example.com/shared-url.png'; + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + + await storeA.putTile( + url: url, + metadata: metadata, + bytes: Uint8List.fromList([1, 1, 1]), + ); + await storeB.putTile( + url: url, + metadata: metadata, + bytes: Uint8List.fromList([2, 2, 2]), + ); + + final cachedA = await storeA.getTile(url); + final cachedB = await storeB.getTile(url); + + expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1]))); + expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2]))); + }); + }); + + group('ProviderTileCacheManager', () { + test('getOrCreate returns same instance for same key', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeA = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + final storeB = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + expect(identical(storeA, storeB), isTrue); + }); + + test('getOrCreate returns different instances for different keys', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeA = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + final storeB = ProviderTileCacheManager.getOrCreate( + providerId: 'bing', + tileTypeId: 'satellite', + policy: const ServicePolicy(), + ); + + expect(identical(storeA, storeB), isFalse); + }); + + test('passes overrideFreshAge from policy.minCacheTtl', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy.osmTileServer(), + ); + + expect(store.overrideFreshAge, equals(const Duration(days: 7))); + }); + + test('custom maxCacheBytes is applied', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store = ProviderTileCacheManager.getOrCreate( + providerId: 'big', + tileTypeId: 'tiles', + policy: const ServicePolicy(), + maxCacheBytes: 1024 * 1024 * 1024, // 1 GB + ); + + expect(store.maxCacheBytes, equals(1024 * 1024 * 1024)); + }); + + test('unregister removes store from registry', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store1 = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + ProviderTileCacheManager.unregister('osm', 'street'); + + // Should create a new instance after unregistering + final store2 = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + expect(identical(store1, store2), isFalse); + }); + }); +} diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart new file mode 100644 index 00000000..f2406741 --- /dev/null +++ b/test/services/service_policy_test.dart @@ -0,0 +1,395 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:deflockapp/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('third acquire blocks until a slot is released', () async { + // Fill both slots (osmEditingApi maxConcurrentRequests = 2) + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + // Third acquire should block + var thirdCompleted = false; + final thirdFuture = ServiceRateLimiter.acquire(ServiceType.osmEditingApi).then((_) { + thirdCompleted = true; + }); + + // Give microtasks a chance to run — third should still be blocked + await Future.delayed(Duration.zero); + expect(thirdCompleted, false); + + // Release one slot — third should now complete + ServiceRateLimiter.release(ServiceType.osmEditingApi); + await thirdFuture; + expect(thirdCompleted, true); + + // Clean up + 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 not apply + // any artificial rate limiting delays. + await ServiceRateLimiter.acquire(ServiceType.overpass); + ServiceRateLimiter.release(ServiceType.overpass); + await ServiceRateLimiter.acquire(ServiceType.overpass); + ServiceRateLimiter.release(ServiceType.overpass); + }); + + test('Nominatim enforces min interval under concurrent callers', () async { + final start = DateTime.now(); + + // Start two concurrent callers; only one should run at a time and + // the minRequestInterval of ~1s should still be enforced. + final future1 = () async { + await ServiceRateLimiter.acquire(ServiceType.nominatim); + ServiceRateLimiter.release(ServiceType.nominatim); + }(); + + final future2 = () async { + await ServiceRateLimiter.acquire(ServiceType.nominatim); + ServiceRateLimiter.release(ServiceType.nominatim); + }(); + + await Future.wait([future1, future2]); + + final elapsed = DateTime.now().difference(start); + // Under contention, the second caller should still be delayed by + // approximately the minRequestInterval (~1 second). + expect(elapsed.inMilliseconds, greaterThanOrEqualTo(900)); + }); + }); + + 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'); + }); + }); +}