Merge pull request #132 from dougborg/fix/tile-retry-on-error

I think this finally has online, offline, proper caching, tile types, all working correctly.
This commit is contained in:
stopflock
2026-03-07 16:33:42 -06:00
committed by GitHub
37 changed files with 4120 additions and 379 deletions
+2 -1
View File
@@ -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
+13 -7
View File
@@ -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)"
}
}
}
+15 -9
View File
@@ -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)"
}
}
}
+13 -7
View File
@@ -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)"
}
}
}
+13 -7
View File
@@ -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)"
}
}
}
+13 -7
View File
@@ -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)"
}
}
}
+9 -3
View File
@@ -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)"
}
}
}
+9 -3
View File
@@ -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)"
}
}
}
+13 -7
View File
@@ -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)"
}
}
}
+9 -3
View File
@@ -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)"
}
}
}
+9 -3
View File
@@ -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": "Імперські (миля, фут)"
}
}
}
+13 -7
View File
@@ -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": "英制 (英里, 英尺)"
}
}
}
+6 -2
View File
@@ -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<void> 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);
+12 -1
View File
@@ -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<String, dynamic> toJson() => {
'id': id,
'name': name,
+32 -28
View File
@@ -578,37 +578,41 @@ class _HomeScreenState extends State<HomeScreen> 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<AppState>();
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),
),
),
),
),
);
},
),
),
],
+310 -110
View File
@@ -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<void> 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<void> 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<DeflockOfflineTileImageProvider> {
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<ImageChunkEvent>();
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<CachedMapTile?> _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<String, String> 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<Codec> _loadAsync(
DeflockOfflineTileImageProvider key,
ImageDecoderCallback decode,
@@ -194,78 +303,169 @@ class DeflockOfflineTileImageProvider
Future<Codec> decodeBytes(Uint8List bytes) =>
ImmutableBuffer.fromUint8List(bytes).then(decode);
Future<Codec> transparent() =>
decodeBytes(TileProvider.transparentImage);
// Track cancellation synchronously via Completer so the catch block
// can reliably check it without microtask ordering races.
final cancelled = Completer<void>();
cancelLoading.then((_) {
if (!cancelled.isCompleted) cancelled.complete();
}).ignore();
try {
// Track cancellation
bool cancelled = false;
cancelLoading.then((_) => cancelled = true);
// Try local tile first — pass captured IDs to avoid a race if the
// user switches provider while this async load is in flight.
try {
final localBytes = await fetchLocalTile(
z: coordinates.z,
x: coordinates.x,
y: coordinates.y,
providerId: providerId,
tileTypeId: tileTypeId,
);
return await decodeBytes(Uint8List.fromList(localBytes));
} catch (_) {
// Local miss — fall through to network if online
if (isOfflineOnly) {
return await _loadOffline(decodeBytes, cancelled);
}
if (cancelled) return await transparent();
if (isOfflineOnly) return await transparent();
// Fall back to network via shared RetryClient.
// Race the download against cancelLoading so we stop waiting if the
// tile is pruned mid-flight (the underlying TCP connection is cleaned
// up naturally by the shared client).
final request = Request('GET', Uri.parse(tileUrl));
request.headers.addAll(headers);
final networkFuture = httpClient.send(request).then((response) async {
final bytes = await response.stream.toBytes();
return (statusCode: response.statusCode, bytes: bytes);
});
final result = await Future.any([
networkFuture,
cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))),
]);
if (cancelled || result.statusCode == 0) return await transparent();
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
return await decodeBytes(result.bytes);
}
return await transparent();
return await _loadOnline(decodeBytes, cancelled);
} catch (e) {
// Don't log routine offline misses
if (!e.toString().contains('offline')) {
debugPrint(
'[DeflockTileProvider] Offline-first tile failed '
'${coordinates.z}/${coordinates.x}/${coordinates.y} '
'(${e.runtimeType})');
// Cancelled tiles throw — flutter_map handles the error silently.
// Preserve TileNotAvailableOfflineException even if the tile was also
// cancelled — it has distinct semantics (genuine cache miss) that
// matter for diagnostics and future UI indicators.
if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) {
throw const TileLoadCancelledException();
}
return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage)
.then(decode);
// Let real errors propagate so flutter_map marks loadError = true
rethrow;
}
}
/// Online mode: disk cache → offline areas → network (with caching).
Future<Codec> _loadOnline(
Future<Codec> Function(Uint8List) decodeBytes,
Completer<void> cancelled,
) async {
// 1. Check disk cache — fast hash-based file read.
final cachedTile = await _getCachedTile();
if (cachedTile != null && !cachedTile.metadata.isStale) {
return await decodeBytes(cachedTile.bytes);
}
// 2. Check offline areas — file scan per area.
try {
final localBytes = await fetchLocalTile(
z: coordinates.z,
x: coordinates.x,
y: coordinates.y,
providerId: providerId,
tileTypeId: tileTypeId,
);
return await decodeBytes(Uint8List.fromList(localBytes));
} catch (_) {
// Local miss — fall through to network
}
// 3. If cancelled before network, bail.
if (cancelled.isCompleted) throw const TileLoadCancelledException();
// 4. Network fetch with conditional headers from stale cache entry.
final request = Request('GET', Uri.parse(tileUrl));
request.headers.addAll(headers);
if (cachedTile != null) {
if (cachedTile.metadata.lastModified case final lastModified?) {
request.headers[HttpHeaders.ifModifiedSinceHeader] =
HttpDate.format(lastModified);
}
if (cachedTile.metadata.etag case final etag?) {
request.headers[HttpHeaders.ifNoneMatchHeader] = etag;
}
}
// 5. Race the download against cancelLoading.
final networkFuture = httpClient.send(request).then((response) async {
final bytes = await response.stream.toBytes();
return (
statusCode: response.statusCode,
bytes: bytes,
headers: response.headers,
);
});
final result = await Future.any([
networkFuture,
cancelLoading.then((_) => (
statusCode: 0,
bytes: Uint8List(0),
headers: <String, String>{},
)),
]);
// 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<Codec> _loadOffline(
Future<Codec> Function(Uint8List) decodeBytes,
Completer<void> cancelled,
) async {
// 1. Check offline areas (primary source — guaranteed available).
try {
final localBytes = await fetchLocalTile(
z: coordinates.z,
x: coordinates.x,
y: coordinates.y,
providerId: providerId,
tileTypeId: tileTypeId,
);
if (cancelled.isCompleted) throw const TileLoadCancelledException();
return await decodeBytes(Uint8List.fromList(localBytes));
} on TileLoadCancelledException {
rethrow;
} catch (_) {
// Local miss — fall through to disk cache
}
// 2. Check disk cache (tiles cached from previous online sessions).
if (cancelled.isCompleted) throw const TileLoadCancelledException();
final cachedTile = await _getCachedTile();
if (cachedTile != null) {
return await decodeBytes(cachedTile.bytes);
}
// 3. Both miss — throw so flutter_map marks the tile as failed.
throw const TileNotAvailableOfflineException();
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is DeflockOfflineTileImageProvider &&
other.coordinates == coordinates &&
other.providerId == providerId &&
other.tileTypeId == tileTypeId;
other.tileTypeId == tileTypeId &&
other.isOfflineOnly == isOfflineOnly;
}
@override
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
int get hashCode =>
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
}
@@ -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<List<OsmNode>> _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
}
@@ -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<List<int>> 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<List<int>> 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<List<int>> 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';
+26 -3
View File
@@ -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<OfflineArea> areas) {
_areas
..clear()
..addAll(areas);
_initialized = true;
}
/// Cancel all active downloads (used when enabling offline mode)
Future<void> 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,
@@ -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<String, dynamic> 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'] ?? '',
@@ -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<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
Set<List<int>> 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<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
lonMin -= epsilon;
lonMax += epsilon;
}
return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax));
}
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
Set<List<int>> 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);
@@ -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<String, ProviderTileCacheStore> _stores = {};
static String? _baseCacheDir;
/// Resolve the platform cache directory. Call once at startup.
static Future<void> 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<void> 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<Map<String, int>> getCacheSizes() async {
final sizes = <String, int>{};
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<void> 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;
}
}
+315
View File
@@ -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<void>? _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<CachedMapTile?> 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<String, dynamic>;
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<void> 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<void> _ensureDirectory() {
if (_directoryReady == null) {
final completer = Completer<void>();
_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<int> _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<void> _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 = <File>[];
final metaFiles = <String>{};
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 = <String>{};
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<void> 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<int> get estimatedSizeBytes => _getEstimatedSize();
/// Force an eviction check, bypassing the throttle.
/// Only exposed for testing — production code uses [_scheduleEvictionCheck].
@visibleForTesting
Future<void> forceEviction() => _evictIfNeeded();
}
+97 -27
View File
@@ -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<SearchResult> 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<String, _CachedResult> _resultCache = {};
/// Search for places using Nominatim geocoding service
Future<List<SearchResult>> 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<List<SearchResult>> _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<List<SearchResult>> _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<dynamic> jsonResults = json.decode(response.body);
final results = jsonResults
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
.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);
}
}
}
}
+402
View File
@@ -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<String, ServiceType> _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<ServiceType, ServicePolicy> _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<String, ServicePolicy> _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<ServiceType, DateTime> _lastRequestTime = {};
/// Per-service concurrency semaphores.
static final Map<ServiceType, _Semaphore> _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<void> 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<Completer<void>> _waiters = [];
_Semaphore(this._maxCount);
Future<void> acquire() async {
if (_currentCount < _maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_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.',
);
}
}
}
+37 -9
View File
@@ -262,16 +262,44 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
ElevatedButton(
onPressed: isOfflineMode ? null : () async {
try {
// Get current tile provider info
final appState = context.read<AppState>();
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<AppState>();
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<DownloadAreaDialog> {
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(
+74 -19
View File
@@ -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,
),
),
),
+228 -24
View File
@@ -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<String, DeflockTileProvider> _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<void> _resetController =
StreamController<void>.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<void> 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,
);
});
}
}
+4 -9
View File
@@ -284,17 +284,12 @@ class MapViewState extends State<MapView> {
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 addmode target once, after first controller center is available.
if (session != null && session.target == null) {
@@ -396,7 +391,7 @@ class MapViewState extends State<MapView> {
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),
+1 -1
View File
@@ -178,7 +178,7 @@ packages:
source: hosted
version: "0.2.3"
fake_async:
dependency: transitive
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+1
View File
@@ -43,6 +43,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
fake_async: ^1.3.0
flutter_launcher_icons: ^0.14.4
flutter_lints: ^6.0.0
flutter_native_splash: ^2.4.6
+372 -52
View File
@@ -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<void>.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<void>.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<void>.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<void>.value();
final imageProvider = cachingProvider.getImageWithCancelLoadingSupport(
coords,
options,
cancelLoading,
);
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
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<void>.value();
final imageProvider = provider.getImageWithCancelLoadingSupport(
coords,
options,
cancelLoading,
);
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
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<Object> loadAndExpectError(
DeflockOfflineTileImageProvider provider) {
final completer = Completer<Object>();
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<void>().future, // never cancels
isOfflineOnly: true,
providerId: 'nonexistent',
tileTypeId: 'nonexistent',
tileUrl: 'https://example.com/3/1/2.png',
),
);
expect(error, isA<TileNotAvailableOfflineException>());
});
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<void>.value(), // already cancelled
isOfflineOnly: true,
providerId: 'nonexistent',
tileTypeId: 'nonexistent',
tileUrl: 'https://example.com/3/1/2.png',
),
);
expect(error, isA<TileLoadCancelledException>());
});
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<void>.value(), // already cancelled
isOfflineOnly: false,
providerId: 'nonexistent',
tileTypeId: 'nonexistent',
tileUrl: 'https://example.com/3/1/2.png',
),
);
expect(error, isA<TileLoadCancelledException>());
});
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<void>().future, // never cancels
isOfflineOnly: false,
providerId: 'nonexistent',
tileTypeId: 'nonexistent',
tileUrl: 'https://example.com/6/4/5.png',
),
);
expect(error, isA<HttpException>());
expect((error as HttpException).message, contains('6/4/5'));
expect(error.message, contains('500'));
});
});
}
@@ -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);
});
});
}
@@ -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<void> 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<File>()
.where((f) => f.path.endsWith('.tile'))
.length;
final metaFiles = files
.whereType<File>()
.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');
});
});
}
+426
View File
@@ -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<void>.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');
});
});
}
+227
View File
@@ -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)',
);
}
}
});
});
}
@@ -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 = <void>[];
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 = <void>[];
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 = <void>[];
late StreamSubscription<void> 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 nullosm 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 = <void>[];
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 = <void>[];
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 = <void>[];
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 = <void>[];
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));
});
});
});
}