From 2d92214bedb5436a31dfee678e07c89fdea1ea62 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 3 Mar 2026 14:33:26 -0700 Subject: [PATCH 1/3] Add offline-first tile system with per-provider caching and error retry - Add ServicePolicy framework with OSM-specific rate limiting and TTL - Add per-provider disk tile cache (ProviderTileCacheStore) with O(1) lookup, oldest-modified eviction, and ETag/304 revalidation - Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider) and offline-first (disk cache -> local tiles -> network with caching) - Add zoom-aware offline routing so tiles outside offline area zoom ranges use the efficient common path instead of the overhead-heavy offline path - Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map widget recycling; shutdown() handles permanent teardown - Add TileLayerManager with exponential backoff retry (2s->60s cap), provider switch detection, and backoff reset - Guard null provider/tileType in download dialog with localized error - Fix Nominatim cache key to use normalized viewbox values - Comprehensive test coverage (1800+ lines across 6 test files) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +- lib/localizations/de.json | 20 +- lib/localizations/en.json | 24 +- lib/localizations/es.json | 20 +- lib/localizations/fr.json | 20 +- lib/localizations/it.json | 20 +- lib/localizations/nl.json | 12 +- lib/localizations/pl.json | 12 +- lib/localizations/pt.json | 20 +- lib/localizations/tr.json | 12 +- lib/localizations/uk.json | 12 +- lib/localizations/zh.json | 20 +- lib/main.dart | 8 +- lib/models/tile_provider.dart | 13 +- lib/services/deflock_tile_provider.dart | 413 ++++++++++---- .../nodes_from_osm_api.dart | 26 +- .../map_data_submodules/tiles_from_local.dart | 59 +- lib/services/offline_area_service.dart | 29 +- .../offline_areas/offline_area_models.dart | 5 +- .../offline_areas/offline_tile_utils.dart | 25 +- lib/services/provider_tile_cache_manager.dart | 103 ++++ lib/services/provider_tile_cache_store.dart | 313 +++++++++++ lib/services/search_service.dart | 124 ++++- lib/services/service_policy.dart | 400 ++++++++++++++ lib/widgets/download_area_dialog.dart | 82 ++- lib/widgets/map/map_overlays.dart | 93 +++- lib/widgets/map/tile_layer_manager.dart | 221 +++++++- lib/widgets/map_view.dart | 13 +- pubspec.lock | 2 +- pubspec.yaml | 1 + test/services/deflock_tile_provider_test.dart | 424 +++++++++++++-- test/services/offline_area_service_test.dart | 93 ++++ .../provider_tile_cache_store_test.dart | 509 ++++++++++++++++++ test/services/service_policy_test.dart | 426 +++++++++++++++ test/services/tiles_from_local_test.dart | 227 ++++++++ test/widgets/map/tile_layer_manager_test.dart | 487 +++++++++++++++++ 36 files changed, 3940 insertions(+), 351 deletions(-) create mode 100644 lib/services/provider_tile_cache_manager.dart create mode 100644 lib/services/provider_tile_cache_store.dart create mode 100644 lib/services/service_policy.dart create mode 100644 test/services/offline_area_service_test.dart create mode 100644 test/services/provider_tile_cache_store_test.dart create mode 100644 test/services/service_policy_test.dart create mode 100644 test/services/tiles_from_local_test.dart create mode 100644 test/widgets/map/tile_layer_manager_test.dart diff --git a/.gitignore b/.gitignore index d03c893..db2e32a 100644 --- a/.gitignore +++ b/.gitignore @@ -73,12 +73,13 @@ fuchsia/build/ web/build/ # ─────────────────────────────── -# IDE / Editor Settings +# IDE / Editor / AI Tool Settings # ─────────────────────────────── .idea/ .idea/**/workspace.xml .idea/**/tasks.xml .vscode/ +.claude/settings.local.json # Swap files *.swp *.swo diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 47b41da..cd9bdae 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -144,7 +144,10 @@ "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", + "noTileProviderSelected": "Kein Kachelanbieter ausgewählt. Bitte wählen Sie einen Kartenstil, bevor Sie einen Offlinebereich herunterladen." }, "downloadStarted": { "title": "Download gestartet", @@ -292,13 +295,16 @@ "addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?", "createCustomProfile": "Benutzerdefiniertes Profil Erstellen", "createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags", - "importFromWebsite": "Von Webseite Importieren", + "importFromWebsite": "Von Webseite Importieren", "importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren" }, "mapTiles": { "title": "Karten-Kacheln", "manageProviders": "Anbieter Verwalten", - "attribution": "Karten-Zuschreibung" + "attribution": "Karten-Zuschreibung", + "mapAttribution": "Kartenquelle: {}", + "couldNotOpenLink": "Link konnte nicht geöffnet werden", + "openLicense": "Lizenz öffnen: {}" }, "profileEditor": { "viewProfile": "Profil Anzeigen", @@ -325,7 +331,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Neues Betreiber-Profil", - "editOperatorProfile": "Betreiber-Profil Bearbeiten", + "editOperatorProfile": "Betreiber-Profil Bearbeiten", "operatorName": "Betreiber-Name", "operatorNameHint": "z.B. Polizei Austin", "operatorNameRequired": "Betreiber-Name ist erforderlich", @@ -520,7 +526,7 @@ "updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen", "neverFetched": "Nie abgerufen", "daysAgo": "vor {} Tagen", - "hoursAgo": "vor {} Stunden", + "hoursAgo": "vor {} Stunden", "minutesAgo": "vor {} Minuten", "justNow": "Gerade eben" }, @@ -528,7 +534,7 @@ "title": "Verdächtiger Standort #{}", "ticketNo": "Ticket-Nr.", "address": "Adresse", - "street": "Straße", + "street": "Straße", "city": "Stadt", "state": "Bundesland", "intersectingStreet": "Kreuzende Straße", @@ -552,4 +558,4 @@ "metricDescription": "Metrisch (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/en.json b/lib/localizations/en.json index fa62994..7f7023b 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -70,7 +70,7 @@ "submitAnyway": "Submit Anyway", "nodeType": { "alpr": "ALPR/ANPR Camera", - "publicCamera": "Public Surveillance Camera", + "publicCamera": "Public Surveillance Camera", "camera": "Surveillance Camera", "amenity": "{}", "device": "{} Device", @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "No tile provider is selected. Please select a map style before downloading an offline area." }, "downloadStarted": { "title": "Download Started", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "How would you like to add a profile?", "createCustomProfile": "Create Custom Profile", "createCustomProfileDescription": "Build a profile from scratch with your own tags", - "importFromWebsite": "Import from Website", + "importFromWebsite": "Import from Website", "importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify" }, "mapTiles": { "title": "Map Tiles", "manageProviders": "Manage Providers", - "attribution": "Map Attribution" + "attribution": "Map Attribution", + "mapAttribution": "Map attribution: {}", + "couldNotOpenLink": "Could not open link", + "openLicense": "Open license: {}" }, "profileEditor": { "viewProfile": "View Profile", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "New Operator Profile", - "editOperatorProfile": "Edit Operator Profile", + "editOperatorProfile": "Edit Operator Profile", "operatorName": "Operator name", "operatorNameHint": "e.g., Austin Police Department", "operatorNameRequired": "Operator name is required", @@ -443,7 +449,7 @@ "mobileEditors": "Mobile Editors", "iDEditor": "iD Editor", "iDEditorSubtitle": "Full-featured web editor - always works", - "rapidEditor": "RapiD Editor", + "rapidEditor": "RapiD Editor", "rapidEditorSubtitle": "AI-assisted editing with Facebook data", "vespucci": "Vespucci", "vespucciSubtitle": "Advanced Android OSM editor", @@ -520,7 +526,7 @@ "updateFailed": "Failed to update suspected locations", "neverFetched": "Never fetched", "daysAgo": "{} days ago", - "hoursAgo": "{} hours ago", + "hoursAgo": "{} hours ago", "minutesAgo": "{} minutes ago", "justNow": "Just now" }, @@ -528,7 +534,7 @@ "title": "Suspected Location #{}", "ticketNo": "Ticket No", "address": "Address", - "street": "Street", + "street": "Street", "city": "City", "state": "State", "intersectingStreet": "Intersecting Street", @@ -552,4 +558,4 @@ "metricDescription": "Metric (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 8cfe386..423ca6d 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "No hay proveedor de mosaicos seleccionado. Seleccione un estilo de mapa antes de descargar un área sin conexión." }, "downloadStarted": { "title": "Descarga Iniciada", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "¿Cómo desea añadir un perfil?", "createCustomProfile": "Crear Perfil Personalizado", "createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas", - "importFromWebsite": "Importar desde Sitio Web", + "importFromWebsite": "Importar desde Sitio Web", "importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify" }, "mapTiles": { "title": "Tiles de Mapa", "manageProviders": "Gestionar Proveedores", - "attribution": "Atribución del Mapa" + "attribution": "Atribución del Mapa", + "mapAttribution": "Atribución del mapa: {}", + "couldNotOpenLink": "No se pudo abrir el enlace", + "openLicense": "Abrir licencia: {}" }, "profileEditor": { "viewProfile": "Ver Perfil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nuevo Perfil de Operador", - "editOperatorProfile": "Editar Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", "operatorName": "Nombre del operador", "operatorNameHint": "ej., Departamento de Policía de Austin", "operatorNameRequired": "El nombre del operador es requerido", @@ -520,7 +526,7 @@ "updateFailed": "Error al actualizar ubicaciones sospechosas", "neverFetched": "Nunca obtenido", "daysAgo": "hace {} días", - "hoursAgo": "hace {} horas", + "hoursAgo": "hace {} horas", "minutesAgo": "hace {} minutos", "justNow": "Ahora mismo" }, @@ -528,7 +534,7 @@ "title": "Ubicación Sospechosa #{}", "ticketNo": "No. de Ticket", "address": "Dirección", - "street": "Calle", + "street": "Calle", "city": "Ciudad", "state": "Estado", "intersectingStreet": "Calle que Intersecta", @@ -552,4 +558,4 @@ "metricDescription": "Métrico (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 2dcb895..b314a5b 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "Aucun fournisseur de tuiles sélectionné. Veuillez choisir un style de carte avant de télécharger une zone hors ligne." }, "downloadStarted": { "title": "Téléchargement Démarré", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?", "createCustomProfile": "Créer Profil Personnalisé", "createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises", - "importFromWebsite": "Importer depuis Site Web", + "importFromWebsite": "Importer depuis Site Web", "importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify" }, "mapTiles": { "title": "Tuiles de Carte", "manageProviders": "Gérer Fournisseurs", - "attribution": "Attribution de Carte" + "attribution": "Attribution de Carte", + "mapAttribution": "Attribution de la carte : {}", + "couldNotOpenLink": "Impossible d'ouvrir le lien", + "openLicense": "Ouvrir la licence : {}" }, "profileEditor": { "viewProfile": "Voir Profil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nouveau Profil d'Opérateur", - "editOperatorProfile": "Modifier Profil d'Opérateur", + "editOperatorProfile": "Modifier Profil d'Opérateur", "operatorName": "Nom de l'opérateur", "operatorNameHint": "ex., Département de Police d'Austin", "operatorNameRequired": "Le nom de l'opérateur est requis", @@ -520,7 +526,7 @@ "updateFailed": "Échec de la mise à jour des emplacements suspects", "neverFetched": "Jamais récupéré", "daysAgo": "il y a {} jours", - "hoursAgo": "il y a {} heures", + "hoursAgo": "il y a {} heures", "minutesAgo": "il y a {} minutes", "justNow": "À l'instant" }, @@ -528,7 +534,7 @@ "title": "Emplacement Suspect #{}", "ticketNo": "N° de Ticket", "address": "Adresse", - "street": "Rue", + "street": "Rue", "city": "Ville", "state": "État", "intersectingStreet": "Rue Transversale", @@ -552,4 +558,4 @@ "metricDescription": "Métrique (km, m)", "imperialDescription": "Impérial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/it.json b/lib/localizations/it.json index c61fe7f..6602d76 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "Nessun provider di tile selezionato. Seleziona uno stile di mappa prima di scaricare un'area offline." }, "downloadStarted": { "title": "Download Avviato", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Come desideri aggiungere un profilo?", "createCustomProfile": "Crea Profilo Personalizzato", "createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag", - "importFromWebsite": "Importa da Sito Web", + "importFromWebsite": "Importa da Sito Web", "importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify" }, "mapTiles": { "title": "Tile Mappa", "manageProviders": "Gestisci Fornitori", - "attribution": "Attribuzione Mappa" + "attribution": "Attribuzione Mappa", + "mapAttribution": "Attribuzione mappa: {}", + "couldNotOpenLink": "Impossibile aprire il link", + "openLicense": "Apri licenza: {}" }, "profileEditor": { "viewProfile": "Visualizza Profilo", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nuovo Profilo Operatore", - "editOperatorProfile": "Modifica Profilo Operatore", + "editOperatorProfile": "Modifica Profilo Operatore", "operatorName": "Nome operatore", "operatorNameHint": "es., Dipartimento di Polizia di Austin", "operatorNameRequired": "Il nome dell'operatore è obbligatorio", @@ -520,7 +526,7 @@ "updateFailed": "Aggiornamento posizioni sospette fallito", "neverFetched": "Mai recuperato", "daysAgo": "{} giorni fa", - "hoursAgo": "{} ore fa", + "hoursAgo": "{} ore fa", "minutesAgo": "{} minuti fa", "justNow": "Proprio ora" }, @@ -528,7 +534,7 @@ "title": "Posizione Sospetta #{}", "ticketNo": "N. Ticket", "address": "Indirizzo", - "street": "Via", + "street": "Via", "city": "Città", "state": "Stato", "intersectingStreet": "Via che Interseca", @@ -552,4 +558,4 @@ "metricDescription": "Metrico (km, m)", "imperialDescription": "Imperiale (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 558cdba..f9d0cd5 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "Geen tegelprovider geselecteerd. Selecteer een kaartstijl voordat u een offlinegebied downloadt." }, "downloadStarted": { "title": "Download Gestart", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Kaart Tiles", "manageProviders": "Beheer Providers", - "attribution": "Kaart Attributie" + "attribution": "Kaart Attributie", + "mapAttribution": "Kaartbron: {}", + "couldNotOpenLink": "Kon link niet openen", + "openLicense": "Open licentie: {}" }, "profileEditor": { "viewProfile": "Bekijk Profiel", @@ -552,4 +558,4 @@ "metricDescription": "Metrisch (km, m)", "imperialDescription": "Imperiaal (mijl, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 3513368..76fc22b 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "Nie wybrano dostawcy kafelków. Wybierz styl mapy przed pobraniem obszaru offline." }, "downloadStarted": { "title": "Pobieranie Rozpoczęte", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Kafelki Mapy", "manageProviders": "Zarządzaj Dostawcami", - "attribution": "Atrybucja Mapy" + "attribution": "Atrybucja Mapy", + "mapAttribution": "Źródło mapy: {}", + "couldNotOpenLink": "Nie udało się otworzyć linku", + "openLicense": "Otwórz licencję: {}" }, "profileEditor": { "viewProfile": "Zobacz Profil", @@ -552,4 +558,4 @@ "metricDescription": "Metryczny (km, m)", "imperialDescription": "Imperialny (mila, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 38e611b..8366a54 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "Nenhum provedor de tiles selecionado. Selecione um estilo de mapa antes de baixar uma área offline." }, "downloadStarted": { "title": "Download Iniciado", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Como gostaria de adicionar um perfil?", "createCustomProfile": "Criar Perfil Personalizado", "createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags", - "importFromWebsite": "Importar do Site", + "importFromWebsite": "Importar do Site", "importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify" }, "mapTiles": { "title": "Tiles do Mapa", "manageProviders": "Gerenciar Provedores", - "attribution": "Atribuição do Mapa" + "attribution": "Atribuição do Mapa", + "mapAttribution": "Atribuição do mapa: {}", + "couldNotOpenLink": "Não foi possível abrir o link", + "openLicense": "Abrir licença: {}" }, "profileEditor": { "viewProfile": "Ver Perfil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Novo Perfil de Operador", - "editOperatorProfile": "Editar Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", "operatorName": "Nome do operador", "operatorNameHint": "ex., Departamento de Polícia de Austin", "operatorNameRequired": "Nome do operador é obrigatório", @@ -520,7 +526,7 @@ "updateFailed": "Falha ao atualizar localizações suspeitas", "neverFetched": "Nunca buscado", "daysAgo": "{} dias atrás", - "hoursAgo": "{} horas atrás", + "hoursAgo": "{} horas atrás", "minutesAgo": "{} minutos atrás", "justNow": "Agora mesmo" }, @@ -528,7 +534,7 @@ "title": "Localização Suspeita #{}", "ticketNo": "N° do Ticket", "address": "Endereço", - "street": "Rua", + "street": "Rua", "city": "Cidade", "state": "Estado", "intersectingStreet": "Rua que Cruza", @@ -552,4 +558,4 @@ "metricDescription": "Métrico (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index f293468..06fc9ad 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -181,7 +181,10 @@ "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", + "noTileProviderSelected": "Döşeme sağlayıcı seçilmedi. Çevrimdışı alan indirmeden önce lütfen bir harita stili seçin." }, "downloadStarted": { "title": "İndirme Başladı", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Harita Döşemeleri", "manageProviders": "Sağlayıcıları Yönet", - "attribution": "Harita Atfı" + "attribution": "Harita Atfı", + "mapAttribution": "Harita kaynağı: {}", + "couldNotOpenLink": "Bağlantı açılamadı", + "openLicense": "Lisansı aç: {}" }, "profileEditor": { "viewProfile": "Profili Görüntüle", @@ -552,4 +558,4 @@ "metricDescription": "Metrik (km, m)", "imperialDescription": "İmperial (mil, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index e8f208e..499f1a2 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.", "areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.", "downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...", - "downloadFailed": "Не вдалося почати завантаження: {}" + "downloadFailed": "Не вдалося почати завантаження: {}", + "offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).", + "currentTileProvider": "поточна плитка", + "noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області." }, "downloadStarted": { "title": "Завантаження Почалося", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Плитки Карти", "manageProviders": "Управляти Постачальниками", - "attribution": "Атрибуція Карти" + "attribution": "Атрибуція Карти", + "mapAttribution": "Джерело карти: {}", + "couldNotOpenLink": "Не вдалося відкрити посилання", + "openLicense": "Відкрити ліцензію: {}" }, "profileEditor": { "viewProfile": "Переглянути Профіль", @@ -552,4 +558,4 @@ "metricDescription": "Метричні (км, м)", "imperialDescription": "Імперські (миля, фут)" } -} \ No newline at end of file +} diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index ab44684..0069558 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -181,7 +181,10 @@ "offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。", "areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。", "downloadStarted": "下载已开始!正在获取瓦片和节点...", - "downloadFailed": "启动下载失败:{}" + "downloadFailed": "启动下载失败:{}", + "offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。", + "currentTileProvider": "当前瓦片", + "noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。" }, "downloadStarted": { "title": "下载已开始", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "您希望如何添加配置文件?", "createCustomProfile": "创建自定义配置文件", "createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件", - "importFromWebsite": "从网站导入", + "importFromWebsite": "从网站导入", "importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件" }, "mapTiles": { "title": "地图瓦片", "manageProviders": "管理提供商", - "attribution": "地图归属" + "attribution": "地图归属", + "mapAttribution": "地图来源:{}", + "couldNotOpenLink": "无法打开链接", + "openLicense": "打开许可证:{}" }, "profileEditor": { "viewProfile": "查看配置文件", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "新建运营商配置文件", - "editOperatorProfile": "编辑运营商配置文件", + "editOperatorProfile": "编辑运营商配置文件", "operatorName": "运营商名称", "operatorNameHint": "例如,奥斯汀警察局", "operatorNameRequired": "运营商名称为必填项", @@ -520,7 +526,7 @@ "updateFailed": "疑似位置更新失败", "neverFetched": "从未获取", "daysAgo": "{}天前", - "hoursAgo": "{}小时前", + "hoursAgo": "{}小时前", "minutesAgo": "{}分钟前", "justNow": "刚刚" }, @@ -528,7 +534,7 @@ "title": "疑似位置 #{}", "ticketNo": "工单号", "address": "地址", - "street": "街道", + "street": "街道", "city": "城市", "state": "州/省", "intersectingStreet": "交叉街道", @@ -552,4 +558,4 @@ "metricDescription": "公制 (公里, 米)", "imperialDescription": "英制 (英里, 英尺)" } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 9bd2d56..ca0445b 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 8f7d81c..5304d33 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 707f015..9ab86cb 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter_map/flutter_map.dart'; @@ -8,55 +9,103 @@ 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'; +/// Thrown when a tile load is cancelled (tile scrolled off screen). +/// TileLayerManager skips retry for these — the tile is already gone. +class TileLoadCancelledException implements Exception { + const TileLoadCancelledException(); +} + +/// Thrown when a tile is not available offline (no offline area or cache hit). +/// TileLayerManager skips retry for these — retrying won't help without network. +class TileNotAvailableOfflineException implements Exception { + const TileNotAvailableOfflineException(); +} + /// Custom tile provider that extends NetworkTileProvider to leverage its /// 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 /// pipeline (disk cache, ETag revalidation, RetryClient, abort support). /// 2. **Offline-first path** (has offline areas or offline mode): returns -/// DeflockOfflineTileImageProvider — checks fetchLocalTile() first, falls -/// back to HTTP via shared RetryClient on miss. +/// DeflockOfflineTileImageProvider — checks disk cache and local tiles +/// first, falls back to HTTP via shared RetryClient on miss. class DeflockTileProvider extends NetworkTileProvider { /// The shared HTTP client we own. We keep a reference because /// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient /// 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; + + /// Caching provider for the offline-first path. The same instance is passed + /// to super for the common path — we keep a reference here so we can also + /// use it in [DeflockOfflineTileImageProvider]. + final MapCachingProvider? _cachingProvider; + + /// Called when a tile loads successfully via the network in the offline-first + /// path. Used by [TileLayerManager] to reset exponential backoff. + VoidCallback? onNetworkSuccess; + + // ignore: use_super_parameters + DeflockTileProvider._({ + required Client httpClient, + required this.providerId, + required this.tileType, + this.apiKey, + MapCachingProvider? cachingProvider, + this.onNetworkSuccess, + }) : _sharedHttpClient = httpClient, + _cachingProvider = cachingProvider, super( httpClient: httpClient, - silenceExceptions: true, + cachingProvider: cachingProvider, + // Let errors propagate so flutter_map marks tiles as failed + // (loadError = true) rather than caching transparent images as + // "successfully loaded". The TileLayerManager wires a reset stream + // that retries failed tiles after a debounced delay. + silenceExceptions: false, ); - factory DeflockTileProvider() { + factory DeflockTileProvider({ + required String providerId, + required models.TileType tileType, + String? apiKey, + MapCachingProvider? cachingProvider, + VoidCallback? onNetworkSuccess, + }) { final client = UserAgentClient(RetryClient(Client())); - return DeflockTileProvider._(httpClient: client); + return DeflockTileProvider._( + httpClient: client, + providerId: providerId, + tileType: tileType, + apiKey: apiKey, + cachingProvider: cachingProvider, + onNetworkSuccess: onNetworkSuccess, + ); } @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, ); } @@ -66,7 +115,7 @@ class DeflockTileProvider extends NetworkTileProvider { TileLayer options, Future cancelLoading, ) { - if (!_shouldCheckOfflineCache()) { + if (!_shouldCheckOfflineCache(coordinates.z)) { // Common path: no offline areas — delegate to NetworkTileProvider's // full pipeline (disk cache, ETag, RetryClient, abort support). return super.getImageWithCancelLoadingSupport( @@ -77,20 +126,18 @@ 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), + cachingProvider: _cachingProvider, + onNetworkSuccess: onNetworkSuccess, ); } @@ -101,44 +148,67 @@ 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; - + bool _shouldCheckOfflineCache(int zoom) { // 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; - } - + // at this zoom level — tiles outside any area's zoom range go through the + // common NetworkTileProvider path for better performance. final offlineService = OfflineAreaService(); - return offlineService.hasOfflineAreasForProvider( - currentProvider.id, - currentTileType.id, + return offlineService.hasOfflineAreasForProviderAtZoom( + providerId, + tileType.id, + zoom, ); } @override Future dispose() async { - try { - await super.dispose(); - } finally { - _sharedHttpClient.close(); - } + // Only call super — do NOT close _sharedHttpClient here. + // flutter_map calls dispose() whenever the TileLayer widget is recycled + // (e.g. provider switch causes a new FlutterMap key), but + // TileLayerManager caches and reuses provider instances across switches. + // Closing the HTTP client here would leave the cached instance broken — + // all future tile requests would fail with "Client closed". + // + // Since we passed our own httpClient to NetworkTileProvider, + // _isInternallyCreatedClient is false, so super.dispose() won't close it + // either. The client is closed in [shutdown], called by + // TileLayerManager.dispose() when the map is truly torn down. + await super.dispose(); + } + + /// Permanently close the HTTP client. Called by [TileLayerManager.dispose] + /// when the map widget is being torn down — NOT by flutter_map's widget + /// recycling. + void shutdown() { + _sharedHttpClient.close(); } } /// Image provider for the offline-first path. /// -/// Tries fetchLocalTile() first. On miss (and if online), falls back to an -/// HTTP GET via the shared RetryClient. Handles cancelLoading abort and -/// returns transparent tiles on errors (consistent with silenceExceptions). +/// Checks disk cache and offline areas before falling back to the network. +/// Caches successful network fetches to disk so panning back doesn't re-fetch. +/// On cancellation, lets in-flight downloads complete and caches the result +/// (fire-and-forget) instead of discarding downloaded bytes. +/// +/// **Online mode flow:** +/// 1. Disk cache (fast hash-based file read) → hit + fresh → return +/// 2. Offline areas (file scan) → hit → return +/// 3. Network fetch with conditional headers from stale cache entry +/// 4. On cancel → fire-and-forget cache write for the in-flight download +/// 5. On 304 → return stale cached bytes, update cache metadata +/// 6. On 200 → cache to disk, decode and return +/// 7. On error → throw (flutter_map marks tile as failed) +/// +/// **Offline mode flow:** +/// 1. Offline areas (primary source — guaranteed available) +/// 2. Disk cache (tiles cached from previous online sessions) +/// 3. Throw if both miss (flutter_map marks tile as failed) class DeflockOfflineTileImageProvider extends ImageProvider { final TileCoordinates coordinates; @@ -150,6 +220,8 @@ class DeflockOfflineTileImageProvider final String providerId; final String tileTypeId; final String tileUrl; + final MapCachingProvider? cachingProvider; + final VoidCallback? onNetworkSuccess; const DeflockOfflineTileImageProvider({ required this.coordinates, @@ -161,6 +233,8 @@ class DeflockOfflineTileImageProvider required this.providerId, required this.tileTypeId, required this.tileUrl, + this.cachingProvider, + this.onNetworkSuccess, }); @override @@ -173,19 +247,47 @@ class DeflockOfflineTileImageProvider ImageStreamCompleter loadImage( DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) { final chunkEvents = StreamController(); - final codecFuture = _loadAsync(key, decode, chunkEvents); - - codecFuture.whenComplete(() { - chunkEvents.close(); - }); return MultiFrameImageStreamCompleter( - codec: codecFuture, + // Chain whenComplete into the codec future so there's a single future + // for MultiFrameImageStreamCompleter to handle. Without this, the + // whenComplete creates an orphaned future whose errors go unhandled. + codec: _loadAsync(key, decode, chunkEvents).whenComplete(() { + chunkEvents.close(); + }), chunkEvents: chunkEvents.stream, scale: 1.0, ); } + /// Try to read a tile from the disk cache. Returns null on miss or error. + Future _getCachedTile() async { + if (cachingProvider == null || !cachingProvider!.isSupported) return null; + try { + return await cachingProvider!.getTile(tileUrl); + } on CachedMapTileReadFailure { + return null; + } catch (_) { + return null; + } + } + + /// Write a tile to the disk cache (best-effort, never throws). + void _putCachedTile({ + required Map responseHeaders, + Uint8List? bytes, + }) { + if (cachingProvider == null || !cachingProvider!.isSupported) return; + try { + final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders); + cachingProvider! + .putTile(url: tileUrl, metadata: metadata, bytes: bytes) + .catchError((_) {}); + } catch (_) { + // Best-effort: never fail the tile load due to cache write errors. + } + } + Future _loadAsync( DeflockOfflineTileImageProvider key, ImageDecoderCallback decode, @@ -194,78 +296,169 @@ class DeflockOfflineTileImageProvider Future decodeBytes(Uint8List bytes) => ImmutableBuffer.fromUint8List(bytes).then(decode); - Future transparent() => - decodeBytes(TileProvider.transparentImage); + // Track cancellation synchronously via Completer so the catch block + // can reliably check it without microtask ordering races. + final cancelled = Completer(); + cancelLoading.then((_) { + if (!cancelled.isCompleted) cancelled.complete(); + }).ignore(); try { - // Track cancellation - bool cancelled = false; - cancelLoading.then((_) => cancelled = true); - - // Try local tile first — pass captured IDs to avoid a race if the - // user switches provider while this async load is in flight. - try { - final localBytes = await fetchLocalTile( - z: coordinates.z, - x: coordinates.x, - y: coordinates.y, - providerId: providerId, - tileTypeId: tileTypeId, - ); - return await decodeBytes(Uint8List.fromList(localBytes)); - } catch (_) { - // Local miss — fall through to network if online + if (isOfflineOnly) { + return await _loadOffline(decodeBytes, cancelled); } - - if (cancelled) return await transparent(); - if (isOfflineOnly) return await transparent(); - - // Fall back to network via shared RetryClient. - // Race the download against cancelLoading so we stop waiting if the - // tile is pruned mid-flight (the underlying TCP connection is cleaned - // up naturally by the shared client). - final request = Request('GET', Uri.parse(tileUrl)); - request.headers.addAll(headers); - - final networkFuture = httpClient.send(request).then((response) async { - final bytes = await response.stream.toBytes(); - return (statusCode: response.statusCode, bytes: bytes); - }); - - final result = await Future.any([ - networkFuture, - cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))), - ]); - - if (cancelled || result.statusCode == 0) return await transparent(); - - if (result.statusCode == 200 && result.bytes.isNotEmpty) { - return await decodeBytes(result.bytes); - } - - return await transparent(); + return await _loadOnline(decodeBytes, cancelled); } catch (e) { - // Don't log routine offline misses - if (!e.toString().contains('offline')) { - debugPrint( - '[DeflockTileProvider] Offline-first tile failed ' - '${coordinates.z}/${coordinates.x}/${coordinates.y} ' - '(${e.runtimeType})'); + // Cancelled tiles throw — flutter_map handles the error silently. + // Preserve TileNotAvailableOfflineException even if the tile was also + // cancelled — it has distinct semantics (genuine cache miss) that + // matter for diagnostics and future UI indicators. + if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) { + throw const TileLoadCancelledException(); } - return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage) - .then(decode); + + // Let real errors propagate so flutter_map marks loadError = true + rethrow; } } + /// Online mode: disk cache → offline areas → network (with caching). + Future _loadOnline( + Future Function(Uint8List) decodeBytes, + Completer cancelled, + ) async { + // 1. Check disk cache — fast hash-based file read. + final cachedTile = await _getCachedTile(); + if (cachedTile != null && !cachedTile.metadata.isStale) { + return await decodeBytes(cachedTile.bytes); + } + + // 2. Check offline areas — file scan per area. + try { + final localBytes = await fetchLocalTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + providerId: providerId, + tileTypeId: tileTypeId, + ); + return await decodeBytes(Uint8List.fromList(localBytes)); + } catch (_) { + // Local miss — fall through to network + } + + // 3. If cancelled before network, bail. + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + + // 4. Network fetch with conditional headers from stale cache entry. + final request = Request('GET', Uri.parse(tileUrl)); + request.headers.addAll(headers); + if (cachedTile != null) { + if (cachedTile.metadata.lastModified case final lastModified?) { + request.headers[HttpHeaders.ifModifiedSinceHeader] = + HttpDate.format(lastModified); + } + if (cachedTile.metadata.etag case final etag?) { + request.headers[HttpHeaders.ifNoneMatchHeader] = etag; + } + } + + // 5. Race the download against cancelLoading. + final networkFuture = httpClient.send(request).then((response) async { + final bytes = await response.stream.toBytes(); + return ( + statusCode: response.statusCode, + bytes: bytes, + headers: response.headers, + ); + }); + + final result = await Future.any([ + networkFuture, + cancelLoading.then((_) => ( + statusCode: 0, + bytes: Uint8List(0), + headers: {}, + )), + ]); + + // 6. On cancel — fire-and-forget cache write for the in-flight download + // instead of discarding the downloaded bytes. + if (cancelled.isCompleted || result.statusCode == 0) { + networkFuture.then((r) { + if (r.statusCode == 200 && r.bytes.isNotEmpty) { + _putCachedTile(responseHeaders: r.headers, bytes: r.bytes); + } + }).ignore(); + throw const TileLoadCancelledException(); + } + + // 7. On 304 Not Modified → return stale cached bytes, update metadata. + if (result.statusCode == HttpStatus.notModified && cachedTile != null) { + _putCachedTile(responseHeaders: result.headers); + onNetworkSuccess?.call(); + return await decodeBytes(cachedTile.bytes); + } + + // 8. On 200 OK → cache to disk, decode and return. + if (result.statusCode == 200 && result.bytes.isNotEmpty) { + _putCachedTile(responseHeaders: result.headers, bytes: result.bytes); + onNetworkSuccess?.call(); + return await decodeBytes(result.bytes); + } + + // 9. Network error — throw so flutter_map marks the tile as failed. + // Don't include tileUrl in the exception — it may contain API keys. + throw HttpException( + 'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} ' + 'returned status ${result.statusCode}', + ); + } + + /// Offline mode: offline areas → disk cache → throw. + Future _loadOffline( + Future Function(Uint8List) decodeBytes, + Completer cancelled, + ) async { + // 1. Check offline areas (primary source — guaranteed available). + try { + final localBytes = await fetchLocalTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + providerId: providerId, + tileTypeId: tileTypeId, + ); + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + return await decodeBytes(Uint8List.fromList(localBytes)); + } on TileLoadCancelledException { + rethrow; + } catch (_) { + // Local miss — fall through to disk cache + } + + // 2. Check disk cache (tiles cached from previous online sessions). + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + final cachedTile = await _getCachedTile(); + if (cachedTile != null) { + return await decodeBytes(cachedTile.bytes); + } + + // 3. Both miss — throw so flutter_map marks the tile as failed. + throw const TileNotAvailableOfflineException(); + } + @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; 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 342abf6..0c21dad 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/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 003d9a0..5113134 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:flutter/foundation.dart' show visibleForTesting; + import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; -import '../offline_areas/offline_tile_utils.dart'; import '../../app_state.dart'; /// Fetch a tile from the newest offline area that matches the given provider, or throw if not found. @@ -19,7 +23,7 @@ Future> fetchLocalTile({ final appState = AppState.instance; final currentProviderId = providerId ?? appState.selectedTileProvider?.id; final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id; - + final offlineService = OfflineAreaService(); await offlineService.ensureInitialized(); final areas = offlineService.offlineAreas; @@ -28,20 +32,21 @@ Future> fetchLocalTile({ for (final area in areas) { if (area.status != OfflineAreaStatus.complete) continue; if (z < area.minZoom || z > area.maxZoom) continue; - + // Only consider areas that match the current provider/type if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue; - // Get tile coverage for area at this zoom only - final coveredTiles = computeTileList(area.bounds, z, z); - final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y); - if (hasTile) { - final tilePath = _tilePath(area.directory, z, x, y); - final file = File(tilePath); - if (await file.exists()) { - final stat = await file.stat(); - candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); - } + // O(1) bounds check instead of enumerating all tiles at this zoom level + if (!tileInBounds(area.bounds, z, x, y)) continue; + + final tilePath = _tilePath(area.directory, z, x, y); + final file = File(tilePath); + try { + final stat = await file.stat(); + if (stat.type == FileSystemEntityType.notFound) continue; + candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); + } on FileSystemException { + continue; } } if (candidates.isEmpty) { @@ -51,6 +56,34 @@ Future> fetchLocalTile({ return await candidates.first.file.readAsBytes(); } +/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds. +/// +/// Uses the same Mercator projection math as [latLonToTile] in +/// offline_tile_utils.dart, but only computes the bounding tile range +/// instead of enumerating every tile at that zoom level. +/// +/// Note: Y axis is inverted in tile coordinates — north = lower Y. +@visibleForTesting +bool tileInBounds(LatLngBounds bounds, int z, int x, int y) { + final n = pow(2.0, z); + final west = bounds.west; + final east = bounds.east; + final north = bounds.north; + final south = bounds.south; + + final minX = ((west + 180.0) / 360.0 * n).floor(); + final maxX = ((east + 180.0) / 360.0 * n).floor(); + // North → lower Y (Mercator projection inverts latitude) + final minY = ((1.0 - log(tan(north * pi / 180.0) + + 1.0 / cos(north * pi / 180.0)) / + pi) / 2.0 * n).floor(); + final maxY = ((1.0 - log(tan(south * pi / 180.0) + + 1.0 / cos(south * pi / 180.0)) / + pi) / 2.0 * n).floor(); + + return x >= minX && x <= maxX && y >= minY && y <= maxY; +} + String _tilePath(String areaDir, int z, int x, int y) => '$areaDir/tiles/$z/$x/$y.png'; diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 9300825..d458d76 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -33,14 +33,37 @@ class OfflineAreaService { if (!_initialized) { return false; // No offline areas loaded yet } - - return _areas.any((area) => + + return _areas.any((area) => area.status == OfflineAreaStatus.complete && area.tileProviderId == providerId && area.tileTypeId == tileTypeId ); } + + /// Like [hasOfflineAreasForProvider] but also checks that at least one area + /// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the + /// offline-first path for tiles that will never be found locally. + bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) { + if (!_initialized) return false; + return _areas.any((area) => + area.status == OfflineAreaStatus.complete && + area.tileProviderId == providerId && + area.tileTypeId == tileTypeId && + zoom >= area.minZoom && + zoom <= area.maxZoom + ); + } + /// Reset service state and inject areas for unit tests. + @visibleForTesting + void setAreasForTesting(List areas) { + _areas + ..clear() + ..addAll(areas); + _initialized = true; + } + /// Cancel all active downloads (used when enabling offline mode) Future cancelActiveDownloads() async { final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList(); @@ -213,7 +236,7 @@ class OfflineAreaService { area = OfflineArea( id: id, name: name ?? area?.name ?? '', - bounds: bounds, + bounds: normalizeBounds(bounds), minZoom: minZoom, maxZoom: maxZoom, directory: directory, diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart index 61906e7..38285c7 100644 --- a/lib/services/offline_areas/offline_area_models.dart +++ b/lib/services/offline_areas/offline_area_models.dart @@ -1,6 +1,7 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../models/osm_node.dart'; +import 'offline_tile_utils.dart' show normalizeBounds; /// Status of an offline area enum OfflineAreaStatus { downloading, complete, error, cancelled } @@ -71,10 +72,10 @@ class OfflineArea { }; static OfflineArea fromJson(Map json) { - final bounds = LatLngBounds( + final bounds = normalizeBounds(LatLngBounds( LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']), LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']), - ); + )); return OfflineArea( id: json['id'], name: json['name'] ?? '', diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart index b3da977..7b283f4 100644 --- a/lib/services/offline_areas/offline_tile_utils.dart +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds; /// Utility for tile calculations and lat/lon conversions for OSM offline logic -Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { - Set> tiles = {}; +/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero) +/// spans are expanded by epsilon. Call this before storing bounds so that +/// `tileInBounds` and [computeTileList] see consistent corner ordering. +LatLngBounds normalizeBounds(LatLngBounds bounds) { const double epsilon = 1e-7; - double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); - double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); - double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); - double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); - // Expand degenerate/flat areas a hair + var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); + var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); + var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); + var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); if ((latMax - latMin).abs() < epsilon) { latMin -= epsilon; latMax += epsilon; @@ -20,6 +21,16 @@ Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { lonMin -= epsilon; lonMax += epsilon; } + return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax)); +} + +Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { + Set> tiles = {}; + final normalized = normalizeBounds(bounds); + final double latMin = normalized.south; + final double latMax = normalized.north; + final double lonMin = normalized.west; + final double lonMax = normalized.east; for (int z = zMin; z <= zMax; z++) { final n = pow(2, z).toInt(); final minTileRaw = latLonToTileRaw(latMin, lonMin, z); diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart new file mode 100644 index 0000000..54e99d3 --- /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 0000000..bf23e15 --- /dev/null +++ b/lib/services/provider_tile_cache_store.dart @@ -0,0 +1,313 @@ +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 oldest-modified 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')); + + 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); + } on PathNotFoundException { + return null; + } 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, + }); + + // Write .tile before .meta: if we crash between the two writes, the + // read path's both-must-exist check sees a miss rather than an orphan .meta. + if (bytes != null) { + await tileFile.writeAsBytes(bytes); + } + await metaFile.writeAsString(metaJson); + + // Reset size estimate so it resyncs from disk on next check. + // This avoids drift from overwrites where the old size isn't subtracted. + _estimatedSize = null; + + // 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: (Object error, StackTrace stackTrace) { + // Reset latch on error so later calls can retry directory creation. + if (identical(_directoryReady, completer)) { + _directoryReady = null; + } + completer.completeError(error, stackTrace); + }, + ); + } + 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-modified tiles if cache exceeds size limit. + /// + /// Sorts by file mtime (oldest first), not by last access — true LRU would + /// require touching files on every [getTile] read, adding I/O on the hot + /// path. In practice write-recency tracks usage well because tiles are + /// immutable and flutter_map holds visible tiles in memory. + /// + /// 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% + final evictedKeys = {}; + + 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; + evictedKeys.add(key); + 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). + // Exclude keys we just evicted — their .tile is gone so they're orphans. + final remainingTileKeys = tileFiles + .map((f) => p.basenameWithoutExtension(f.path)) + .toSet() + ..removeAll(evictedKeys); + for (final metaKey in metaFiles) { + if (!remainingTileKeys.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(); + + /// Force an eviction check, bypassing the throttle. + /// Only exposed for testing — production code uses [_scheduleEvictionCheck]. + @visibleForTesting + Future forceEviction() => _evictIfNeeded(); +} diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 8ba0e40..6459767 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,17 +69,17 @@ class SearchService { type: 'point', ); } - - /// Search using Nominatim API - Future> _searchNominatim(String query, {LatLngBounds? viewbox}) async { - final params = { - 'q': query, - 'format': 'json', - 'limit': _maxResults.toString(), - 'addressdetails': '1', - 'extratags': '1', - }; + /// 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 { + // Normalize the viewbox first so both the cache key and the request + // params use the same effective values (rounded + min-span expanded). + String? viewboxParam; if (viewbox != null) { double round1(double v) => (v * 10).round() / 10; var west = round1(viewbox.west); @@ -80,31 +98,83 @@ class SearchService { north = mid + 0.25; } - params['viewbox'] = '$west,$north,$east,$south'; + viewboxParam = '$west,$north,$east,$south'; + } + + final cacheKey = _buildCacheKey(query, viewboxParam); + + // 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', + 'limit': _maxResults.toString(), + 'addressdetails': '1', + 'extratags': '1', + }; + + if (viewboxParam != null) { + params['viewbox'] = viewboxParam; } 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) { + } catch (e, stackTrace) { debugPrint('[SearchService] Search failed: $e'); - throw Exception('Search failed: $e'); + Error.throwWithStackTrace(e, stackTrace); + } finally { + ServiceRateLimiter.release(ServiceType.nominatim); } } -} \ No newline at end of file + + /// Build a cache key from the query and the already-normalized viewbox string. + /// + /// The viewbox should be the same `west,north,east,south` string sent to + /// Nominatim (after rounding and min-span expansion) so that requests with + /// different raw bounds but the same effective viewbox share a cache entry. + String _buildCacheKey(String query, String? viewboxParam) { + final normalizedQuery = query.trim().toLowerCase(); + if (viewboxParam == null) return normalizedQuery; + return '$normalizedQuery|$viewboxParam'; + } + + /// 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 0000000..e8990a3 --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,400 @@ +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 { + /// Injectable clock for testing. Defaults to [DateTime.now]. + /// + /// Override with a deterministic clock (e.g. from `FakeAsync`) so tests + /// don't rely on wall-clock time and stay fast and stable under CI load. + @visibleForTesting + static DateTime Function() clock = DateTime.now; + + /// 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 = clock().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] = clock(); + } 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(); + clock = DateTime.now; + } +} + +/// 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 0adbdb2..cdda1d0 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -262,16 +262,80 @@ 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; + } + + // Guard: provider and tile type must be non-null for a + // useful offline area (fetchLocalTile requires exact match). + if (selectedProvider == null || selectedTileType == null) { + if (!context.mounted) return; + final navigator = Navigator.of(context); + navigator.pop(); + showDialog( + context: navigator.context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.error, color: Colors.red), + const SizedBox(width: 10), + Text(locService.t('download.title')), + ], + ), + content: Text(locService.t('download.noTileProviderSelected')), + 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( @@ -282,10 +346,10 @@ class _DownloadAreaDialogState extends State { directory: dir, onProgress: (progress) {}, onComplete: (status) {}, - tileProviderId: selectedProvider?.id, - tileProviderName: selectedProvider?.name, - tileTypeId: selectedTileType?.id, - tileTypeName: selectedTileType?.name, + tileProviderId: selectedProvider.id, + tileProviderName: selectedProvider.name, + tileTypeId: selectedTileType.id, + tileTypeName: selectedTileType.name, ); Navigator.pop(context); showDialog( diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 0d9772f..5e952bb 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,63 @@ 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), + Semantics( + link: true, + label: locService.t('mapTiles.openLicense', params: [attributionUrl]), + child: InkWell( + 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( + SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))), + ); + } + } + }, + child: Text( + attributionUrl, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ], ), actions: [ TextButton( @@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget { Positioned( bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom), left: leftPositionWithSafeArea(10, safeArea), - child: GestureDetector( - onTap: () => _showAttributionDialog(context, attribution!), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + child: Semantics( + button: true, + label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]), + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), + child: InkWell( borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - constraints: const BoxConstraints(maxWidth: 250), - child: Text( - attribution!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface, + onTap: () => _showAttributionDialog(context, attribution!), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Text( + attribution!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), ), - overflow: TextOverflow.ellipsis, - maxLines: 1, ), ), ), diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index 75acc72..fcf74d9 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -1,68 +1,124 @@ +import 'dart:async'; +import 'dart:math'; + 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'; +import '../../services/provider_tile_cache_manager.dart'; -/// Manages tile layer creation, caching, and provider switching. -/// Uses DeFlock's custom tile provider for clean integration. +/// 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? _lastProviderId; String? _lastTileTypeId; bool? _lastOfflineMode; - /// Get the current map rebuild key for cache busting + /// Stream that triggers flutter_map to drop all tiles and reload. + /// Fired after a debounced delay when tile errors are detected. + final StreamController _resetController = + StreamController.broadcast(); + + /// Debounce timer for scheduling a tile reset after errors. + Timer? _retryTimer; + + /// Current retry delay — starts at [_minRetryDelay] and doubles on each + /// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay] + /// when a tile loads successfully. + Duration _retryDelay = const Duration(seconds: 2); + + static const _minRetryDelay = Duration(seconds: 2); + static const _maxRetryDelay = Duration(seconds: 60); + + /// Get the current map rebuild key for cache busting. int get mapRebuildKey => _mapRebuildKey; - /// Initialize the tile layer manager + /// Current retry delay (exposed for testing). + @visibleForTesting + Duration get retryDelay => _retryDelay; + + /// Stream of reset events (exposed for testing). + @visibleForTesting + Stream get resetStream => _resetController.stream; + + /// 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. Calls + /// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP + /// client. (We don't call provider.dispose() here — flutter_map already + /// called it when the TileLayer widget was removed, and it's safe to call + /// again but unnecessary.) void dispose() { - _tileProvider?.dispose(); + _retryTimer?.cancel(); + _resetController.close(); + for (final provider in _providers.values) { + provider.shutdown(); + } + _providers.clear(); } /// Check if cache should be cleared and increment rebuild key if needed. /// Returns true if cache was cleared (map should be rebuilt). bool checkAndClearCacheIfNeeded({ + required String? currentProviderId, required String? currentTileTypeId, required bool currentOfflineMode, }) { bool shouldClear = false; String? reason; - if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) { + if (_lastProviderId != currentProviderId) { + reason = 'provider ($currentProviderId)'; + shouldClear = true; + } else if (_lastTileTypeId != currentTileTypeId) { reason = 'tile type ($currentTileTypeId)'; shouldClear = true; - } else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) { + } else if (_lastOfflineMode != currentOfflineMode) { reason = 'offline mode ($currentOfflineMode)'; shouldClear = true; } 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; + // Reset backoff so the new provider starts with a clean slate. + // Cancel any pending retry timer — it belongs to the old provider's errors. + _retryDelay = _minRetryDelay; + _retryTimer?.cancel(); debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey'); } + _lastProviderId = currentProviderId; _lastTileTypeId = currentTileTypeId; _lastOfflineMode = currentOfflineMode; return shouldClear; } - /// Clear the tile request queue (call after cache clear) + /// Clear the tile request queue (call after cache clear). + /// + /// In the old architecture this incremented [_mapRebuildKey] a second time + /// to force a rebuild after the provider was disposed and recreated. With + /// per-provider caching, [checkAndClearCacheIfNeeded] already increments the + /// key, so this is now a no-op. Kept for API compatibility with map_view. 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'); + // No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey. } /// Clear tile queue immediately (for zoom changes, etc.) @@ -70,19 +126,85 @@ 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 } + /// Called by flutter_map when a tile fails to load. Schedules a debounced + /// reset so that all failed tiles get retried after the burst of errors + /// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap. + /// + /// Skips retry for [TileLoadCancelledException] (tile scrolled off screen) + /// and [TileNotAvailableOfflineException] (no cached data, retrying won't + /// help without network). + @visibleForTesting + void onTileLoadError( + TileImage tile, + Object error, + StackTrace? stackTrace, + ) { + // Cancelled tiles are already gone — no retry needed. + if (error is TileLoadCancelledException) return; + + // Offline misses won't resolve by retrying — tile isn't cached. + if (error is TileNotAvailableOfflineException) return; + + debugPrint( + '[TileLayerManager] Tile error at ' + '${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, ' + 'scheduling retry in ${_retryDelay.inSeconds}s', + ); + scheduleRetry(); + } + + /// Schedule a debounced tile reset with exponential backoff. + /// + /// Cancels any pending retry timer and starts a new one at the current + /// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped + /// at [_maxRetryDelay]). + @visibleForTesting + void scheduleRetry() { + _retryTimer?.cancel(); + _retryTimer = Timer(_retryDelay, () { + if (!_resetController.isClosed) { + debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles'); + _resetController.add(null); + } + // Back off for next failure cycle + _retryDelay = Duration( + milliseconds: min( + _retryDelay.inMilliseconds * 2, + _maxRetryDelay.inMilliseconds, + ), + ); + }); + } + + /// Reset backoff to minimum delay. Called when a tile loads successfully + /// via the offline-first path, indicating connectivity has been restored. + /// + /// Note: the common path (`NetworkTileImageProvider`) does not call this, + /// so backoff resets only when the offline-first path succeeds over the + /// network. In practice this is fine — the common path's `RetryClient` + /// handles its own retries, and the reset stream only retries tiles that + /// flutter_map has already marked as `loadError`. + void onTileLoadSuccess() { + _retryDelay = _minRetryDelay; + } + /// 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 +216,58 @@ class TileLayerManager { urlTemplate: urlTemplate, userAgentPackageName: 'me.deflock.deflockapp', maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0, - tileProvider: _tileProvider!, + tileProvider: tileProvider, + // Wire the reset stream so failed tiles get retried after a delay. + reset: _resetController.stream, + errorTileCallback: onTileLoadError, + // Clean up error tiles when they scroll off screen. + evictErrorTileStrategy: EvictErrorTileStrategy.notVisible, ); } + + /// 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: 'https://unknown.invalid/tiles/{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, + onNetworkSuccess: onTileLoadSuccess, + ); + }); + } } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 96ef2b3..1c817a4 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -284,17 +284,12 @@ class MapViewState extends State { onProfilesChanged: _refreshNodesFromProvider, ); - // Check if tile type OR offline mode changed and clear cache if needed - final cacheCleared = _tileManager.checkAndClearCacheIfNeeded( + // Check if provider, tile type, or offline mode changed and clear cache if needed + _tileManager.checkAndClearCacheIfNeeded( + currentProviderId: appState.selectedTileProvider?.id, currentTileTypeId: appState.selectedTileType?.id, currentOfflineMode: appState.offlineMode, ); - - if (cacheCleared) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _tileManager.clearTileQueue(); - }); - } // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -396,7 +391,7 @@ class MapViewState extends State { if (_activePointers > 0) _activePointers--; }, child: FlutterMap( - key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'), + key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'), mapController: _controller.mapController, options: MapOptions( initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), diff --git a/pubspec.lock b/pubspec.lock index 76982e6..b61873a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,7 +178,7 @@ packages: source: hosted version: "0.2.3" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index 03aa82d..bb96369 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.4 + fake_async: ^1.3.0 flutter_launcher_icons: ^0.14.4 flutter_lints: ^6.0.0 flutter_native_splash: ^2.4.6 diff --git a/test/services/deflock_tile_provider_test.dart b/test/services/deflock_tile_provider_test.dart index ee4cd36..140bd0e 100644 --- a/test/services/deflock_tile_provider_test.dart +++ b/test/services/deflock_tile_provider_test.dart @@ -1,46 +1,57 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/app_state.dart'; import 'package:deflockapp/models/tile_provider.dart' as models; import 'package:deflockapp/services/deflock_tile_provider.dart'; +import 'package:deflockapp/services/provider_tile_cache_store.dart'; class MockAppState extends Mock implements AppState {} +class MockMapCachingProvider extends Mock implements MapCachingProvider {} 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 { - await provider.dispose(); + provider.shutdown(); AppState.instance = MockAppState(); }); @@ -49,7 +60,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 +69,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 { + provider.shutdown(); + provider = DeflockTileProvider( + providerId: 'mapbox', + tileType: mapboxTileType, + apiKey: 'test_key_123', ); const coords = TileCoordinates(1, 2, 10); @@ -86,19 +86,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 +123,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 +157,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 +167,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); @@ -247,5 +274,298 @@ void main() { expect(base, isNot(equals(diffType))); expect(base.hashCode, isNot(equals(diffType.hashCode))); }); + + test('equality ignores cachingProvider and onNetworkSuccess', () { + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancel = Future.value(); + + final withCaching = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + cachingProvider: MockMapCachingProvider(), + onNetworkSuccess: () {}, + ); + final withoutCaching = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + + expect(withCaching, equals(withoutCaching)); + expect(withCaching.hashCode, equals(withoutCaching.hashCode)); + }); + }); + + group('DeflockTileProvider caching integration', () { + test('passes cachingProvider through to offline path', () { + when(() => mockAppState.offlineMode).thenReturn(true); + + final mockCaching = MockMapCachingProvider(); + var successCalled = false; + + final cachingProvider = DeflockTileProvider( + providerId: 'openstreetmap', + tileType: osmTileType, + cachingProvider: mockCaching, + onNetworkSuccess: () => successCalled = true, + ); + + const coords = TileCoordinates(5, 10, 12); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancelLoading = Future.value(); + + final imageProvider = cachingProvider.getImageWithCancelLoadingSupport( + coords, + options, + cancelLoading, + ); + + expect(imageProvider, isA()); + final offlineProvider = imageProvider as DeflockOfflineTileImageProvider; + expect(offlineProvider.cachingProvider, same(mockCaching)); + expect(offlineProvider.onNetworkSuccess, isNotNull); + + // Invoke the callback to verify it's wired correctly + offlineProvider.onNetworkSuccess!(); + expect(successCalled, isTrue); + + cachingProvider.shutdown(); + }); + + test('offline provider has null caching when not provided', () { + when(() => mockAppState.offlineMode).thenReturn(true); + + const coords = TileCoordinates(5, 10, 12); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancelLoading = Future.value(); + + final imageProvider = provider.getImageWithCancelLoadingSupport( + coords, + options, + cancelLoading, + ); + + expect(imageProvider, isA()); + final offlineProvider = imageProvider as DeflockOfflineTileImageProvider; + expect(offlineProvider.cachingProvider, isNull); + expect(offlineProvider.onNetworkSuccess, isNull); + }); + }); + + group('DeflockOfflineTileImageProvider caching helpers', () { + late Directory tempDir; + late ProviderTileCacheStore cacheStore; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('tile_cache_test_'); + cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('disk cache integration: putTile then getTile round-trip', () async { + const url = 'https://tile.example.com/3/1/2.png'; + final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: DateTime.utc(2026, 2, 20), + etag: '"tile-etag"', + ); + + // Write to cache + await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes); + + // Read back + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); + expect(cached.metadata.etag, equals('"tile-etag"')); + expect(cached.metadata.isStale, isFalse); + }); + + test('disk cache: stale tiles are detectable', () async { + const url = 'https://tile.example.com/stale.png'; + final bytes = Uint8List.fromList([1, 2, 3]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + lastModified: null, + etag: null, + ); + + await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.metadata.isStale, isTrue); + // Bytes are still available even when stale (for conditional revalidation) + expect(cached.bytes, equals(bytes)); + }); + + test('disk cache: metadata-only update preserves bytes', () async { + const url = 'https://tile.example.com/revalidated.png'; + final bytes = Uint8List.fromList([10, 20, 30]); + + // Initial write with bytes + await cacheStore.putTile( + url: url, + metadata: CachedMapTileMetadata( + staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + lastModified: null, + etag: '"v1"', + ), + bytes: bytes, + ); + + // Metadata-only update (simulating 304 Not Modified revalidation) + await cacheStore.putTile( + url: url, + metadata: CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: null, + etag: '"v2"', + ), + // No bytes — metadata only + ); + + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); // original bytes preserved + expect(cached.metadata.etag, equals('"v2"')); // metadata updated + expect(cached.metadata.isStale, isFalse); // now fresh + }); + }); + + group('DeflockOfflineTileImageProvider load error paths', () { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + /// Load the tile via [loadImage] and return the first error from the + /// image stream. The decode callback should never be reached on error + /// paths, so we throw if it is. + Future loadAndExpectError( + DeflockOfflineTileImageProvider provider) { + final completer = Completer(); + final stream = provider.loadImage( + provider, + (buffer, {getTargetSize}) async => + throw StateError('decode should not be called'), + ); + stream.addListener(ImageStreamListener( + (_, _) { + if (!completer.isCompleted) { + completer + .completeError(StateError('expected error but got image')); + } + }, + onError: (error, _) { + if (!completer.isCompleted) completer.complete(error); + }, + )); + return completer.future; + } + + test('offline both-miss throws TileNotAvailableOfflineException', + () async { + // No offline areas, no cache → both miss. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Completer().future, // never cancels + isOfflineOnly: true, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('cancelled offline tile throws TileLoadCancelledException', + () async { + // cancelLoading already resolved → _loadAsync catch block detects + // cancellation and throws TileLoadCancelledException instead of + // the underlying TileNotAvailableOfflineException. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Future.value(), // already cancelled + isOfflineOnly: true, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('online cancel before network throws TileLoadCancelledException', + () async { + // Online mode: cache miss, local miss, then cancelled check fires + // before reaching the network fetch. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Future.value(), // already cancelled + isOfflineOnly: false, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('network error throws HttpException', () async { + // Online mode: cache miss, local miss, not cancelled, network + // returns 500 → HttpException with tile coordinates and status. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(4, 5, 6), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: MockClient((_) async => http.Response('', 500)), + headers: const {}, + cancelLoading: Completer().future, // never cancels + isOfflineOnly: false, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/6/4/5.png', + ), + ); + + expect(error, isA()); + expect((error as HttpException).message, contains('6/4/5')); + expect(error.message, contains('500')); + }); }); } diff --git a/test/services/offline_area_service_test.dart b/test/services/offline_area_service_test.dart new file mode 100644 index 0000000..54f58e3 --- /dev/null +++ b/test/services/offline_area_service_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +import 'package:deflockapp/services/offline_area_service.dart'; +import 'package:deflockapp/services/offline_areas/offline_area_models.dart'; + +OfflineArea _makeArea({ + String providerId = 'osm', + String tileTypeId = 'standard', + int minZoom = 5, + int maxZoom = 12, + OfflineAreaStatus status = OfflineAreaStatus.complete, +}) { + return OfflineArea( + id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom', + bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)), + minZoom: minZoom, + maxZoom: maxZoom, + directory: '/tmp/test-area', + status: status, + tileProviderId: providerId, + tileTypeId: tileTypeId, + ); +} + +void main() { + final service = OfflineAreaService(); + + setUp(() { + service.setAreasForTesting([]); + }); + + group('hasOfflineAreasForProviderAtZoom', () { + test('returns true for zoom within range', () { + service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue); + }); + + test('returns false for zoom outside range', () { + service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse); + }); + + test('returns false for wrong provider', () { + service.setAreasForTesting([_makeArea(providerId: 'osm')]); + + expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse); + }); + + test('returns false for wrong tile type', () { + service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse); + }); + + test('returns false for non-complete areas', () { + service.setAreasForTesting([ + _makeArea(status: OfflineAreaStatus.downloading), + _makeArea(status: OfflineAreaStatus.error), + ]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse); + }); + + test('returns false when initialized with no areas', () { + service.setAreasForTesting([]); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse); + }); + + test('matches when any area covers the zoom level', () { + service.setAreasForTesting([ + _makeArea(minZoom: 5, maxZoom: 8), + _makeArea(minZoom: 10, maxZoom: 14), + ]); + + // In first area's range + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue); + // In gap between areas + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse); + // In second area's range + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue); + // Beyond both areas + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse); + }); + }); +} 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 0000000..c1906f4 --- /dev/null +++ b/test/services/provider_tile_cache_store_test.dart @@ -0,0 +1,509 @@ +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('resetAll clears all stores from registry', () async { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeBefore = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + ProviderTileCacheManager.getOrCreate( + providerId: 'bing', + tileTypeId: 'satellite', + policy: const ServicePolicy(), + ); + + await ProviderTileCacheManager.resetAll(); + + // After reset, must set base dir again before creating stores + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + final storeAfter = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + // New instance should be created (not the old cached one) + expect(identical(storeBefore, storeAfter), isFalse); + }); + + 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); + }); + }); + + group('ProviderTileCacheStore eviction', () { + /// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes. + /// Uses small delays between writes so modification times are + /// distinguishable for oldest-modified ordering. + Future fillCache( + ProviderTileCacheStore store, { + required int count, + required int bytesPerTile, + String prefix = '', + }) async { + final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42)); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + for (var i = 0; i < count; i++) { + await store.putTile( + url: 'https://tile.example.com/$prefix$i.png', + metadata: metadata, + bytes: bytes, + ); + // Small delay so modification times are distinguishable for eviction order + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + test('eviction reduces cache when exceeding maxCacheBytes', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write tiles that exceed the limit + await fillCache(store, count: 10, bytesPerTile: 100); + + // Explicitly trigger eviction (bypasses throttle) + await store.forceEviction(); + + final sizeAfter = await store.estimatedSizeBytes; + expect(sizeAfter, lessThanOrEqualTo(500), + reason: 'Eviction should reduce cache to at or below limit'); + }); + + test('eviction targets 80% of maxCacheBytes', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 1000, + ); + + await fillCache(store, count: 10, bytesPerTile: 200); + await store.forceEviction(); + + final sizeAfter = await store.estimatedSizeBytes; + // Target is 80% of 1000 = 800 bytes + expect(sizeAfter, lessThanOrEqualTo(800), + reason: 'Eviction should target 80% of maxCacheBytes'); + }); + + test('oldest-modified tiles are evicted first', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write old tiles first (these should be evicted) + await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_'); + + // Write newer tiles (these should survive) + await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_'); + + await store.forceEviction(); + + // Newest tile should still be present + final newestTile = await store.getTile('https://tile.example.com/new_4.png'); + expect(newestTile, isNotNull, + reason: 'Newest tiles should survive eviction'); + + // Oldest tile should have been evicted + final oldestTile = await store.getTile('https://tile.example.com/old_0.png'); + expect(oldestTile, isNull, + reason: 'Oldest tiles should be evicted first'); + }); + + test('orphan .meta files are cleaned up during eviction', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write a tile to create the directory + await fillCache(store, count: 1, bytesPerTile: 50); + + // Manually create an orphan .meta file (no matching .tile) + final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta')); + await orphanMetaFile.writeAsString('{"staleAt":0}'); + expect(await orphanMetaFile.exists(), isTrue); + + // Write enough tiles to exceed the limit, then force eviction + await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_'); + await store.forceEviction(); + + // The orphan .meta file should have been cleaned up + expect(await orphanMetaFile.exists(), isFalse, + reason: 'Orphan .meta file should be cleaned up during eviction'); + }); + + test('evicted tiles have their .meta files removed too', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 300, + ); + + await fillCache(store, count: 10, bytesPerTile: 100); + await store.forceEviction(); + + // After eviction, count remaining .tile and .meta files + final dir = Directory(tempDir.path); + final files = await dir.list().toList(); + final tileFiles = files + .whereType() + .where((f) => f.path.endsWith('.tile')) + .length; + final metaFiles = files + .whereType() + .where((f) => f.path.endsWith('.meta')) + .length; + + // Every remaining .tile should have a matching .meta (1:1) + expect(metaFiles, equals(tileFiles), + reason: '.meta count should match .tile count after eviction'); + }); + + test('no eviction when cache is under limit', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 100000, // 100KB — way more than we'll write + ); + + await fillCache(store, count: 3, bytesPerTile: 50); + final sizeBefore = await store.estimatedSizeBytes; + + await store.forceEviction(); + final sizeAfter = await store.estimatedSizeBytes; + + expect(sizeAfter, equals(sizeBefore), + reason: 'No eviction needed when under limit'); + }); + }); +} diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart new file mode 100644 index 0000000..59a1287 --- /dev/null +++ b/test/services/service_policy_test.dart @@ -0,0 +1,426 @@ +import 'package:fake_async/fake_async.dart'; +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', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var acquireCount = 0; + + // First request should be immediate + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + async.flushMicrotasks(); + expect(acquireCount, 1); + + // Second request should be delayed by ~1 second + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + async.flushMicrotasks(); + expect(acquireCount, 1, reason: 'second acquire should be blocked'); + + // Advance past the 1-second rate limit + async.elapse(const Duration(seconds: 1)); + expect(acquireCount, 2, reason: 'second acquire should have completed'); + }); + }); + + test('services with no rate limit pass through immediately', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var acquireCount = 0; + + // Overpass has maxConcurrentRequests: 0, so acquire should not apply + // any artificial rate limiting delays. + ServiceRateLimiter.acquire(ServiceType.overpass).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.overpass); + }); + async.flushMicrotasks(); + expect(acquireCount, 1); + + ServiceRateLimiter.acquire(ServiceType.overpass).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.overpass); + }); + async.flushMicrotasks(); + expect(acquireCount, 2); + }); + }); + + test('Nominatim enforces min interval under concurrent callers', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var completedCount = 0; + + // Start two concurrent callers; only one should run at a time and + // the minRequestInterval of ~1s should still be enforced. + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + completedCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + completedCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + + async.flushMicrotasks(); + expect(completedCount, 1, reason: 'only first caller should complete immediately'); + + // Advance past the 1-second rate limit + async.elapse(const Duration(seconds: 1)); + expect(completedCount, 2, reason: 'second caller should complete after interval'); + }); + }); + }); + + 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'); + }); + }); +} diff --git a/test/services/tiles_from_local_test.dart b/test/services/tiles_from_local_test.dart new file mode 100644 index 0000000..767647f --- /dev/null +++ b/test/services/tiles_from_local_test.dart @@ -0,0 +1,227 @@ +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:deflockapp/services/map_data_submodules/tiles_from_local.dart'; +import 'package:deflockapp/services/offline_areas/offline_tile_utils.dart'; + +void main() { + group('normalizeBounds', () { + test('swapped corners are normalized', () { + // NE as first arg, SW as second (swapped) + final swapped = LatLngBounds( + const LatLng(52.0, 1.0), // NE corner passed as SW + const LatLng(51.0, -1.0), // SW corner passed as NE + ); + final normalized = normalizeBounds(swapped); + expect(normalized.south, closeTo(51.0, 1e-6)); + expect(normalized.north, closeTo(52.0, 1e-6)); + expect(normalized.west, closeTo(-1.0, 1e-6)); + expect(normalized.east, closeTo(1.0, 1e-6)); + }); + + test('degenerate (zero-width) bounds are expanded', () { + final point = LatLngBounds( + const LatLng(51.5, -0.1), + const LatLng(51.5, -0.1), + ); + final normalized = normalizeBounds(point); + expect(normalized.south, lessThan(51.5)); + expect(normalized.north, greaterThan(51.5)); + expect(normalized.west, lessThan(-0.1)); + expect(normalized.east, greaterThan(-0.1)); + }); + + test('already-normalized bounds are unchanged', () { + final normal = LatLngBounds( + const LatLng(40.0, -10.0), + const LatLng(60.0, 30.0), + ); + final normalized = normalizeBounds(normal); + expect(normalized.south, closeTo(40.0, 1e-6)); + expect(normalized.north, closeTo(60.0, 1e-6)); + expect(normalized.west, closeTo(-10.0, 1e-6)); + expect(normalized.east, closeTo(30.0, 1e-6)); + }); + }); + + group('tileInBounds', () { + /// Helper: compute expected tile range for [bounds] at [z] using the same + /// Mercator projection math and return whether (x, y) is within range. + bool referenceTileInBounds( + LatLngBounds bounds, int z, int x, int y) { + final n = pow(2.0, z); + final minX = ((bounds.west + 180.0) / 360.0 * n).floor(); + final maxX = ((bounds.east + 180.0) / 360.0 * n).floor(); + final minY = ((1.0 - + log(tan(bounds.north * pi / 180.0) + + 1.0 / cos(bounds.north * pi / 180.0)) / + pi) / + 2.0 * + n) + .floor(); + final maxY = ((1.0 - + log(tan(bounds.south * pi / 180.0) + + 1.0 / cos(bounds.south * pi / 180.0)) / + pi) / + 2.0 * + n) + .floor(); + return x >= minX && x <= maxX && y >= minY && y <= maxY; + } + + test('zoom 0: single tile covers the whole world', () { + final world = LatLngBounds( + const LatLng(-85, -180), + const LatLng(85, 180), + ); + expect(tileInBounds(world, 0, 0, 0), isTrue); + }); + + test('zoom 1: London area covers NW and NE quadrants', () { + // Bounds straddling the prime meridian in the northern hemisphere + final londonArea = LatLngBounds( + const LatLng(51.0, -1.0), + const LatLng(52.0, 1.0), + ); + + // NW quadrant (x=0, y=0) — should be in bounds + expect(tileInBounds(londonArea, 1, 0, 0), isTrue); + // NE quadrant (x=1, y=0) — should be in bounds + expect(tileInBounds(londonArea, 1, 1, 0), isTrue); + // SW quadrant (x=0, y=1) — southern hemisphere, out of bounds + expect(tileInBounds(londonArea, 1, 0, 1), isFalse); + // SE quadrant (x=1, y=1) — southern hemisphere, out of bounds + expect(tileInBounds(londonArea, 1, 1, 1), isFalse); + }); + + test('zoom 2: London area covers specific tiles', () { + final londonArea = LatLngBounds( + const LatLng(51.0, -1.0), + const LatLng(52.0, 1.0), + ); + + // Expected: X 1-2, Y 1 + expect(tileInBounds(londonArea, 2, 1, 1), isTrue); + expect(tileInBounds(londonArea, 2, 2, 1), isTrue); + // Outside X range + expect(tileInBounds(londonArea, 2, 0, 1), isFalse); + expect(tileInBounds(londonArea, 2, 3, 1), isFalse); + // Outside Y range + expect(tileInBounds(londonArea, 2, 1, 0), isFalse); + expect(tileInBounds(londonArea, 2, 1, 2), isFalse); + }); + + test('southern hemisphere: Sydney area', () { + final sydneyArea = LatLngBounds( + const LatLng(-34.0, 151.0), + const LatLng(-33.5, 151.5), + ); + + // At zoom 1, Sydney is in the SE quadrant (x=1, y=1) + expect(tileInBounds(sydneyArea, 1, 1, 1), isTrue); + expect(tileInBounds(sydneyArea, 1, 0, 0), isFalse); + expect(tileInBounds(sydneyArea, 1, 0, 1), isFalse); + expect(tileInBounds(sydneyArea, 1, 1, 0), isFalse); + }); + + test('western hemisphere: NYC area at zoom 4', () { + final nycArea = LatLngBounds( + const LatLng(40.5, -74.5), + const LatLng(41.0, -73.5), + ); + + // At zoom 4 (16x16), NYC should be around x=4-5, y=6 + // x = floor((-74.5+180)/360 * 16) = floor(105.5/360*16) = floor(4.69) = 4 + // x = floor((-73.5+180)/360 * 16) = floor(106.5/360*16) = floor(4.73) = 4 + // So x range is just 4 + expect(tileInBounds(nycArea, 4, 4, 6), isTrue); + expect(tileInBounds(nycArea, 4, 5, 6), isFalse); + expect(tileInBounds(nycArea, 4, 3, 6), isFalse); + }); + + test('higher zoom: smaller area at zoom 10', () { + // Small area around central London + final centralLondon = LatLngBounds( + const LatLng(51.49, -0.13), + const LatLng(51.52, -0.08), + ); + + // Compute expected tile range at zoom 10 using reference + const z = 10; + final n = pow(2.0, z); + final expectedMinX = + ((-0.13 + 180.0) / 360.0 * n).floor(); + final expectedMaxX = + ((-0.08 + 180.0) / 360.0 * n).floor(); + + // Tiles inside the computed range should be in bounds + for (var x = expectedMinX; x <= expectedMaxX; x++) { + expect( + referenceTileInBounds(centralLondon, z, x, 340), + equals(tileInBounds(centralLondon, z, x, 340)), + reason: 'Mismatch at tile ($x, 340, $z)', + ); + } + + // Tiles outside X range should not be in bounds + expect(tileInBounds(centralLondon, z, expectedMinX - 1, 340), isFalse); + expect(tileInBounds(centralLondon, z, expectedMaxX + 1, 340), isFalse); + }); + + test('tile exactly at boundary is included', () { + // Bounds whose edges align exactly with tile boundaries at zoom 1 + // At zoom 1: x=0 covers lon -180 to 0, x=1 covers lon 0 to 180 + final halfWorld = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(60.0, 180.0), + ); + + // Tile (1, 0, 1) should be in bounds (NE quadrant) + expect(tileInBounds(halfWorld, 1, 1, 0), isTrue); + }); + + test('anti-meridian: bounds crossing 180° longitude', () { + // Bounds from eastern Russia (170°E) to Alaska (170°W = -170°) + // After normalization, west=170 east=-170 which is swapped — + // normalizeBounds will swap to west=-170 east=170, which covers + // nearly the whole world. This is the expected behavior since + // LatLngBounds doesn't support anti-meridian wrapping. + final antiMeridian = normalizeBounds(LatLngBounds( + const LatLng(50.0, 170.0), + const LatLng(70.0, -170.0), + )); + + // After normalization, west=-170 east=170 (covers most longitudes) + // At zoom 2, tiles 0-3 along X axis + // Since the normalized bounds cover lon -170 to 170 (340° of 360°), + // almost all tiles should be in bounds + expect(tileInBounds(antiMeridian, 2, 0, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 1, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 2, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 3, 0), isTrue); + }); + + test('exhaustive check at zoom 3 matches reference', () { + final bounds = LatLngBounds( + const LatLng(40.0, -10.0), + const LatLng(60.0, 30.0), + ); + + // Check all 64 tiles at zoom 3 against reference implementation + const z = 3; + final tilesPerSide = pow(2, z).toInt(); + for (var x = 0; x < tilesPerSide; x++) { + for (var y = 0; y < tilesPerSide; y++) { + expect( + tileInBounds(bounds, z, x, y), + equals(referenceTileInBounds(bounds, z, x, y)), + reason: 'Mismatch at tile ($x, $y, $z)', + ); + } + } + }); + }); +} diff --git a/test/widgets/map/tile_layer_manager_test.dart b/test/widgets/map/tile_layer_manager_test.dart new file mode 100644 index 0000000..43a80b2 --- /dev/null +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -0,0 +1,487 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/services/deflock_tile_provider.dart'; +import 'package:deflockapp/widgets/map/tile_layer_manager.dart'; + +class MockTileImage extends Mock implements TileImage {} + +void main() { + group('TileLayerManager exponential backoff', () { + test('initial retry delay is 2 seconds', () { + final manager = TileLayerManager(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + manager.dispose(); + }); + + test('scheduleRetry fires reset stream after delay', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.scheduleRetry(); + + expect(resets, isEmpty); + async.elapse(const Duration(seconds: 1)); + expect(resets, isEmpty); + async.elapse(const Duration(seconds: 1)); + expect(resets, hasLength(1)); + + manager.dispose(); + }); + }); + + test('delay doubles after each retry fires', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // First retry: 2s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + expect(manager.retryDelay, equals(const Duration(seconds: 4))); + + // Second retry: 4s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Third retry: 8s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 8)); + expect(manager.retryDelay, equals(const Duration(seconds: 16))); + + manager.dispose(); + }); + }); + + test('delay caps at 60 seconds', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // Drive through cycles: 2 → 4 → 8 → 16 → 32 → 60 → 60 + var currentDelay = manager.retryDelay; + while (currentDelay < const Duration(seconds: 60)) { + manager.scheduleRetry(); + async.elapse(currentDelay); + currentDelay = manager.retryDelay; + } + + // Should be capped at 60s + expect(manager.retryDelay, equals(const Duration(seconds: 60))); + + // Another cycle stays at 60s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 60)); + expect(manager.retryDelay, equals(const Duration(seconds: 60))); + + manager.dispose(); + }); + }); + + test('onTileLoadSuccess resets delay to minimum', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // Drive up the delay + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + expect(manager.retryDelay, equals(const Duration(seconds: 4))); + + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Reset on success + manager.onTileLoadSuccess(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + + manager.dispose(); + }); + }); + + test('rapid errors debounce: only last timer fires', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + // Fire 3 errors in quick succession (each cancels the previous timer) + manager.scheduleRetry(); + async.elapse(const Duration(milliseconds: 500)); + manager.scheduleRetry(); + async.elapse(const Duration(milliseconds: 500)); + manager.scheduleRetry(); + + // 1s elapsed total since first error, but last timer started 0ms ago + // Need to wait 2s from *last* scheduleRetry call + async.elapse(const Duration(seconds: 1)); + expect(resets, isEmpty, reason: 'Timer should not fire yet'); + async.elapse(const Duration(seconds: 1)); + expect(resets, hasLength(1), reason: 'Only one reset should fire'); + + manager.dispose(); + }); + }); + + test('delay stays at minimum if no retries have fired', () { + final manager = TileLayerManager(); + // Just calling onTileLoadSuccess without any errors + manager.onTileLoadSuccess(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + manager.dispose(); + }); + + test('backoff progression: 2 → 4 → 8 → 16 → 32 → 60 → 60', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + final expectedDelays = [ + const Duration(seconds: 2), + const Duration(seconds: 4), + const Duration(seconds: 8), + const Duration(seconds: 16), + const Duration(seconds: 32), + const Duration(seconds: 60), + const Duration(seconds: 60), // capped + ]; + + for (var i = 0; i < expectedDelays.length; i++) { + expect(manager.retryDelay, equals(expectedDelays[i]), + reason: 'Step $i'); + manager.scheduleRetry(); + async.elapse(expectedDelays[i]); + } + + manager.dispose(); + }); + }); + + test('dispose cancels pending retry timer', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + late StreamSubscription sub; + sub = manager.resetStream.listen((_) => resets.add(null)); + + manager.scheduleRetry(); + // Dispose before timer fires + sub.cancel(); + manager.dispose(); + + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty, reason: 'Timer should be cancelled by dispose'); + }); + }); + }); + + group('TileLayerManager checkAndClearCacheIfNeeded', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + test('first call triggers clear (initial null differs from provided values)', () { + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // First call: internal state is (null, null, false) → (osm, street, false) + // provider null→osm triggers clear. Harmless: no tiles to clear yet. + expect(result, isTrue); + }); + + test('same values on second call returns false', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(result, isFalse); + }); + + test('different provider triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('different tile type triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('different offline mode triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: true, + ); + expect(result, isTrue); + }); + + test('cache clear increments mapRebuildKey', () { + final initialKey = manager.mapRebuildKey; + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // First call increments (null → osm) + expect(manager.mapRebuildKey, equals(initialKey + 1)); + + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ); + // Type change should increment again + expect(manager.mapRebuildKey, equals(initialKey + 2)); + }); + + test('no cache clear does not increment mapRebuildKey', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final keyAfterFirst = manager.mapRebuildKey; + + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(manager.mapRebuildKey, equals(keyAfterFirst)); + }); + + test('null to non-null transition triggers clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: null, + currentTileTypeId: null, + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // null → osm is a change — triggers clear so stale tiles are flushed + expect(result, isTrue); + }); + + test('non-null to null to non-null triggers clear both times', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + // Provider goes null (e.g., during reload) + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: null, + currentTileTypeId: null, + currentOfflineMode: false, + ), + isTrue, + ); + + // Provider returns — should still trigger clear + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ), + isTrue, + ); + }); + + test('switching back and forth triggers clear each time', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ), + isTrue, + ); + + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ), + isTrue, + ); + }); + + test('switching providers with same tile type triggers clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'standard', + currentOfflineMode: false, + ); + + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'standard', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('provider switch resets retry delay and cancels pending timer', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + // Escalate backoff: 2s → 4s → 8s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Start another retry timer (hasn't fired yet) + manager.scheduleRetry(); + + // Switch provider — should reset delay and cancel pending timer + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + + // The pending 8s timer should have been cancelled + final resetsBefore = resets.length; + async.elapse(const Duration(seconds: 10)); + expect(resets.length, equals(resetsBefore), + reason: 'Old retry timer should be cancelled on provider switch'); + }); + }); + }); + + group('TileLayerManager error-type filtering', () { + late TileLayerManager manager; + late MockTileImage mockTile; + + setUp(() { + manager = TileLayerManager(); + mockTile = MockTileImage(); + when(() => mockTile.coordinates) + .thenReturn(const TileCoordinates(1, 2, 3)); + }); + + tearDown(() { + manager.dispose(); + }); + + test('skips retry for TileLoadCancelledException', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const TileLoadCancelledException(), + null, + ); + + // Even after waiting well past the retry delay, no reset should fire. + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty); + }); + }); + + test('skips retry for TileNotAvailableOfflineException', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const TileNotAvailableOfflineException(), + null, + ); + + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty); + }); + }); + + test('schedules retry for other errors (e.g. HttpException)', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const HttpException('tile fetch failed'), + null, + ); + + // Should fire after the initial 2s retry delay. + async.elapse(const Duration(seconds: 2)); + expect(resets, hasLength(1)); + }); + }); + }); +} From f3f40f36ef866b4de5489d1de1235af333ec27ba Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 4 Mar 2026 09:47:42 -0700 Subject: [PATCH 2/3] Allow OSM offline downloads, disable button for restricted providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow offline area downloads for OSM tile server. Move the "downloads not permitted" check from inside the download dialog to the download button itself — the button is now disabled (greyed out) when the current tile type doesn't support offline downloads. Co-Authored-By: Claude Opus 4.6 --- lib/screens/home_screen.dart | 60 ++++++++++++++------------ lib/services/service_policy.dart | 4 +- lib/widgets/download_area_dialog.dart | 36 ---------------- test/services/service_policy_test.dart | 8 ++-- 4 files changed, 38 insertions(+), 70 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ff9702e..acc3e03 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -578,37 +578,41 @@ class _HomeScreenState extends State with TickerProviderStateMixin { flex: 3, // 30% for secondary action child: AnimatedBuilder( animation: LocalizationService.instance, - builder: (context, child) => FittedBox( - fit: BoxFit.scaleDown, - child: ElevatedButton.icon( - icon: Icon(Icons.download_for_offline), - label: Text(LocalizationService.instance.download), - onPressed: () { - // Check minimum zoom level before opening download dialog - final currentZoom = _mapController.mapController.camera.zoom; - if (currentZoom < kMinZoomForOfflineDownload) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocalizationService.instance.t('download.areaTooBigMessage', - params: [kMinZoomForOfflineDownload.toString()]) + builder: (context, child) { + final appState = context.watch(); + final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false; + return FittedBox( + fit: BoxFit.scaleDown, + child: ElevatedButton.icon( + icon: Icon(Icons.download_for_offline), + label: Text(LocalizationService.instance.download), + onPressed: canDownload ? () { + // Check minimum zoom level before opening download dialog + final currentZoom = _mapController.mapController.camera.zoom; + if (currentZoom < kMinZoomForOfflineDownload) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + LocalizationService.instance.t('download.areaTooBigMessage', + params: [kMinZoomForOfflineDownload.toString()]) + ), ), - ), + ); + return; + } + + showDialog( + context: context, + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), ); - return; - } - - showDialog( - context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), - ); - }, - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), + } : null, + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), ), - ), - ), + ); + }, ), ), ], diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index e8990a3..078e7a7 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -70,13 +70,13 @@ class ServicePolicy { attributionUrl = null; /// OSM tile server (tile.openstreetmap.org) - /// Policy: no offline/bulk downloading, min 7-day cache, must honor cache headers. + /// Policy: 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, + allowsOfflineDownload = true, requiresClientCaching = true, minCacheTtl = const Duration(days: 7), attributionUrl = 'https://www.openstreetmap.org/copyright'; diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index cdda1d0..a370aaf 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -267,42 +267,6 @@ class _DownloadAreaDialogState extends State { 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; - } - // Guard: provider and tile type must be non-null for a // useful offline area (fetchLocalTile requires exact match). if (selectedProvider == null || selectedTileType == null) { diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index 59a1287..bfe31e4 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -96,11 +96,11 @@ void main() { }); group('resolve', () { - test('OSM tile server policy disallows offline download', () { + test('OSM tile server policy allows offline download', () { final policy = ServicePolicyResolver.resolve( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ); - expect(policy.allowsOfflineDownload, false); + expect(policy.allowsOfflineDownload, true); }); test('OSM tile server policy requires 7-day min cache TTL', () { @@ -175,7 +175,7 @@ void main() { final policy = ServicePolicyResolver.resolve( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', ); - expect(policy.allowsOfflineDownload, false); + expect(policy.allowsOfflineDownload, true); }); test('handles {quadkey} template variable', () { @@ -381,7 +381,7 @@ void main() { group('ServicePolicy', () { test('osmTileServer policy has correct values', () { const policy = ServicePolicy.osmTileServer(); - expect(policy.allowsOfflineDownload, false); + expect(policy.allowsOfflineDownload, true); expect(policy.minCacheTtl, const Duration(days: 7)); expect(policy.requiresClientCaching, true); expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); From 91e517705608bca5efabdce314111c7cfc98a72a Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Wed, 4 Mar 2026 10:12:49 -0700 Subject: [PATCH 3/3] Detect config drift in cached tile providers and replace stale instances When a user edits a tile type's URL template, max zoom, or API key without changing IDs, the cached DeflockTileProvider would keep the old frozen config. Now _getOrCreateProvider() computes a config fingerprint and replaces the provider when drift is detected. Co-Authored-By: Claude Opus 4.6 --- lib/services/deflock_tile_provider.dart | 7 + lib/services/provider_tile_cache_manager.dart | 7 +- lib/services/provider_tile_cache_store.dart | 8 +- lib/services/service_policy.dart | 10 +- lib/widgets/map/tile_layer_manager.dart | 31 +++++ .../provider_tile_cache_store_test.dart | 16 ++- test/widgets/map/tile_layer_manager_test.dart | 131 ++++++++++++++++++ 7 files changed, 197 insertions(+), 13 deletions(-) diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 9ab86cb..7b1d6ce 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -52,6 +52,10 @@ class DeflockTileProvider extends NetworkTileProvider { final models.TileType tileType; final String? apiKey; + /// Opaque fingerprint of the config this provider was created with. + /// Used by [TileLayerManager] to detect config drift after edits. + final String configFingerprint; + /// Caching provider for the offline-first path. The same instance is passed /// to super for the common path — we keep a reference here so we can also /// use it in [DeflockOfflineTileImageProvider]. @@ -69,6 +73,7 @@ class DeflockTileProvider extends NetworkTileProvider { this.apiKey, MapCachingProvider? cachingProvider, this.onNetworkSuccess, + this.configFingerprint = '', }) : _sharedHttpClient = httpClient, _cachingProvider = cachingProvider, super( @@ -87,6 +92,7 @@ class DeflockTileProvider extends NetworkTileProvider { String? apiKey, MapCachingProvider? cachingProvider, VoidCallback? onNetworkSuccess, + String configFingerprint = '', }) { final client = UserAgentClient(RetryClient(Client())); return DeflockTileProvider._( @@ -96,6 +102,7 @@ class DeflockTileProvider extends NetworkTileProvider { apiKey: apiKey, cachingProvider: cachingProvider, onNetworkSuccess: onNetworkSuccess, + configFingerprint: configFingerprint, ); } diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart index 54e99d3..cdbce71 100644 --- a/lib/services/provider_tile_cache_manager.dart +++ b/lib/services/provider_tile_cache_manager.dart @@ -37,8 +37,11 @@ class ProviderTileCacheManager { required ServicePolicy policy, int? maxCacheBytes, }) { - assert(_baseCacheDir != null, - 'ProviderTileCacheManager.init() must be called before getOrCreate()'); + if (_baseCacheDir == null) { + throw StateError( + 'ProviderTileCacheManager.init() must be called before getOrCreate()', + ); + } final key = '$providerId/$tileTypeId'; if (_stores.containsKey(key)) return _stores[key]!; diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart index bf23e15..192a13a 100644 --- a/lib/services/provider_tile_cache_store.dart +++ b/lib/services/provider_tile_cache_store.dart @@ -47,7 +47,7 @@ class ProviderTileCacheStore implements MapCachingProvider { @override Future getTile(String url) async { - final key = _keyFor(url); + final key = keyFor(url); final tileFile = File(p.join(cacheDirectory, '$key.tile')); final metaFile = File(p.join(cacheDirectory, '$key.meta')); @@ -90,7 +90,7 @@ class ProviderTileCacheStore implements MapCachingProvider { }) async { await _ensureDirectory(); - final key = _keyFor(url); + final key = keyFor(url); final tileFile = File(p.join(cacheDirectory, '$key.tile')); final metaFile = File(p.join(cacheDirectory, '$key.meta')); @@ -158,7 +158,8 @@ class ProviderTileCacheStore implements MapCachingProvider { } /// 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); + @visibleForTesting + static String keyFor(String url) => _uuid.v5(Namespace.url.value, url); /// Estimate total cache size (lazy, first call scans directory). Future _getEstimatedSize() async { @@ -301,6 +302,7 @@ class ProviderTileCacheStore implements MapCachingProvider { } _estimatedSize = null; _directoryReady = null; // Allow lazy re-creation + _lastPruneCheck = null; // Reset throttle so next write can trigger eviction } /// Get the current estimated cache size in bytes. diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 078e7a7..acf8a24 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -315,10 +315,12 @@ class ServiceRateLimiter { 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. + // Concurrency: acquire a semaphore slot first so that at most + // [policy.maxConcurrentRequests] callers proceed concurrently. + // The min-interval check below is only race-free when + // maxConcurrentRequests == 1 (currently only Nominatim). For services + // with higher concurrency the interval is approximate, which is + // acceptable — their policies don't specify a min interval. _Semaphore? semaphore; if (policy.maxConcurrentRequests > 0) { semaphore = _semaphores.putIfAbsent( diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index fcf74d9..78cda5e 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -225,7 +225,24 @@ class TileLayerManager { ); } + /// Build a config fingerprint for drift detection. + /// + /// If any of these fields change (e.g. user edits the URL template or + /// rotates an API key) the cached [DeflockTileProvider] must be replaced. + static String _configFingerprint( + models.TileProvider provider, + models.TileType tileType, + ) => + '${provider.id}/${tileType.id}' + '|${tileType.urlTemplate}' + '|${tileType.maxZoom}' + '|${provider.apiKey ?? ''}'; + /// Get or create a [DeflockTileProvider] for the given provider/type. + /// + /// Providers are cached by `providerId/tileTypeId`. If the effective config + /// (URL template, max zoom, API key) has changed since the provider was + /// created, the stale instance is shut down and replaced. DeflockTileProvider _getOrCreateProvider({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, @@ -247,6 +264,19 @@ class TileLayerManager { } final key = '${selectedProvider.id}/${selectedTileType.id}'; + final fingerprint = _configFingerprint(selectedProvider, selectedTileType); + + // Check for config drift: if the provider exists but its config has + // changed, shut down the stale instance so a fresh one is created below. + final existing = _providers[key]; + if (existing != null && existing.configFingerprint != fingerprint) { + debugPrint( + '[TileLayerManager] Config changed for $key — replacing provider', + ); + existing.shutdown(); + _providers.remove(key); + } + return _providers.putIfAbsent(key, () { final cachingProvider = ProviderTileCacheManager.isInitialized ? ProviderTileCacheManager.getOrCreate( @@ -267,6 +297,7 @@ class TileLayerManager { apiKey: selectedProvider.apiKey, cachingProvider: cachingProvider, onNetworkSuccess: onTileLoadSuccess, + configFingerprint: fingerprint, ); }); } diff --git a/test/services/provider_tile_cache_store_test.dart b/test/services/provider_tile_cache_store_test.dart index c1906f4..e0f974d 100644 --- a/test/services/provider_tile_cache_store_test.dart +++ b/test/services/provider_tile_cache_store_test.dart @@ -359,8 +359,8 @@ void main() { group('ProviderTileCacheStore eviction', () { /// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes. - /// Uses small delays between writes so modification times are - /// distinguishable for oldest-modified ordering. + /// Sets deterministic modification times (1 second apart) so eviction + /// ordering is stable across platforms without relying on wall-clock delays. Future fillCache( ProviderTileCacheStore store, { required int count, @@ -373,14 +373,22 @@ void main() { lastModified: null, etag: null, ); + final baseTime = DateTime.utc(2026, 1, 1); for (var i = 0; i < count; i++) { await store.putTile( url: 'https://tile.example.com/$prefix$i.png', metadata: metadata, bytes: bytes, ); - // Small delay so modification times are distinguishable for eviction order - await Future.delayed(const Duration(milliseconds: 10)); + // Set deterministic mtime so eviction order is stable across platforms. + final key = ProviderTileCacheStore.keyFor( + 'https://tile.example.com/$prefix$i.png', + ); + final tileFile = File(p.join(store.cacheDirectory, '$key.tile')); + final metaFile = File(p.join(store.cacheDirectory, '$key.meta')); + final mtime = baseTime.add(Duration(seconds: i)); + await tileFile.setLastModified(mtime); + await metaFile.setLastModified(mtime); } } diff --git a/test/widgets/map/tile_layer_manager_test.dart b/test/widgets/map/tile_layer_manager_test.dart index 43a80b2..3a9183b 100644 --- a/test/widgets/map/tile_layer_manager_test.dart +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:deflockapp/models/tile_provider.dart' as models; import 'package:deflockapp/services/deflock_tile_provider.dart'; import 'package:deflockapp/widgets/map/tile_layer_manager.dart'; @@ -419,6 +420,136 @@ void main() { }); }); + group('TileLayerManager config drift detection', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + models.TileProvider makeProvider({String? apiKey}) => models.TileProvider( + id: 'test_provider', + name: 'Test', + apiKey: apiKey, + tileTypes: [], + ); + + models.TileType makeTileType({ + String urlTemplate = 'https://example.com/{z}/{x}/{y}.png', + int maxZoom = 18, + }) => + models.TileType( + id: 'test_tile', + name: 'Test', + urlTemplate: urlTemplate, + attribution: 'Test', + maxZoom: maxZoom, + ); + + test('returns same provider for identical config', () { + final provider = makeProvider(); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isTrue, + reason: 'Same config should return the cached provider instance', + ); + }); + + test('replaces provider when urlTemplate changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType( + urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png', + ); + final tileTypeV2 = makeTileType( + urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png', + ); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed urlTemplate should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate, + 'https://new.example.com/{z}/{x}/{y}.png', + ); + }); + + test('replaces provider when apiKey changes', () { + final providerV1 = makeProvider(apiKey: 'old_key'); + final providerV2 = makeProvider(apiKey: 'new_key'); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: providerV1, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: providerV2, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed apiKey should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).apiKey, + 'new_key', + ); + }); + + test('replaces provider when maxZoom changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType(maxZoom: 18); + final tileTypeV2 = makeTileType(maxZoom: 20); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed maxZoom should create a new provider', + ); + }); + }); + group('TileLayerManager error-type filtering', () { late TileLayerManager manager; late MockTileImage mockTile;