diff --git a/.gitignore b/.gitignore index d03c8937..db2e32aa 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/dev_config.dart b/lib/dev_config.dart index 44294c69..19d9f046 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -142,7 +142,7 @@ const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance b const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km) // Node display configuration -const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once +const int kDefaultMaxNodes = 2000; // Default maximum number of nodes to render on the map at once (clustering handles visual density) // NSI (Name Suggestion Index) configuration const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful @@ -163,8 +163,31 @@ const int kMaxReasonableTileCount = 20000; const int kAbsoluteMaxTileCount = 50000; const int kAbsoluteMaxZoom = 23; +// Direction cone zoom gating +const int kDirectionConeMinZoomLevel = 14; + +// Direction cone arc smoothness +const int kDirectionConeArcPoints = 36; // points per 90 degrees +const int kDirectionConeMinArcPoints = 12; // minimum for narrow FOVs + // Node icon configuration const double kNodeIconDiameter = 18.0; + +// Node marker zoom scaling +const double kNodeIconReferenceZoom = 15.0; +const double kNodeIconScalePerZoom = 2.0; // px per zoom level +const double kNodeIconMinDiameter = 8.0; +const double kNodeIconMaxDiameter = 28.0; + +/// Returns marker diameter scaled by current zoom level +double getScaledNodeDiameter(double zoom) { + final scaled = kNodeIconDiameter + (zoom - kNodeIconReferenceZoom) * kNodeIconScalePerZoom; + return scaled.clamp(kNodeIconMinDiameter, kNodeIconMaxDiameter); +} + +// Clustering +const int kNodeClusterMaxZoomLevel = 13; // clustering active at zoom <= 13; disabled at zoom >= 14 +const double kClusterIconDiameter = 32.0; const double _kNodeRingThicknessBase = 2.5; const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 47b41da1..cd9bdae6 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 fa62994e..7f7023b4 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 8cfe386e..423ca6d8 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 2dcb8951..b314a5b0 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 c61fe7f1..6602d76c 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 558cdbad..f9d0cd55 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 3513368e..76fc22b4 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 38e611b7..8366a549 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 f2934682..06fc9adf 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 e8f208e9..499f1a24 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 ab446840..00695588 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 9bd2d565..ca0445b8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'screens/release_notes_screen.dart'; import 'screens/osm_account_screen.dart'; import 'screens/upload_queue_screen.dart'; import 'services/localization_service.dart'; +import 'services/provider_tile_cache_manager.dart'; import 'services/version_service.dart'; import 'services/deep_link_service.dart'; @@ -21,13 +22,16 @@ import 'services/deep_link_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - + // Initialize version service await VersionService().init(); - + // Initialize localization service await LocalizationService.instance.init(); + // Resolve platform cache directory for per-provider tile caching + await ProviderTileCacheManager.init(); + // Initialize deep link service await DeepLinkService().init(); DeepLinkService().setNavigatorKey(_navigatorKey); diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 8f7d81c4..5304d33e 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import '../services/service_policy.dart'; + /// A specific tile type within a provider class TileType { final String id; @@ -10,7 +12,7 @@ class TileType { final Uint8List? previewTile; // Single tile image data for preview final int maxZoom; // Maximum zoom level for this tile type - const TileType({ + TileType({ required this.id, required this.name, required this.urlTemplate, @@ -76,6 +78,15 @@ class TileType { /// Check if this tile type needs an API key bool get requiresApiKey => urlTemplate.contains('{api_key}'); + /// The service policy that applies to this tile type's server. + /// Cached because [urlTemplate] is immutable. + late final ServicePolicy servicePolicy = + ServicePolicyResolver.resolve(urlTemplate); + + /// Whether this tile server's usage policy permits offline/bulk downloading. + /// Resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload; + Map toJson() => { 'id': id, 'name': name, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ff9702e6..acc3e03a 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/screens/settings/sections/max_nodes_section.dart b/lib/screens/settings/sections/max_nodes_section.dart index 51f8a833..94569d79 100644 --- a/lib/screens/settings/sections/max_nodes_section.dart +++ b/lib/screens/settings/sections/max_nodes_section.dart @@ -34,7 +34,7 @@ class _MaxNodesSectionState extends State { final locService = LocalizationService.instance; final appState = context.watch(); final current = appState.maxNodes; - final showWarning = current > 1000; + final showWarning = current > 5000; return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 707f0155..7b1d6ce7 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,110 @@ 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; + + /// 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]. + 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, + this.configFingerprint = '', + }) : _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, + String configFingerprint = '', + }) { final client = UserAgentClient(RetryClient(Client())); - return DeflockTileProvider._(httpClient: client); + return DeflockTileProvider._( + httpClient: client, + providerId: providerId, + tileType: tileType, + apiKey: apiKey, + cachingProvider: cachingProvider, + onNetworkSuccess: onNetworkSuccess, + configFingerprint: configFingerprint, + ); } @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 +122,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 +133,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 +155,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 +227,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 +240,8 @@ class DeflockOfflineTileImageProvider required this.providerId, required this.tileTypeId, required this.tileUrl, + this.cachingProvider, + this.onNetworkSuccess, }); @override @@ -173,19 +254,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,67 +303,156 @@ 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); + } + return await _loadOnline(decodeBytes, cancelled); + } catch (e) { + // 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(); } - 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); + // Let real errors propagate so flutter_map marks loadError = true + rethrow; + } + } - final networkFuture = httpClient.send(request).then((response) async { - final bytes = await response.stream.toBytes(); - return (statusCode: response.statusCode, bytes: bytes); - }); + /// 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); + } - final result = await Future.any([ - networkFuture, - cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))), - ]); + // 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 + } - if (cancelled || result.statusCode == 0) return await transparent(); + // 3. If cancelled before network, bail. + if (cancelled.isCompleted) throw const TileLoadCancelledException(); - if (result.statusCode == 200 && result.bytes.isNotEmpty) { - return await decodeBytes(result.bytes); + // 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); } - - return await transparent(); - } 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})'); + if (cachedTile.metadata.etag case final etag?) { + request.headers[HttpHeaders.ifNoneMatchHeader] = etag; } - return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage) - .then(decode); } + + // 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 @@ -263,9 +461,11 @@ class DeflockOfflineTileImageProvider return other is DeflockOfflineTileImageProvider && other.coordinates == coordinates && other.providerId == providerId && - other.tileTypeId == tileTypeId; + other.tileTypeId == tileTypeId && + other.isOfflineOnly == isOfflineOnly; } @override - int get hashCode => Object.hash(coordinates, providerId, tileTypeId); + int get hashCode => + Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly); } diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6f5e99b5..231de6ac 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -60,7 +60,11 @@ class MapDataProvider { throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode."); } - // For downloads, always fetch fresh data (don't use cache) + // For downloads, always fetch fresh data (don't use cache). + // Note: passes null generation, so downloads are never cancelled by stale-fetch + // detection and will hold semaphore slots until complete. This is intentional — + // offline downloads should run to completion — but means concurrent downloads + // can block foreground map fetches via the shared semaphore. return _nodeDataManager.fetchWithSplitting(bounds, profiles); } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 342abf6a..0c21dada 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:xml/xml.dart'; @@ -7,6 +8,7 @@ import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../http_client.dart'; +import '../service_policy.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). @@ -58,28 +60,36 @@ Future> _fetchFromOsmApi({ try { debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); debugPrint('[fetchOsmApiNodes] URL: $url'); - - final response = await _client.get(Uri.parse(url)); - + + // Enforce max 2 concurrent download threads per OSM API usage policy + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + final http.Response response; + try { + response = await _client.get(Uri.parse(url)); + } finally { + ServiceRateLimiter.release(ServiceType.osmEditingApi); + } + if (response.statusCode != 200) { debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); throw Exception('OSM API error: ${response.statusCode} - ${response.body}'); } - + // Parse XML response final document = XmlDocument.parse(response.body); final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults); - + if (nodes.isNotEmpty) { debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes'); } - + // Don't report success here - let the top level handle it return nodes; - + } catch (e) { debugPrint('[fetchOsmApiNodes] Exception: $e'); - + // Don't report status here - let the top level handle it rethrow; // Re-throw to let caller handle } diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 003d9a09..5113134d 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/node_data_manager.dart b/lib/services/node_data_manager.dart index 2dbaeeb3..9533fa77 100644 --- a/lib/services/node_data_manager.dart +++ b/lib/services/node_data_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -14,19 +15,107 @@ import 'map_data_submodules/nodes_from_local.dart'; import 'offline_area_service.dart'; import 'offline_areas/offline_area_models.dart'; +/// Resizable async semaphore for limiting concurrent Overpass requests. +class _AsyncSemaphore { + int _maxConcurrent; + int _current = 0; + final _waiters = Queue>(); + + _AsyncSemaphore(int maxConcurrent) : _maxConcurrent = maxConcurrent < 1 ? 1 : maxConcurrent; + + int get maxConcurrent => _maxConcurrent; + + /// Resize the semaphore. If capacity increased, wake up queued waiters. + void resize(int newMax) { + _maxConcurrent = newMax < 1 ? 1 : newMax; + // Wake exactly the number of newly available slots. + // Can't use _current in the loop condition because woken waiters + // haven't incremented it yet (their continuations are microtasks). + var available = _maxConcurrent - _current; + while (available > 0 && _waiters.isNotEmpty) { + _waiters.removeFirst().complete(); + available--; + } + } + + Future run(Future Function() fn) async { + while (_current >= _maxConcurrent) { + final completer = Completer(); + _waiters.add(completer); + await completer.future; + } + _current++; + try { + return await fn(); + } finally { + _current--; + if (_waiters.isNotEmpty && _current < _maxConcurrent) { + _waiters.removeFirst().complete(); + } + } + } +} + /// Coordinates node data fetching between cache, Overpass, and OSM API. /// Simple interface: give me nodes for this view with proper caching and error handling. class NodeDataManager extends ChangeNotifier { static final NodeDataManager _instance = NodeDataManager._(); factory NodeDataManager() => _instance; - NodeDataManager._(); - final OverpassService _overpassService = OverpassService(); - final NodeSpatialCache _cache = NodeSpatialCache(); - + NodeDataManager._({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) : _overpassService = overpassService ?? OverpassService(), + _cache = cache ?? NodeSpatialCache(); + + @visibleForTesting + factory NodeDataManager.forTesting({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) => NodeDataManager._(overpassService: overpassService, cache: cache); + + final OverpassService _overpassService; + final NodeSpatialCache _cache; + + // Concurrency limiter for Overpass requests + _AsyncSemaphore? _overpassSemaphore; + Future<_AsyncSemaphore>? _semaphoreInitFuture; + + // Generation counter for cancelling stale fetch requests. + // Each new getNodesFor() call increments this; queued work checks before proceeding. + int _fetchGeneration = 0; + int? _lastLoggedStaleGeneration; + + bool _isStale(int? generation) { + if (generation == null || generation == _fetchGeneration) return false; + if (_lastLoggedStaleGeneration != generation) { + _lastLoggedStaleGeneration = generation; + debugPrint('[NodeDataManager] Fetch generation $generation is stale ' + '(current: $_fetchGeneration), cancelling remaining work'); + } + return true; + } + + @visibleForTesting + void advanceFetchGeneration() => _fetchGeneration++; + + Future<_AsyncSemaphore> _getOrCreateSemaphore() { + return _semaphoreInitFuture ??= _createSemaphore().catchError((e, st) { + _semaphoreInitFuture = null; // Allow retry on next fetch + Error.throwWithStackTrace(e, st); + }); + } + + Future<_AsyncSemaphore> _createSemaphore() async { + final slots = await _overpassService.getSlotCount(); + _overpassSemaphore = _AsyncSemaphore(slots); + debugPrint('[NodeDataManager] Overpass semaphore: $slots slots'); + return _overpassSemaphore!; + } + // Track ongoing user-initiated requests for status reporting final Set _userInitiatedRequests = {}; - + /// Get nodes for the given bounds and profiles. /// Returns cached data immediately if available, otherwise fetches from appropriate source. Future> getNodesFor({ @@ -43,7 +132,7 @@ class NodeDataManager extends ChangeNotifier { if (isUserInitiated) { NetworkStatus.instance.clear(); } - + if (uploadMode == UploadMode.sandbox) { // Offline + Sandbox = no nodes (local cache is production data) debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes'); @@ -51,7 +140,7 @@ class NodeDataManager extends ChangeNotifier { } else { // Offline + Production = use local offline areas (instant) final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles); - + // Add offline nodes to cache so they integrate with the rest of the system if (offlineNodes.isNotEmpty) { _cache.addOrUpdateNodes(offlineNodes); @@ -59,7 +148,7 @@ class NodeDataManager extends ChangeNotifier { _cache.markAreaAsFetched(bounds, offlineNodes); notifyListeners(); } - + // Show brief success for user-initiated offline loads with data if (isUserInitiated && offlineNodes.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -71,7 +160,7 @@ class NodeDataManager extends ChangeNotifier { NetworkStatus.instance.setNoData(); }); } - + return offlineNodes; } } @@ -79,15 +168,15 @@ class NodeDataManager extends ChangeNotifier { // Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI) if (uploadMode == UploadMode.sandbox) { debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API'); - + // Track user-initiated requests for status reporting final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] Sandbox request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -96,7 +185,7 @@ class NodeDataManager extends ChangeNotifier { } else { debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)'); } - + try { final nodes = await fetchOsmApiNodes( bounds: bounds, @@ -104,7 +193,7 @@ class NodeDataManager extends ChangeNotifier { uploadMode: uploadMode, maxResults: 0, ); - + // Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches) if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); @@ -113,10 +202,10 @@ class NodeDataManager extends ChangeNotifier { // Mark area as fetched even with no nodes so UI knows we've checked this area _cache.markAreaAsFetched(bounds, []); } - + // Update UI notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -124,12 +213,12 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Sandbox fetch failed: $e'); - + // Only report errors for user-initiated requests if (isUserInitiated) { if (e is RateLimitError) { @@ -141,7 +230,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e'); } - + // Return whatever we have in cache for this area (likely empty for sandbox) return _cache.getNodesFor(bounds); } finally { @@ -159,13 +248,13 @@ class NodeDataManager extends ChangeNotifier { // Not cached - need to fetch final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + // Only allow one user-initiated request per area at a time if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] User request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests only if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -175,12 +264,19 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Starting background request (no status reporting)'); } + final generation = ++_fetchGeneration; try { - final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated); - + final nodes = await fetchWithSplitting(bounds, profiles, + isUserInitiated: isUserInitiated, generation: generation); + + // If this fetch became stale (user panned away), skip UI updates + if (_isStale(generation)) { + return _cache.getNodesFor(bounds); + } + // Update cache and notify listeners notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -188,14 +284,14 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated request completed successfully'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Fetch failed: $e'); - - // Only report errors for user-initiated requests - if (isUserInitiated) { + + // Skip error reporting for stale requests + if (isUserInitiated && !_isStale(generation)) { if (e is RateLimitError) { NetworkStatus.instance.setRateLimited(); } else if (e.toString().contains('timeout')) { @@ -205,7 +301,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated request failed: $e'); } - + // Return whatever we have in cache for this area return _cache.getNodesFor(bounds); } finally { @@ -215,90 +311,143 @@ class NodeDataManager extends ChangeNotifier { } } - /// Fetch nodes with automatic area splitting if needed + /// Fetch nodes with automatic area splitting if needed. + /// When [generation] is non-null, the request is cancelled if a newer + /// generation has started (user panned/zoomed away). Future> fetchWithSplitting( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, { int splitDepth = 0, + int rateLimitRetries = 0, bool isUserInitiated = false, + int? generation, }) async { const maxSplitDepth = 3; // 4^3 = 64 max sub-areas - + + // Checkpoint 1: bail before entering semaphore + if (_isStale(generation)) return []; + try { // Expand bounds slightly to reduce edge effects final expandedBounds = _expandBounds(bounds, 1.2); - - final nodes = await _overpassService.fetchNodes( - bounds: expandedBounds, - profiles: profiles, + + final semaphore = await _getOrCreateSemaphore(); + // Checkpoint 2: stale request woke from queue — don't make HTTP call + final nodes = await semaphore.run>( + () { + if (_isStale(generation)) return Future.value([]); + return _overpassService.fetchNodes( + bounds: expandedBounds, + profiles: profiles, + ); + }, ); - - // Success - cache the data for the expanded area - _cache.markAreaAsFetched(expandedBounds, nodes); + + // Cache real data even if stale (valid for if user pans back). + // Skip marking area if stale and got empty result (short-circuited). + if (nodes.isNotEmpty || !_isStale(generation)) { + _cache.markAreaAsFetched(expandedBounds, nodes); + if (nodes.isNotEmpty) { + notifyListeners(); // Progressive rendering: each quadrant renders immediately + } + } return nodes; - + } on NodeLimitError { // Hit node limit or timeout - split area if not too deep if (splitDepth >= maxSplitDepth) { debugPrint('[NodeDataManager] Max split depth reached, giving up'); return []; } - + + // Checkpoint 3: don't spawn 4 new sub-requests for stale fetch + if (_isStale(generation)) return []; + debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)'); - + // Only report splitting status for user-initiated requests if (isUserInitiated && splitDepth == 0) { NetworkStatus.instance.setSplitting(); } - - return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated); - + + return _fetchSplitAreas(bounds, profiles, splitDepth + 1, + isUserInitiated: isUserInitiated, generation: generation); + } on RateLimitError { - // Rate limited - wait and return empty - debugPrint('[NodeDataManager] Rate limited, backing off'); - await Future.delayed(const Duration(seconds: 30)); - return []; + if (rateLimitRetries >= 2) { + debugPrint('[NodeDataManager] Max rate limit retries reached, giving up'); + return []; + } + + // Checkpoint 4: don't wait up to 2 minutes for a stale request + if (_isStale(generation)) return []; + + debugPrint('[NodeDataManager] Rate limited, polling for slot (retry ${rateLimitRetries + 1}/2)'); + if (isUserInitiated) NetworkStatus.instance.setRateLimited(); + + // Poll until slot available; resize semaphore with fresh slot count + final slots = await _overpassService.waitForSlot(); + + // Checkpoint 5: became stale during the wait + if (_isStale(generation)) return []; + + _overpassSemaphore?.resize(slots); + debugPrint('[NodeDataManager] Semaphore resized to $slots slots'); + + return fetchWithSplitting( + bounds, profiles, + splitDepth: splitDepth, + rateLimitRetries: rateLimitRetries + 1, + isUserInitiated: isUserInitiated, + generation: generation, + ); } } - /// Fetch data by splitting area into quadrants + /// Fetch data by splitting area into quadrants (parallel) Future> _fetchSplitAreas( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, int splitDepth, { bool isUserInitiated = false, + int? generation, }) async { - final quadrants = _splitBounds(bounds); - final allNodes = []; - - for (final quadrant in quadrants) { - try { - final nodes = await fetchWithSplitting( - quadrant, - profiles, - splitDepth: splitDepth, - isUserInitiated: isUserInitiated, - ); - allNodes.addAll(nodes); - } catch (e) { - debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); - // Continue with other quadrants - } - } - + // Checkpoint 6: don't spawn quadrants for stale tree + if (_isStale(generation)) return []; + + final quadrants = splitBounds(bounds); + + final results = await Future.wait( + quadrants.map((quadrant) async { + try { + return await fetchWithSplitting( + quadrant, profiles, + splitDepth: splitDepth, + isUserInitiated: isUserInitiated, + generation: generation, + ); + } catch (e) { + debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); + return []; + } + }), + ); + + final allNodes = results.expand((nodes) => nodes).toList(); debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes'); return allNodes; } /// Split bounds into 4 quadrants - List _splitBounds(LatLngBounds bounds) { + @visibleForTesting + static List splitBounds(LatLngBounds bounds) { final centerLat = (bounds.north + bounds.south) / 2; final centerLng = (bounds.east + bounds.west) / 2; - + return [ // Southwest LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)), - // Southeast + // Southeast LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)), // Northwest LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)), @@ -311,10 +460,10 @@ class NodeDataManager extends ChangeNotifier { LatLngBounds _expandBounds(LatLngBounds bounds, double factor) { final centerLat = (bounds.north + bounds.south) / 2; final centerLng = (bounds.east + bounds.west) / 2; - + final latSpan = (bounds.north - bounds.south) * factor / 2; final lngSpan = (bounds.east - bounds.west) * factor / 2; - + return LatLngBounds( LatLng(centerLat - latSpan, centerLng - lngSpan), LatLng(centerLat + latSpan, centerLng + lngSpan), @@ -347,7 +496,7 @@ class NodeDataManager extends ChangeNotifier { }) async { // Clear any cached data for this area _cache.clear(); - + // Re-fetch as user-initiated request await getNodesFor( bounds: bounds, @@ -374,16 +523,16 @@ class NodeDataManager extends ChangeNotifier { Future preloadOfflineNodes() async { try { final offlineAreaService = OfflineAreaService(); - + for (final area in offlineAreaService.offlineAreas) { if (area.status != OfflineAreaStatus.complete) continue; - + // Load nodes from this offline area final nodes = await fetchLocalNodes( bounds: area.bounds, profiles: [], // Empty profiles = load all nodes ); - + if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); // Mark the offline area as having coverage so submit buttons work @@ -391,7 +540,7 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}'); } } - + notifyListeners(); } catch (e) { debugPrint('[NodeDataManager] Error preloading offline nodes: $e'); @@ -400,4 +549,4 @@ class NodeDataManager extends ChangeNotifier { /// Get cache statistics String get cacheStats => _cache.stats.toString(); -} \ No newline at end of file +} diff --git a/lib/services/node_spatial_cache.dart b/lib/services/node_spatial_cache.dart index 35d90442..4e27fd90 100644 --- a/lib/services/node_spatial_cache.dart +++ b/lib/services/node_spatial_cache.dart @@ -13,6 +13,9 @@ class NodeSpatialCache { factory NodeSpatialCache() => _instance; NodeSpatialCache._(); + @visibleForTesting + NodeSpatialCache.forTesting(); + final List _fetchedAreas = []; final Map _nodes = {}; // nodeId -> node diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 93008256..d458d760 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 61906e74..38285c7d 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 b3da9773..7b283f47 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/overpass_service.dart b/lib/services/overpass_service.dart index 0d0f7ca5..bce36e32 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -13,11 +13,13 @@ import 'http_client.dart'; /// Single responsibility: Make requests, handle network errors, return data. class OverpassService { static const String _endpoint = 'https://overpass-api.de/api/interpreter'; + static const String _statusEndpoint = 'https://overpass-api.de/api/status'; + static const int defaultSlotCount = 4; + final http.Client _client; OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); - /// Fetch surveillance nodes from Overpass API with proper retry logic. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ @@ -99,6 +101,67 @@ class OverpassService { throw NetworkError('Max retries exceeded'); } + /// Query Overpass /api/status to get the rate limit (slot count per IP). + Future getSlotCount() async { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final match = RegExp(r'Rate limit:\s*(\d+)').firstMatch(response.body); + if (match != null) return int.parse(match.group(1)!); + } + } catch (e) { + debugPrint('[OverpassService] Failed to get slot count: $e'); + } + return defaultSlotCount; + } + + /// Poll /api/status until a slot is available. Returns observed slot count. + /// + /// Uses [elapsedFn] to track elapsed time. Defaults to a [Stopwatch]-based + /// implementation; tests can inject a fake to control time progression. + Future waitForSlot({ + Duration maxWait = const Duration(minutes: 2), + Duration Function()? elapsedFn, + }) async { + final stopwatch = Stopwatch()..start(); + final elapsed = elapsedFn ?? () => stopwatch.elapsed; + int observedSlots = defaultSlotCount; + + while (elapsed() < maxWait) { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + // Always parse slot count while we have the response + final slotMatch = RegExp(r'Rate limit:\s*(\d+)').firstMatch(response.body); + if (slotMatch != null) observedSlots = int.parse(slotMatch.group(1)!); + + // Slots available → ready + if (response.body.contains('slots available now')) return observedSlots; + + // Parse "in N seconds" from "Slot available after: ..., in N seconds." + final match = RegExp(r'in (\d+) seconds').firstMatch(response.body); + if (match != null) { + final wait = int.parse(match.group(1)!).clamp(1, 30); + debugPrint('[OverpassService] Waiting $wait seconds for slot'); + await Future.delayed(Duration(seconds: wait)); + continue; + } + } + } catch (e) { + debugPrint('[OverpassService] Status check failed: $e'); + } + + // Fallback: wait 5 seconds and re-poll + await Future.delayed(const Duration(seconds: 5)); + } + + debugPrint('[OverpassService] Max wait time exceeded, proceeding anyway'); + return observedSlots; + } + /// Build Overpass QL query for given bounds and profiles String _buildQuery(LatLngBounds bounds, List profiles) { final nodeClauses = profiles.map((profile) { diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart new file mode 100644 index 00000000..cdbce718 --- /dev/null +++ b/lib/services/provider_tile_cache_manager.dart @@ -0,0 +1,106 @@ +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, + }) { + if (_baseCacheDir == null) { + throw StateError( + 'ProviderTileCacheManager.init() must be called before getOrCreate()', + ); + } + + final key = '$providerId/$tileTypeId'; + if (_stores.containsKey(key)) return _stores[key]!; + + final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId); + + final store = ProviderTileCacheStore( + cacheDirectory: cacheDir, + maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024, + overrideFreshAge: policy.minCacheTtl, + ); + + _stores[key] = store; + return store; + } + + /// Delete a specific provider's cache directory and remove the store. + static Future deleteCache(String providerId, String tileTypeId) async { + final key = '$providerId/$tileTypeId'; + final store = _stores.remove(key); + if (store != null) { + await store.clear(); + } else if (_baseCacheDir != null) { + final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + } + } + } + + /// Get estimated cache sizes for all active stores. + /// + /// Returns a map of `providerId/tileTypeId` → size in bytes. + static Future> getCacheSizes() async { + final sizes = {}; + for (final entry in _stores.entries) { + sizes[entry.key] = await entry.value.estimatedSizeBytes; + } + return sizes; + } + + /// Remove a store from the registry (e.g., when a provider is disposed). + static void unregister(String providerId, String tileTypeId) { + _stores.remove('$providerId/$tileTypeId'); + } + + /// Clear all stores and reset the registry (for testing). + @visibleForTesting + static Future resetAll() async { + for (final store in _stores.values) { + await store.clear(); + } + _stores.clear(); + _baseCacheDir = null; + } + + /// Set the base cache directory directly (for testing). + @visibleForTesting + static void setBaseCacheDir(String dir) { + _baseCacheDir = dir; + } +} diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart new file mode 100644 index 00000000..192a13a8 --- /dev/null +++ b/lib/services/provider_tile_cache_store.dart @@ -0,0 +1,315 @@ +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). + @visibleForTesting + 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 + _lastPruneCheck = null; // Reset throttle so next write can trigger eviction + } + + /// 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 8ba0e40e..64597670 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); + } + } + + /// 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); + } } } -} \ No newline at end of file +} diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart new file mode 100644 index 00000000..acf8a246 --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,402 @@ +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: 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 = true, + 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 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( + 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/camera_icon.dart b/lib/widgets/camera_icon.dart index 0637936d..4a258c74 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -13,8 +13,9 @@ enum CameraIconType { /// Simple camera icon with grey dot and colored ring class CameraIcon extends StatelessWidget { final CameraIconType type; - - const CameraIcon({super.key, required this.type}); + final double diameter; + + const CameraIcon({super.key, required this.type, this.diameter = kNodeIconDiameter}); Color get _ringColor { switch (type) { @@ -36,8 +37,8 @@ class CameraIcon extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: kNodeIconDiameter, - height: kNodeIconDiameter, + width: diameter, + height: diameter, decoration: BoxDecoration( shape: BoxShape.circle, color: _ringColor.withValues(alpha: kNodeDotOpacity), diff --git a/lib/widgets/cluster_icon.dart b/lib/widgets/cluster_icon.dart new file mode 100644 index 00000000..490763f8 --- /dev/null +++ b/lib/widgets/cluster_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import '../dev_config.dart'; + +/// Cluster icon showing a blue circle with white count text +class ClusterIcon extends StatelessWidget { + final int count; + + const ClusterIcon({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + width: kClusterIconDiameter, + height: kClusterIconDiameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: kNodeRingColorReal.withValues(alpha: 0.7), + border: Border.all(color: kNodeRingColorReal, width: 2), + ), + alignment: Alignment.center, + child: Text( + count.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 0adbdb28..a370aaf4 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -262,16 +262,44 @@ 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; + + // 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 +310,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/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 5c38aed8..9b9dfa0f 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -86,18 +86,21 @@ class DirectionConesBuilder { } // Add cones for cameras with direction (but exclude camera being edited) - for (final node in cameras) { - if (_isValidCameraWithDirection(node) && - (editSession == null || node.id != editSession.originalNode.id)) { - // Build a cone for each direction+fov pair - for (final directionFov in node.directionFovPairs) { - overlays.add(_buildConeWithFov( - node.coord, - directionFov.centerDegrees, - directionFov.fovDegrees, - zoom, - context: context, - )); + // Only show at sufficient zoom where direction is meaningful + if (zoom >= kDirectionConeMinZoomLevel) { + for (final node in cameras) { + if (_isValidCameraWithDirection(node) && + (editSession == null || node.id != editSession.originalNode.id)) { + // Build a cone for each direction+fov pair + for (final directionFov in node.directionFovPairs) { + overlays.add(_buildConeWithFov( + node.coord, + directionFov.centerDegrees, + directionFov.fovDegrees, + zoom, + context: context, + )); + } } } } @@ -136,6 +139,7 @@ class DirectionConesBuilder { } /// Internal cone building method that handles the actual rendering + /// Builds a wedge/pie shape emanating from the camera center static Polygon _buildConeInternal({ required LatLng origin, required double bearingDeg, @@ -157,20 +161,17 @@ class DirectionConesBuilder { isActiveDirection: isActiveDirection, ); } - - // Calculate pixel-based radii - final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); - final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context)); - + + // Calculate pixel-based outer radius using scaled marker diameter + final diameter = getScaledNodeDiameter(zoom); + final outerRadiusPx = diameter + (diameter * kDirectionConeBaseLength); + // Convert pixels to coordinate distances with zoom scaling final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); final outerRadius = outerRadiusPx * pixelToCoordinate; - final innerRadius = innerRadiusPx * pixelToCoordinate; - - // Number of points for the outer arc (within our directional range) - // Scale arc points based on FOV width for better rendering - final baseArcPoints = 12; - final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round()); + + // Smooth arc: scale points by FOV width + final arcPoints = math.max(kDirectionConeMinArcPoints, (kDirectionConeArcPoints * halfAngleDeg / 90).round()); LatLng project(double deg, double distance) { final rad = deg * math.pi / 180; @@ -180,20 +181,13 @@ class DirectionConesBuilder { return LatLng(origin.latitude + dLat, origin.longitude + dLon); } - // Build outer arc points only within our directional sector - final points = []; - - // Add outer arc points from left to right (counterclockwise for proper polygon winding) + // Build wedge/pie shape: origin → outer arc → auto-closes back to origin + final points = [origin]; + for (int i = 0; i <= arcPoints; i++) { final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); points.add(project(angle, outerRadius)); } - - // Add inner arc points from right to left (to close the donut shape) - for (int i = arcPoints; i >= 0; i--) { - final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); - points.add(project(angle, innerRadius)); - } // Adjust opacity based on direction state double opacity = kDirectionConeOpacity; @@ -210,7 +204,6 @@ class DirectionConesBuilder { } /// Build a full circle for 360-degree FOV cases - /// Returns just the outer circle - we'll handle the donut effect differently static Polygon _buildFullCircle({ required LatLng origin, required double zoom, @@ -218,14 +211,14 @@ class DirectionConesBuilder { bool isSession = false, bool isActiveDirection = true, }) { - // Calculate pixel-based radii - final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); - + // Calculate pixel-based radius using scaled marker diameter + final diameter = getScaledNodeDiameter(zoom); + final outerRadiusPx = diameter + (diameter * kDirectionConeBaseLength); + // Convert pixels to coordinate distances with zoom scaling final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); final outerRadius = outerRadiusPx * pixelToCoordinate; - - // Create simple filled circle - no donut complexity + const int circlePoints = 60; final points = []; @@ -236,9 +229,8 @@ class DirectionConesBuilder { distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180); return LatLng(origin.latitude + dLat, origin.longitude + dLon); } - - // Add outer circle points - simple complete circle - for (int i = 0; i <= circlePoints; i++) { // Note: <= to ensure closure + + for (int i = 0; i < circlePoints; i++) { final angle = (i * 360.0 / circlePoints) % 360.0; points.add(project(angle, outerRadius)); } diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 0d9772f9..5e952bb0 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/marker_layer_builder.dart b/lib/widgets/map/marker_layer_builder.dart index 26528b40..92b4c870 100644 --- a/lib/widgets/map/marker_layer_builder.dart +++ b/lib/widgets/map/marker_layer_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; import 'package:latlong2/latlong.dart'; import '../../models/osm_node.dart'; @@ -8,6 +9,7 @@ import '../../models/suspected_location.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; import '../camera_icon.dart'; +import '../cluster_icon.dart'; import '../provisional_pin.dart'; import 'node_markers.dart'; import 'suspected_location_markers.dart'; @@ -44,8 +46,10 @@ class LocationPin extends StatelessWidget { /// session markers, navigation pins, and route visualization. class MarkerLayerBuilder { - /// Build complete marker layers for the map - static Widget buildMarkerLayers({ + /// Build complete marker layers for the map. + /// Returns a list of widgets: a cluster layer for node markers and + /// a regular MarkerLayer for all other markers. + static List buildMarkerLayers({ required List nodesToRender, required AnimatedMapController mapController, required AppState appState, @@ -58,85 +62,111 @@ class MarkerLayerBuilder { required Function(OsmNode)? onNodeTap, required Function(SuspectedLocation)? onSuspectedLocationTap, }) { - return LayoutBuilder( - builder: (context, constraints) { - - // Determine if nodes should be dimmed and/or disabled - final shouldDimNodes = appState.selectedSuspectedLocation != null || - appState.isInSearchMode || - appState.showingOverview; - - // Disable node interactions when navigation is in conflicting state - final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; - - final markers = NodeMarkersBuilder.buildNodeMarkers( - nodes: nodesToRender, + // Determine if nodes should be dimmed and/or disabled + final shouldDimNodes = appState.selectedSuspectedLocation != null || + appState.isInSearchMode || + appState.showingOverview; + + // Disable node interactions when navigation is in conflicting state + final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; + + final markers = NodeMarkersBuilder.buildNodeMarkers( + nodes: nodesToRender, + mapController: mapController.mapController, + zoom: currentZoom, + selectedNodeId: selectedNodeId, + onNodeTap: onNodeTap, + shouldDim: shouldDimNodes, + enabled: !shouldDisableNodeTaps, + ); + + // User location marker (separate from node markers for clustering) + final userLocationMarkers = [ + if (userLocation != null) + Marker( + point: userLocation, + width: 16, + height: 16, + child: const Icon(Icons.my_location, color: Colors.blue), + ), + ]; + + // Build suspected location markers (respect same zoom and count limits as nodes) + final suspectedLocationMarkers = []; + if (appState.suspectedLocationsEnabled && mapBounds != null && + currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { + final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( + north: mapBounds.north, + south: mapBounds.south, + east: mapBounds.east, + west: mapBounds.west, + ); + + // Apply same node count limit as surveillance nodes + final maxNodes = appState.maxNodes; + final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); + + // Filter out suspected locations that are too close to real nodes + final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( + suspectedLocations: limitedSuspectedLocations, + realNodes: nodesToRender, + minDistance: appState.suspectedLocationMinDistance, + ); + + suspectedLocationMarkers.addAll( + SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( + locations: filteredSuspectedLocations, mapController: mapController.mapController, - userLocation: userLocation, - selectedNodeId: selectedNodeId, - onNodeTap: onNodeTap, // Keep the original callback - shouldDim: shouldDimNodes, - enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - ); + selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, + onLocationTap: onSuspectedLocationTap, + shouldDimAll: shouldDisableNodeTaps, + enabled: !shouldDisableNodeTaps, + ), + ); + } - // Build suspected location markers (respect same zoom and count limits as nodes) - final suspectedLocationMarkers = []; - if (appState.suspectedLocationsEnabled && mapBounds != null && - currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { - final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( - north: mapBounds.north, - south: mapBounds.south, - east: mapBounds.east, - west: mapBounds.west, - ); - - // Apply same node count limit as surveillance nodes - final maxNodes = appState.maxNodes; - final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); - - // Filter out suspected locations that are too close to real nodes - final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( - suspectedLocations: limitedSuspectedLocations, - realNodes: nodesToRender, - minDistance: appState.suspectedLocationMinDistance, - ); - - suspectedLocationMarkers.addAll( - SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( - locations: filteredSuspectedLocations, - mapController: mapController.mapController, - selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, - onLocationTap: onSuspectedLocationTap, // Keep the original callback - shouldDimAll: shouldDisableNodeTaps, - enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - ), - ); - } + // Build center marker for add/edit sessions + final centerMarkers = _buildSessionMarkers( + mapController: mapController, + session: session, + editSession: editSession, + ); - // Build center marker for add/edit sessions - final centerMarkers = _buildSessionMarkers( - mapController: mapController, - session: session, - editSession: editSession, - ); + // Build provisional pin for navigation/search mode + final navigationMarkers = _buildNavigationMarkers(appState); - // Build provisional pin for navigation/search mode - final navigationMarkers = _buildNavigationMarkers(appState); + // Build start/end pins for route visualization + final routeMarkers = _buildRouteMarkers(appState); - // Build start/end pins for route visualization - final routeMarkers = _buildRouteMarkers(appState); + // Node markers go into cluster layer + final clusterLayer = MarkerClusterLayerWidget( + options: MarkerClusterLayerOptions( + markers: markers, + maxClusterRadius: 80, + disableClusteringAtZoom: kNodeClusterMaxZoomLevel, + zoomToBoundsOnClick: true, + spiderfyCluster: false, + centerMarkerOnClick: false, + markerChildBehavior: true, // Let NodeMapMarker handle its own gestures + size: Size(kClusterIconDiameter, kClusterIconDiameter), + builder: (context, clusterMarkers) { + return ClusterIcon(count: clusterMarkers.length); + }, + ), + ); - return MarkerLayer( - markers: [ - ...suspectedLocationMarkers, - ...markers, - ...centerMarkers, - ...navigationMarkers, - ...routeMarkers, - ] - ); - }, + // All other markers stay in a regular layer + final otherMarkersLayer = MarkerLayer( + markers: [ + ...suspectedLocationMarkers, + ...userLocationMarkers, + ...centerMarkers, + ...navigationMarkers, + ...routeMarkers, + ], ); + + return [clusterLayer, otherMarkersLayer]; } /// Build center markers for add/edit sessions diff --git a/lib/widgets/map/node_markers.dart b/lib/widgets/map/node_markers.dart index 7c386bef..e522a62e 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; import '../../models/osm_node.dart'; @@ -14,12 +13,14 @@ class NodeMapMarker extends StatefulWidget { final MapController mapController; final void Function(OsmNode)? onNodeTap; final bool enabled; - + final double diameter; + const NodeMapMarker({ - required this.node, - required this.mapController, + required this.node, + required this.mapController, this.onNodeTap, this.enabled = true, + this.diameter = kNodeIconDiameter, super.key, }); @@ -91,7 +92,7 @@ class _NodeMapMarkerState extends State { return GestureDetector( onTap: _onTap, onDoubleTap: _onDoubleTap, - child: CameraIcon(type: iconType), + child: CameraIcon(type: iconType, diameter: widget.diameter), ); } } @@ -101,12 +102,13 @@ class NodeMarkersBuilder { static List buildNodeMarkers({ required List nodes, required MapController mapController, - LatLng? userLocation, + required double zoom, int? selectedNodeId, void Function(OsmNode)? onNodeTap, bool shouldDim = false, bool enabled = true, }) { + final diameter = getScaledNodeDiameter(zoom); final markers = [ // Node markers ...nodes @@ -115,31 +117,23 @@ class NodeMarkersBuilder { // Check if this node should be highlighted (selected) or dimmed final isSelected = selectedNodeId == n.id; final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected); - + return Marker( point: n.coord, - width: kNodeIconDiameter, - height: kNodeIconDiameter, + width: diameter, + height: diameter, child: Opacity( opacity: shouldDimNode ? 0.5 : 1.0, child: NodeMapMarker( - node: n, + node: n, mapController: mapController, onNodeTap: onNodeTap, enabled: enabled, + diameter: diameter, ), ), ); }), - - // User location marker - if (userLocation != null) - Marker( - point: userLocation, - width: 16, - height: 16, - child: const Icon(Icons.my_location, color: Colors.blue), - ), ]; return markers; diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index 75acc72b..78cda5e2 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,89 @@ 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, ); } + + /// 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, + }) { + 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}'; + 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( + 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, + configFingerprint: fingerprint, + ); + }); + } } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 96ef2b3d..086f5e4e 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) { @@ -346,8 +341,8 @@ class MapViewState extends State { Widget cameraLayers = LayoutBuilder( builder: (context, constraints) { - // Build all marker layers - final markerLayer = MarkerLayerBuilder.buildMarkerLayers( + // Build all marker layers (cluster layer + other markers layer) + final markerLayers = MarkerLayerBuilder.buildMarkerLayers( nodesToRender: nodeData.nodesToRender, mapController: _controller, appState: appState, @@ -374,7 +369,7 @@ class MapViewState extends State { return Stack( children: [ ...overlayLayers, - markerLayer, + ...markerLayers, ], ); }, @@ -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/lib/widgets/node_provider_with_cache.dart b/lib/widgets/node_provider_with_cache.dart index 09ff7cef..09272764 100644 --- a/lib/widgets/node_provider_with_cache.dart +++ b/lib/widgets/node_provider_with_cache.dart @@ -13,7 +13,22 @@ import '../app_state.dart'; class NodeProviderWithCache extends ChangeNotifier { static final NodeProviderWithCache instance = NodeProviderWithCache._internal(); factory NodeProviderWithCache() => instance; - NodeProviderWithCache._internal(); + NodeProviderWithCache._internal() { + _nodeDataManager.addListener(_onNodeDataManagerUpdate); + } + + void _onNodeDataManagerUpdate() { + notifyListeners(); + } + + /// Singleton — dispose is only safe at app shutdown; included for completeness. + @override + void dispose() { + _debounceTimer?.cancel(); + _debounceTimer = null; + _nodeDataManager.removeListener(_onNodeDataManagerUpdate); + super.dispose(); + } final NodeDataManager _nodeDataManager = NodeDataManager(); Timer? _debounceTimer; @@ -52,10 +67,7 @@ class NodeProviderWithCache extends ChangeNotifier { uploadMode: uploadMode, isUserInitiated: true, ); - - // Notify UI of new data - notifyListeners(); - + // NodeDataManager.notifyListeners() propagates via _onNodeDataManagerUpdate } catch (e) { debugPrint('[NodeProviderWithCache] Node fetch failed: $e'); // Cache already holds whatever is available for the view diff --git a/pubspec.lock b/pubspec.lock index 76982e6e..21c66975 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + animated_stack_widget: + dependency: transitive + description: + name: animated_stack_widget + sha256: ce4788dd158768c9d4388354b6fb72600b78e041a37afc4c279c63ecafcb9408 + url: "https://pub.dev" + source: hosted + version: "0.0.4" ansicolor: dependency: transitive description: @@ -178,7 +186,7 @@ packages: source: hosted version: "0.2.3" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -278,6 +286,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.0" + flutter_map_marker_cluster: + dependency: "direct main" + description: + name: flutter_map_marker_cluster + sha256: "04a20d9b1c3a18b67cc97c1240f75361ab98449b735ab06f2534ece0d0794733" + url: "https://pub.dev" + source: hosted + version: "8.2.2" + flutter_map_marker_popup: + dependency: transitive + description: + name: flutter_map_marker_popup + sha256: "982b38455e739fe04abf05066340e0ce5883c40fb08b121cc8c60f5ee2c664a3" + url: "https://pub.dev" + source: hosted + version: "8.1.0" flutter_native_splash: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 03aa82d1..eabe18ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: provider: ^6.1.2 flutter_map: ^8.2.1 flutter_map_animations: ^0.9.0 + flutter_map_marker_cluster: ^8.2.2 latlong2: ^0.9.0 geolocator: ^10.1.0 http: ^1.2.1 @@ -42,6 +43,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + fake_async: ^1.3.3 mocktail: ^1.0.4 flutter_launcher_icons: ^0.14.4 flutter_lints: ^6.0.0 diff --git a/test/services/deflock_tile_provider_test.dart b/test/services/deflock_tile_provider_test.dart index ee4cd36b..140bd0e6 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/node_data_manager_test.dart b/test/services/node_data_manager_test.dart new file mode 100644 index 00000000..d7194b5c --- /dev/null +++ b/test/services/node_data_manager_test.dart @@ -0,0 +1,713 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/osm_node.dart'; +import 'package:deflockapp/services/overpass_service.dart'; +import 'package:deflockapp/services/node_data_manager.dart'; +import 'package:deflockapp/services/node_spatial_cache.dart'; + +class MockOverpassService extends Mock implements OverpassService {} + +class MockNodeSpatialCache extends Mock implements NodeSpatialCache {} + +class MockHttpClient extends Mock implements http.Client {} + +void main() { + final testBounds = LatLngBounds( + const LatLng(38.0, -78.0), + const LatLng(39.0, -77.0), + ); + + final testProfiles = [ + NodeProfile( + id: 'test', + name: 'Test Profile', + tags: const {'man_made': 'surveillance'}, + ), + ]; + + OsmNode makeNode(int id, {double lat = 38.5, double lng = -77.5}) => OsmNode( + id: id, + coord: LatLng(lat, lng), + tags: const {'man_made': 'surveillance'}, + ); + + setUpAll(() { + registerFallbackValue(testBounds); + registerFallbackValue([]); + registerFallbackValue([]); + registerFallbackValue(Uri.parse('https://example.com')); + registerFallbackValue(const Duration(seconds: 1)); + }); + + group('splitBounds', () { + test('splits into 4 correct quadrants with center at midpoint', () { + final bounds = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(10.0, 10.0), + ); + + final quadrants = NodeDataManager.splitBounds(bounds); + + expect(quadrants, hasLength(4)); + + // Southwest + expect(quadrants[0].south, 0.0); + expect(quadrants[0].west, 0.0); + expect(quadrants[0].north, 5.0); + expect(quadrants[0].east, 5.0); + + // Southeast + expect(quadrants[1].south, 0.0); + expect(quadrants[1].west, 5.0); + expect(quadrants[1].north, 5.0); + expect(quadrants[1].east, 10.0); + + // Northwest + expect(quadrants[2].south, 5.0); + expect(quadrants[2].west, 0.0); + expect(quadrants[2].north, 10.0); + expect(quadrants[2].east, 5.0); + + // Northeast + expect(quadrants[3].south, 5.0); + expect(quadrants[3].west, 5.0); + expect(quadrants[3].north, 10.0); + expect(quadrants[3].east, 10.0); + }); + + test('quadrants tile exactly - no gaps or overlaps', () { + final bounds = LatLngBounds( + const LatLng(10.0, 20.0), + const LatLng(30.0, 40.0), + ); + + final quadrants = NodeDataManager.splitBounds(bounds); + + // Summing all quadrant spans gives 2x original (2 rows + 2 columns of half-spans) + final totalLatSpan = quadrants.map((q) => q.north - q.south).reduce((a, b) => a + b); + final totalLngSpan = quadrants.map((q) => q.east - q.west).reduce((a, b) => a + b); + expect(totalLatSpan, closeTo((bounds.north - bounds.south) * 2, 1e-10)); + expect(totalLngSpan, closeTo((bounds.east - bounds.west) * 2, 1e-10)); + + // Verify edges align at center + final centerLat = (bounds.north + bounds.south) / 2; + final centerLng = (bounds.east + bounds.west) / 2; + + for (final q in quadrants) { + // Every quadrant edge should be either an original edge or the center + expect( + q.south == bounds.south || q.south == centerLat, + isTrue, + reason: 'south edge ${q.south} should be original south or center', + ); + expect( + q.north == bounds.north || q.north == centerLat, + isTrue, + reason: 'north edge ${q.north} should be original north or center', + ); + expect( + q.west == bounds.west || q.west == centerLng, + isTrue, + reason: 'west edge ${q.west} should be original west or center', + ); + expect( + q.east == bounds.east || q.east == centerLng, + isTrue, + reason: 'east edge ${q.east} should be original east or center', + ); + } + }); + }); + + group('OverpassService.getSlotCount', () { + late MockHttpClient mockClient; + late OverpassService service; + + setUp(() { + mockClient = MockHttpClient(); + service = OverpassService(client: mockClient); + }); + + test('parses Rate limit from status response', () async { + when(() => mockClient.get(any())).thenAnswer( + (_) async => http.Response( + 'Connected as: 123456\n' + 'Current time: 2025-01-01T00:00:00Z\n' + 'Rate limit: 6\n' + '2 slots available now.', + 200, + ), + ); + + final count = await service.getSlotCount(); + expect(count, 6); + }); + + test('falls back to defaultSlotCount on HTTP failure', () async { + when(() => mockClient.get(any())).thenAnswer( + (_) async => http.Response('Server Error', 500), + ); + + final count = await service.getSlotCount(); + expect(count, OverpassService.defaultSlotCount); + }); + + test('falls back to defaultSlotCount on network error', () async { + when(() => mockClient.get(any())).thenThrow( + http.ClientException('Connection refused'), + ); + + final count = await service.getSlotCount(); + expect(count, OverpassService.defaultSlotCount); + }); + }); + + group('OverpassService.waitForSlot', () { + late MockHttpClient mockClient; + late OverpassService service; + + setUp(() { + mockClient = MockHttpClient(); + service = OverpassService(client: mockClient); + }); + + test('returns immediately when slots available now', () async { + when(() => mockClient.get(any())).thenAnswer( + (_) async => http.Response( + 'Rate limit: 6\n2 slots available now.', + 200, + ), + ); + + final slots = await service.waitForSlot(); + expect(slots, 6); + verify(() => mockClient.get(any())).called(1); + }); + + test('waits and re-polls when "in N seconds" in response', () { + FakeAsync().run((fake) { + var fakeElapsed = Duration.zero; + var callCount = 0; + when(() => mockClient.get(any())).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + return http.Response( + 'Rate limit: 4\nSlot available after: 2025-01-01T00:00:03Z, in 1 seconds.', + 200, + ); + } + return http.Response( + 'Rate limit: 4\n2 slots available now.', + 200, + ); + }); + + late int slots; + service.waitForSlot(elapsedFn: () => fakeElapsed).then((s) => slots = s); + + fakeElapsed = const Duration(seconds: 1); + fake.elapse(const Duration(seconds: 2)); + + expect(slots, 4); + expect(callCount, 2); + }); + }); + + test('falls back to 5s poll on unparseable response', () { + FakeAsync().run((fake) { + var fakeElapsed = Duration.zero; + var callCount = 0; + when(() => mockClient.get(any())).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + return http.Response('some garbage response', 200); + } + return http.Response( + 'Rate limit: 4\n1 slots available now.', + 200, + ); + }); + + late int slots; + service.waitForSlot(elapsedFn: () => fakeElapsed).then((s) => slots = s); + + fakeElapsed = const Duration(seconds: 5); + fake.elapse(const Duration(seconds: 6)); + + expect(slots, 4); + expect(callCount, 2); + }); + }); + + test('returns updated slot count if Rate limit changes', () { + FakeAsync().run((fake) { + var fakeElapsed = Duration.zero; + var callCount = 0; + when(() => mockClient.get(any())).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + return http.Response( + 'Rate limit: 4\nSlot available after: ..., in 1 seconds.', + 200, + ); + } + return http.Response( + 'Rate limit: 8\n3 slots available now.', + 200, + ); + }); + + late int slots; + service.waitForSlot(elapsedFn: () => fakeElapsed).then((s) => slots = s); + + fakeElapsed = const Duration(seconds: 1); + fake.elapse(const Duration(seconds: 2)); + + expect(slots, 8); + }); + }); + + test('returns default slot count when maxWait deadline expires', () { + FakeAsync().run((fake) { + var fakeElapsed = Duration.zero; + when(() => mockClient.get(any())).thenAnswer( + (_) async => http.Response('Rate limit: 6\nNo slots right now.', 200), + ); + + late int slots; + service.waitForSlot( + maxWait: const Duration(seconds: 10), + elapsedFn: () => fakeElapsed, + ).then((s) => slots = s); + + // Advance past maxWait + fakeElapsed = const Duration(seconds: 11); + fake.elapse(const Duration(seconds: 6)); + + expect(slots, 6); + }); + }); + }); + + group('fetchWithSplitting', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + // Default: semaphore init returns 4 slots + when(() => mockOverpass.getSlotCount()).thenAnswer((_) async => 4); + + // Default: cache operations are no-ops + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('happy path - returns nodes and caches them', () async { + final nodes = [makeNode(1), makeNode(2)]; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async => nodes); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + expect(result, hasLength(2)); + verify(() => mockCache.markAreaAsFetched(any(), any())).called(1); + }); + + test('NodeLimitError splits into 4 and combines results', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + return [makeNode(callCount)]; + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + // First call throws, then 4 quadrant calls succeed + expect(result, hasLength(4)); + verify(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).called(5); // 1 initial + 4 quadrants + }); + + test('max depth + NodeLimitError returns empty', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenThrow(NodeLimitError('too many nodes')); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + splitDepth: 3, + ); + + expect(result, isEmpty); + }); + + test('RateLimitError polls for slot, resizes semaphore, retries', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw RateLimitError('rate limited'); + } + return [makeNode(1)]; + }); + + when(() => mockOverpass.waitForSlot(maxWait: any(named: 'maxWait'))) + .thenAnswer((_) async => 6); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + expect(result, hasLength(1)); + verify(() => mockOverpass.waitForSlot(maxWait: any(named: 'maxWait'))).called(1); + }); + + test('RateLimitError x3 gives up after 2 retries', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenThrow(RateLimitError('rate limited')); + + when(() => mockOverpass.waitForSlot(maxWait: any(named: 'maxWait'))) + .thenAnswer((_) async => 4); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + expect(result, isEmpty); + // Called twice (retry 1 and retry 2), third attempt gives up + verify(() => mockOverpass.waitForSlot(maxWait: any(named: 'maxWait'))).called(2); + }); + }); + + group('_fetchSplitAreas (via fetchWithSplitting)', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockOverpass.getSlotCount()).thenAnswer((_) async => 4); + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('partial failure - 1 quadrant throws, other 3 return nodes', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + // Initial call: trigger split + throw NodeLimitError('too many nodes'); + } + if (callCount == 2) { + // First quadrant: network error + throw NetworkError('connection failed'); + } + // Other 3 quadrants succeed + return [makeNode(callCount)]; + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + // 3 of 4 quadrants returned 1 node each + expect(result, hasLength(3)); + }); + + test('all quadrants fail returns empty', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + throw NetworkError('connection failed'); + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + expect(result, isEmpty); + }); + + test('recursive splitting - depth-1 NodeLimitError, depth-2 success', () async { + var callCount = 0; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + // First 5 calls all hit node limit (1 initial + 4 quadrants at depth 1) + if (callCount <= 5) { + throw NodeLimitError('too many nodes'); + } + // Depth-2 calls succeed + return [makeNode(callCount)]; + }); + + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + // 4 quadrants at depth 1 each split into 4 = 16 depth-2 fetches + expect(result, hasLength(16)); + }); + }); + + group('stale fetch cancellation', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockOverpass.getSlotCount()).thenAnswer((_) async => 4); + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('stale generation skips fetch entirely', () async { + manager.advanceFetchGeneration(); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + expect(result, isEmpty); + verifyNever(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )); + }); + + test('stale generation inside semaphore lambda prevents HTTP call', () async { + when(() => mockOverpass.getSlotCount()).thenAnswer((_) async { + manager.advanceFetchGeneration(); + return 4; + }); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + expect(result, isEmpty); + verifyNever(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )); + }); + + test('stale generation prevents recursive splitting', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + manager.advanceFetchGeneration(); + throw NodeLimitError('too many nodes'); + }); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + expect(result, isEmpty); + // Only the initial call, no quadrant fetches + verify(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).called(1); + }); + + test('stale generation skips waitForSlot', () async { + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + manager.advanceFetchGeneration(); + throw RateLimitError('rate limited'); + }); + + final result = await manager.fetchWithSplitting( + testBounds, testProfiles, + generation: 0, + ); + + expect(result, isEmpty); + verifyNever(() => mockOverpass.waitForSlot(maxWait: any(named: 'maxWait'))); + }); + + test('null generation is never stale (backward compat)', () async { + final nodes = [makeNode(1), makeNode(2)]; + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async => nodes); + + // Advance generation many times + for (var i = 0; i < 10; i++) { + manager.advanceFetchGeneration(); + } + + // Call without generation parameter — null generation is never stale + final result = await manager.fetchWithSplitting(testBounds, testProfiles); + + expect(result, hasLength(2)); + verify(() => mockCache.markAreaAsFetched(any(), any())).called(1); + }); + }); + + group('progressive rendering', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockOverpass.getSlotCount()).thenAnswer((_) async => 4); + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('notifyListeners called per completed quadrant during split', () async { + var notifyCount = 0; + manager.addListener(() => notifyCount++); + + var callCount = 0; + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + return [makeNode(callCount)]; + }); + + await manager.fetchWithSplitting(testBounds, testProfiles); + + // Each of the 4 quadrants returned nodes → 4 notifications + expect(notifyCount, 4); + }); + + test('empty quadrant results do not trigger notification', () async { + var notifyCount = 0; + manager.addListener(() => notifyCount++); + + var callCount = 0; + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async { + callCount++; + if (callCount == 1) { + throw NodeLimitError('too many nodes'); + } + // Only 2 of 4 quadrants return nodes + if (callCount <= 3) return [makeNode(callCount)]; + return []; + }); + + await manager.fetchWithSplitting(testBounds, testProfiles); + + // Only 2 quadrants had nodes → 2 notifications + expect(notifyCount, 2); + }); + }); + + group('semaphore initialization', () { + late MockOverpassService mockOverpass; + late MockNodeSpatialCache mockCache; + late NodeDataManager manager; + + setUp(() { + mockOverpass = MockOverpassService(); + mockCache = MockNodeSpatialCache(); + manager = NodeDataManager.forTesting( + overpassService: mockOverpass, + cache: mockCache, + ); + + when(() => mockCache.markAreaAsFetched(any(), any())).thenReturn(null); + }); + + test('concurrent calls to semaphore init return same instance', () async { + var getSlotCallCount = 0; + when(() => mockOverpass.getSlotCount()).thenAnswer((_) async { + getSlotCallCount++; + // Simulate slow network + await Future.delayed(const Duration(milliseconds: 10)); + return 4; + }); + + when(() => mockOverpass.fetchNodes( + bounds: any(named: 'bounds'), + profiles: any(named: 'profiles'), + )).thenAnswer((_) async => [makeNode(1)]); + + // Launch two concurrent fetches + final results = await Future.wait([ + manager.fetchWithSplitting(testBounds, testProfiles), + manager.fetchWithSplitting(testBounds, testProfiles), + ]); + + // Both should succeed + expect(results[0], hasLength(1)); + expect(results[1], hasLength(1)); + + // getSlotCount should only be called once (shared init future) + expect(getSlotCallCount, 1); + }); + }); +} diff --git a/test/services/offline_area_service_test.dart b/test/services/offline_area_service_test.dart new file mode 100644 index 00000000..54f58e3a --- /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 00000000..e0f974d7 --- /dev/null +++ b/test/services/provider_tile_cache_store_test.dart @@ -0,0 +1,517 @@ +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. + /// 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, + 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, + ); + 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, + ); + // 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); + } + } + + 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 00000000..bfe31e41 --- /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 allows offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + }); + + 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, true); + }); + + 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, true); + 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 00000000..767647f3 --- /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 00000000..3a9183ba --- /dev/null +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -0,0 +1,618 @@ +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/models/tile_provider.dart' as models; +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 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; + + 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)); + }); + }); + }); +}