Compare commits

..
Author SHA1 Message Date
stopflock 2d00d7a8fb bump ver 2026-03-12 22:30:16 -05:00
stopflock f85db3ca11 add "report osm map issue" link to info/about page 2026-03-12 22:19:21 -05:00
stopflock 447f358727 Limit max files open to prevent OS error for too many files open related to tile storage 2026-03-12 19:58:07 -05:00
stopflock 08b395214b Move "about osm" to about page, add option to clear tile caches, option to add reason when deleting, min zoom 16 for edits/submissions, 300km nav distance warning 2026-03-12 18:59:48 -05:00
stopflock 256dd1a43c Merge pull request #145 from dougborg/feat/resilience-policy
Add endpoint migration with centralized retry/fallback policy
2026-03-12 11:42:28 -05:00
Doug BorgandClaude Opus 4.6 ca7192d3ec Add changelog entry for retry/fallback feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:20:08 -06:00
Doug BorgandClaude Opus 4.6 2833906c68 Add centralized retry/fallback policy with hard-coded endpoints
Extract duplicated retry logic from OverpassService and RoutingService
into a shared resilience framework in service_policy.dart:

- ResiliencePolicy: configurable retries, backoff, and HTTP timeout
- executeWithFallback: retry loop with primary→fallback endpoint chain
- ErrorDisposition enum: abort / fallback / retry classification
- ServicePolicy + ServicePolicyResolver: per-service compliance rules
  (rate limits, caching, concurrency) for OSMF and third-party services
- ServiceRateLimiter: async semaphore-based concurrency and rate control

OverpassService now hits overpass.deflock.org first, falls back to
overpass-api.de. RoutingService hits api.dontgetflocked.com first,
falls back to alprwatch.org. Both use per-service error classifiers
to determine retry vs fallback vs abort behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:13:52 -06:00
35 changed files with 1300 additions and 333 deletions

No files matched your search

+10 -1
View File
@@ -1,7 +1,16 @@
{ {
"2.9.2": {
"content": [
"• Moved 'About OpenStreetMap' section from OSM Account page to Settings > About for better organization",
"• Added 'Clear Caches' option to tile provider menus - easily free up storage space by clearing cached tiles for specific providers",
"• Enhanced node deletion workflow - users can now provide an informative reason when deleting surveillance devices",
"• Added 'Report issue with OSM base map' link in About screen help section - easily report issues with the underlying OpenStreetMap data"
]
},
"2.9.1": { "2.9.1": {
"content": [ "content": [
"• When hitting node render limit, only render nodes closest to center of viewport." "• When hitting node render limit, only render nodes closest to center of viewport.",
"• Moved to our own infrastructure for Overpass and routing services, with automatic fallback to public servers."
] ]
}, },
"2.9.0": { "2.9.0": {
-2
View File
@@ -20,7 +20,5 @@
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict> </dict>
</plist> </plist>
+5 -2
View File
@@ -2,12 +2,15 @@ import Flutter
import UIKit import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
} }
+46 -27
View File
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -20,18 +22,58 @@
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLSchemes</key>
<array>
<string>deflockapp</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string> <string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
@@ -45,28 +87,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> </dict>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- OAuth2 redirect handler -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>None</string>
<key>CFBundleURLSchemes</key>
<array>
<string>deflockapp</string>
</array>
</dict>
</array>
<!-- (Optional) allow opening the system browser and returning -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist> </plist>
+7 -2
View File
@@ -581,8 +581,8 @@ class AppState extends ChangeNotifier {
} }
} }
void deleteNode(OsmNode node) { void deleteNode(OsmNode node, {String? changesetComment}) {
_uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode); _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment);
_startUploader(); _startUploader();
} }
@@ -717,6 +717,11 @@ class AppState extends ChangeNotifier {
await _settingsState.deleteTileProvider(providerId); await _settingsState.deleteTileProvider(providerId);
} }
/// Clear all tile caches for a specific provider
Future<void> clearTileProviderCaches(String providerId) async {
await _settingsState.clearTileProviderCaches(providerId);
}
/// Set follow-me mode /// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async { Future<void> setFollowMeMode(FollowMeMode mode) async {
await _settingsState.setFollowMeMode(mode); await _settingsState.setFollowMeMode(mode);
+3 -6
View File
@@ -64,9 +64,6 @@ const Duration kChangesetCloseMaxRetryDelay = Duration(minutes: 5); // Cap at 5
const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close const Duration kChangesetAutoCloseTimeout = Duration(minutes: 59); // Give up and trust OSM auto-close
const double kChangesetCloseBackoffMultiplier = 2.0; const double kChangesetCloseBackoffMultiplier = 2.0;
// Navigation routing configuration
const Duration kNavigationRoutingTimeout = Duration(seconds: 90); // HTTP timeout for routing requests
// Overpass API configuration // Overpass API configuration
const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded) const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Overpass API queries (was 25s hardcoded)
@@ -93,7 +90,7 @@ bool enableNavigationFeatures({required bool offlineMode}) {
// Marker/node interaction // Marker/node interaction
const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) const int kNodeMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass)
const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode) const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode)
const int kMinZoomForNodeEditingSheets = 15; // Minimum zoom to open add/edit node sheets const int kMinZoomForNodeEditingSheets = 16; // Minimum zoom to open add/edit node sheets
const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes) const int kMinZoomForOfflineDownload = 10; // Minimum zoom to download offline areas (prevents large area crashes)
const Duration kMarkerTapTimeout = Duration(milliseconds: 250); const Duration kMarkerTapTimeout = Duration(milliseconds: 250);
const Duration kDebounceCameraRefresh = Duration(milliseconds: 500); const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
@@ -131,7 +128,7 @@ const int kProximityAlertMaxDistance = 1600; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Node proximity warning configuration (for new/edited nodes that are too close to existing ones) // Node proximity warning configuration (for new/edited nodes that are too close to existing ones)
const double kNodeProximityWarningDistance = 15.0; // meters - distance threshold to show warning const double kNodeProximityWarningDistance = 50.0; // meters - distance threshold to show warning
// Positioning tutorial configuration // Positioning tutorial configuration
const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay const double kPositioningTutorialBlurSigma = 3.0; // Blur strength for sheet overlay
@@ -139,7 +136,7 @@ const double kPositioningTutorialMinMovementMeters = 1.0; // Minimum map movemen
// Navigation route planning configuration // Navigation route planning configuration
const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance between start and end points
const double kNavigationDistanceWarningThreshold = 20000.0; // meters - distance threshold for timeout warning (30km) const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km)
// Node display configuration // Node display configuration
const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once
+6 -1
View File
@@ -95,7 +95,9 @@
"editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht", "editQueuedForUpload": "Knotenbearbeitung zum Upload eingereiht",
"deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht", "deleteQueuedForUpload": "Knoten-Löschung zum Upload eingereiht",
"confirmDeleteTitle": "Knoten löschen", "confirmDeleteTitle": "Knoten löschen",
"confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden." "confirmDeleteMessage": "Sind Sie sicher, dass Sie Knoten #{} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteReasonLabel": "Grund für Löschung (Optional)",
"deleteReasonHint": "z.B. Gerät entfernt, falsche Position, Duplikat..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -247,6 +249,9 @@
"addProvider": "Anbieter Hinzufügen", "addProvider": "Anbieter Hinzufügen",
"deleteProvider": "Anbieter Löschen", "deleteProvider": "Anbieter Löschen",
"deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?", "deleteProviderConfirm": "Sind Sie sicher, dass Sie \"{}\" löschen möchten?",
"clearCaches": "Caches Leeren",
"clearCachesConfirm": "Alle Kachel-Caches für \"{}\" löschen? Dies wird Speicherplatz freigeben, aber die Kacheln müssen beim Betrachten dieser Bereiche erneut heruntergeladen werden.",
"cachesCleared": "Caches für \"{}\" geleert",
"providerName": "Anbieter-Name", "providerName": "Anbieter-Name",
"providerNameHint": "z.B. Benutzerdefinierte Karten GmbH", "providerNameHint": "z.B. Benutzerdefinierte Karten GmbH",
"providerNameRequired": "Anbieter-Name ist erforderlich", "providerNameRequired": "Anbieter-Name ist erforderlich",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Node edit queued for upload", "editQueuedForUpload": "Node edit queued for upload",
"deleteQueuedForUpload": "Node deletion queued for upload", "deleteQueuedForUpload": "Node deletion queued for upload",
"confirmDeleteTitle": "Delete Node", "confirmDeleteTitle": "Delete Node",
"confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone." "confirmDeleteMessage": "Are you sure you want to delete node #{}? This action cannot be undone.",
"deleteReasonLabel": "Reason for Deletion (Optional)",
"deleteReasonHint": "e.g., device removed, incorrect location, duplicate..."
}, },
"addNode": { "addNode": {
"profile": "Profile", "profile": "Profile",
@@ -284,6 +286,9 @@
"addProvider": "Add Provider", "addProvider": "Add Provider",
"deleteProvider": "Delete Provider", "deleteProvider": "Delete Provider",
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?", "deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
"clearCaches": "Clear Caches",
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
"cachesCleared": "Caches cleared for \"{}\"",
"providerName": "Provider Name", "providerName": "Provider Name",
"providerNameHint": "e.g., Custom Maps Inc.", "providerNameHint": "e.g., Custom Maps Inc.",
"providerNameRequired": "Provider name is required", "providerNameRequired": "Provider name is required",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Edición de nodo en cola para subir", "editQueuedForUpload": "Edición de nodo en cola para subir",
"deleteQueuedForUpload": "Eliminación de nodo en cola para subir", "deleteQueuedForUpload": "Eliminación de nodo en cola para subir",
"confirmDeleteTitle": "Eliminar Nodo", "confirmDeleteTitle": "Eliminar Nodo",
"confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer." "confirmDeleteMessage": "¿Estás seguro de que quieres eliminar el nodo #{}? Esta acción no se puede deshacer.",
"deleteReasonLabel": "Razón para Eliminación (Opcional)",
"deleteReasonHint": "ej. dispositivo removido, ubicación incorrecta, duplicado..."
}, },
"addNode": { "addNode": {
"profile": "Perfil", "profile": "Perfil",
@@ -284,6 +286,9 @@
"addProvider": "Agregar Proveedor", "addProvider": "Agregar Proveedor",
"deleteProvider": "Eliminar Proveedor", "deleteProvider": "Eliminar Proveedor",
"deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?", "deleteProviderConfirm": "¿Está seguro de que desea eliminar \"{}\"?",
"clearCaches": "Limpiar Caché",
"clearCachesConfirm": "¿Limpiar todo el caché de mosaicos para \"{}\"? Esto liberará espacio de almacenamiento pero los mosaicos deberán descargarse nuevamente al ver esas áreas.",
"cachesCleared": "Caché limpiado para \"{}\"",
"providerName": "Nombre del Proveedor", "providerName": "Nombre del Proveedor",
"providerNameHint": "ej., Mapas Personalizados Inc.", "providerNameHint": "ej., Mapas Personalizados Inc.",
"providerNameRequired": "El nombre del proveedor es requerido", "providerNameRequired": "El nombre del proveedor es requerido",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Modification de nœud mise en file pour envoi", "editQueuedForUpload": "Modification de nœud mise en file pour envoi",
"deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi", "deleteQueuedForUpload": "Suppression de nœud mise en file pour envoi",
"confirmDeleteTitle": "Supprimer le Nœud", "confirmDeleteTitle": "Supprimer le Nœud",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée." "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le nœud #{} ? Cette action ne peut pas être annulée.",
"deleteReasonLabel": "Raison de Suppression (Optionnel)",
"deleteReasonHint": "ex. appareil retiré, emplacement incorrect, doublon..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -284,6 +286,9 @@
"addProvider": "Ajouter Fournisseur", "addProvider": "Ajouter Fournisseur",
"deleteProvider": "Supprimer Fournisseur", "deleteProvider": "Supprimer Fournisseur",
"deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?", "deleteProviderConfirm": "Êtes-vous sûr de vouloir supprimer \"{}\"?",
"clearCaches": "Vider les Caches",
"clearCachesConfirm": "Vider tous les caches de tuiles pour \"{}\"? Cela libérera de l'espace de stockage mais les tuiles devront être téléchargées à nouveau lors de la consultation de ces zones.",
"cachesCleared": "Caches vidés pour \"{}\"",
"providerName": "Nom du Fournisseur", "providerName": "Nom du Fournisseur",
"providerNameHint": "ex., Cartes Personnalisées Inc.", "providerNameHint": "ex., Cartes Personnalisées Inc.",
"providerNameRequired": "Le nom du fournisseur est requis", "providerNameRequired": "Le nom du fournisseur est requis",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Modifica nodo in coda per il caricamento", "editQueuedForUpload": "Modifica nodo in coda per il caricamento",
"deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento", "deleteQueuedForUpload": "Eliminazione nodo in coda per il caricamento",
"confirmDeleteTitle": "Elimina Nodo", "confirmDeleteTitle": "Elimina Nodo",
"confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata." "confirmDeleteMessage": "Sei sicuro di voler eliminare il nodo #{}? Questa azione non può essere annullata.",
"deleteReasonLabel": "Motivo per Eliminazione (Opzionale)",
"deleteReasonHint": "es. dispositivo rimosso, posizione errata, duplicato..."
}, },
"addNode": { "addNode": {
"profile": "Profilo", "profile": "Profilo",
@@ -284,6 +286,9 @@
"addProvider": "Aggiungi Fornitore", "addProvider": "Aggiungi Fornitore",
"deleteProvider": "Elimina Fornitore", "deleteProvider": "Elimina Fornitore",
"deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?", "deleteProviderConfirm": "Sei sicuro di voler eliminare \"{}\"?",
"clearCaches": "Svuota Cache",
"clearCachesConfirm": "Svuotare tutte le cache delle tessere per \"{}\"? Questo libererà spazio di archiviazione ma le tessere dovranno essere scaricate nuovamente quando si visualizzano quelle aree.",
"cachesCleared": "Cache svuotate per \"{}\"",
"providerName": "Nome Fornitore", "providerName": "Nome Fornitore",
"providerNameHint": "es., Mappe Personalizzate Inc.", "providerNameHint": "es., Mappe Personalizzate Inc.",
"providerNameRequired": "Il nome del fornitore è obbligatorio", "providerNameRequired": "Il nome del fornitore è obbligatorio",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload", "editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload", "deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
"confirmDeleteTitle": "Verwijder Node", "confirmDeleteTitle": "Verwijder Node",
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." "confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleteReasonLabel": "Reden voor Verwijdering (Optioneel)",
"deleteReasonHint": "bijv. apparaat verwijderd, onjuiste locatie, duplicaat..."
}, },
"addNode": { "addNode": {
"profile": "Profiel", "profile": "Profiel",
@@ -284,6 +286,9 @@
"addProvider": "Voeg Provider Toe", "addProvider": "Voeg Provider Toe",
"deleteProvider": "Verwijder Provider", "deleteProvider": "Verwijder Provider",
"deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?", "deleteProviderConfirm": "Weet u zeker dat u \"{}\" wilt verwijderen?",
"clearCaches": "Cache Wissen",
"clearCachesConfirm": "Alle tegelcaches wissen voor \"{}\"? Dit zal opslagruimte vrijmaken maar tegels moeten opnieuw worden gedownload bij het bekijken van die gebieden.",
"cachesCleared": "Caches gewist voor \"{}\"",
"providerName": "Provider Naam", "providerName": "Provider Naam",
"providerNameHint": "bijv., Aangepaste Kaarten B.V.", "providerNameHint": "bijv., Aangepaste Kaarten B.V.",
"providerNameRequired": "Provider naam is vereist", "providerNameRequired": "Provider naam is vereist",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania", "editQueuedForUpload": "Edycja węzła umieszczona w kolejce do przesłania",
"deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania", "deleteQueuedForUpload": "Usuwanie węzła umieszczone w kolejce do przesłania",
"confirmDeleteTitle": "Usuń Węzeł", "confirmDeleteTitle": "Usuń Węzeł",
"confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć." "confirmDeleteMessage": "Czy na pewno chcesz usunąć węzeł #{}? Tej akcji nie można cofnąć.",
"deleteReasonLabel": "Powód Usunięcia (Opcjonalny)",
"deleteReasonHint": "np. urządzenie usunięte, nieprawidłowa lokalizacja, duplikat..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -284,6 +286,9 @@
"addProvider": "Dodaj Dostawcę", "addProvider": "Dodaj Dostawcę",
"deleteProvider": "Usuń Dostawcę", "deleteProvider": "Usuń Dostawcę",
"deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?", "deleteProviderConfirm": "Czy na pewno chcesz usunąć \"{}\"?",
"clearCaches": "Wyczyść Cache",
"clearCachesConfirm": "Wyczyścić wszystkie cache kafelków dla \"{}\"? To zwolni miejsce na dysku, ale kafelki będą musiały zostać ponownie pobrane podczas przeglądania tych obszarów.",
"cachesCleared": "Cache wyczyszczone dla \"{}\"",
"providerName": "Nazwa Dostawcy", "providerName": "Nazwa Dostawcy",
"providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.", "providerNameHint": "np., Niestandardowe Mapy Sp. z o.o.",
"providerNameRequired": "Nazwa dostawcy jest wymagana", "providerNameRequired": "Nazwa dostawcy jest wymagana",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Edição de nó na fila para envio", "editQueuedForUpload": "Edição de nó na fila para envio",
"deleteQueuedForUpload": "Exclusão de nó na fila para envio", "deleteQueuedForUpload": "Exclusão de nó na fila para envio",
"confirmDeleteTitle": "Excluir Nó", "confirmDeleteTitle": "Excluir Nó",
"confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita." "confirmDeleteMessage": "Tem certeza de que deseja excluir o nó #{}? Esta ação não pode ser desfeita.",
"deleteReasonLabel": "Motivo para Exclusão (Opcional)",
"deleteReasonHint": "ex. dispositivo removido, localização incorreta, duplicado..."
}, },
"addNode": { "addNode": {
"profile": "Perfil", "profile": "Perfil",
@@ -284,6 +286,9 @@
"addProvider": "Adicionar Provedor", "addProvider": "Adicionar Provedor",
"deleteProvider": "Excluir Provedor", "deleteProvider": "Excluir Provedor",
"deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?", "deleteProviderConfirm": "Tem certeza de que deseja excluir \"{}\"?",
"clearCaches": "Limpar Caches",
"clearCachesConfirm": "Limpar todos os caches de mapas para \"{}\"? Isso liberará espaço de armazenamento, mas os mapas precisarão ser baixados novamente ao visualizar essas áreas.",
"cachesCleared": "Caches limpos para \"{}\"",
"providerName": "Nome do Provedor", "providerName": "Nome do Provedor",
"providerNameHint": "ex., Mapas Personalizados Inc.", "providerNameHint": "ex., Mapas Personalizados Inc.",
"providerNameRequired": "Nome do provedor é obrigatório", "providerNameRequired": "Nome do provedor é obrigatório",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı", "editQueuedForUpload": "Düğüm düzenlemesi yükleme için sıraya alındı",
"deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı", "deleteQueuedForUpload": "Düğüm silme işlemi yükleme için sıraya alındı",
"confirmDeleteTitle": "Düğümü Sil", "confirmDeleteTitle": "Düğümü Sil",
"confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz." "confirmDeleteMessage": "#{} düğümünü silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"deleteReasonLabel": "Silme Nedeni (İsteğe Bağlı)",
"deleteReasonHint": "örn. cihaz kaldırıldı, yanlış konum, duplikat..."
}, },
"addNode": { "addNode": {
"profile": "Profil", "profile": "Profil",
@@ -284,6 +286,9 @@
"addProvider": "Sağlayıcı Ekle", "addProvider": "Sağlayıcı Ekle",
"deleteProvider": "Sağlayıcıyı Sil", "deleteProvider": "Sağlayıcıyı Sil",
"deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?", "deleteProviderConfirm": "\"{}\" silmek istediğinizden emin misiniz?",
"clearCaches": "Önbellekleri Temizle",
"clearCachesConfirm": "\"{}\" için tüm döşeme önbelleklerini temizle? Bu depolama alanını boşaltacak ancak bu alanları görüntülerken döşemelerin tekrar indirilmesi gerekecek.",
"cachesCleared": "\"{}\" için önbellekler temizlendi",
"providerName": "Sağlayıcı Adı", "providerName": "Sağlayıcı Adı",
"providerNameHint": "örn., Özel Haritalar A.Ş.", "providerNameHint": "örn., Özel Haritalar A.Ş.",
"providerNameRequired": "Sağlayıcı adı gerekli", "providerNameRequired": "Sağlayıcı adı gerekli",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження", "editQueuedForUpload": "Редагування вузла поставлено в чергу для завантаження",
"deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження", "deleteQueuedForUpload": "Видалення вузла поставлено в чергу для завантаження",
"confirmDeleteTitle": "Видалити Вузол", "confirmDeleteTitle": "Видалити Вузол",
"confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати." "confirmDeleteMessage": "Ви впевнені, що хочете видалити вузол #{}? Цю дію не можна скасувати.",
"deleteReasonLabel": "Причина Видалення (Опціонально)",
"deleteReasonHint": "наприклад пристрій видалено, неправильне розташування, дублікат..."
}, },
"addNode": { "addNode": {
"profile": "Профіль", "profile": "Профіль",
@@ -284,6 +286,9 @@
"addProvider": "Додати Постачальника", "addProvider": "Додати Постачальника",
"deleteProvider": "Видалити Постачальника", "deleteProvider": "Видалити Постачальника",
"deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?", "deleteProviderConfirm": "Ви впевнені, що хочете видалити \"{}\"?",
"clearCaches": "Очистити Кеш",
"clearCachesConfirm": "Очистити всі кеші тайлів для \"{}\"? Це звільнить місце на диску, але тайли доведеться знову завантажити при перегляді цих областей.",
"cachesCleared": "Кеш очищено для \"{}\"",
"providerName": "Назва Постачальника", "providerName": "Назва Постачальника",
"providerNameHint": "напр., Кастомні Карти ТОВ", "providerNameHint": "напр., Кастомні Карти ТОВ",
"providerNameRequired": "Назва постачальника обов'язкова", "providerNameRequired": "Назва постачальника обов'язкова",
+6 -1
View File
@@ -132,7 +132,9 @@
"editQueuedForUpload": "节点编辑已排队上传", "editQueuedForUpload": "节点编辑已排队上传",
"deleteQueuedForUpload": "节点删除已排队上传", "deleteQueuedForUpload": "节点删除已排队上传",
"confirmDeleteTitle": "删除节点", "confirmDeleteTitle": "删除节点",
"confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。" "confirmDeleteMessage": "您确定要删除节点 #{} 吗?此操作无法撤销。",
"deleteReasonLabel": "删除原因(可选)",
"deleteReasonHint": "例如:设备已移除、位置错误、重复..."
}, },
"addNode": { "addNode": {
"profile": "配置文件", "profile": "配置文件",
@@ -284,6 +286,9 @@
"addProvider": "添加提供商", "addProvider": "添加提供商",
"deleteProvider": "删除提供商", "deleteProvider": "删除提供商",
"deleteProviderConfirm": "您确定要删除 \"{}\" 吗?", "deleteProviderConfirm": "您确定要删除 \"{}\" 吗?",
"clearCaches": "清除缓存",
"clearCachesConfirm": "清除 \"{}\" 的所有瓦片缓存?这将释放存储空间,但在查看这些区域时需要重新下载瓦片。",
"cachesCleared": "已清除 \"{}\" 的缓存",
"providerName": "提供商名称", "providerName": "提供商名称",
"providerNameHint": "例如,自定义地图公司", "providerNameHint": "例如,自定义地图公司",
"providerNameRequired": "提供商名称为必填项", "providerNameRequired": "提供商名称为必填项",
+38
View File
@@ -76,6 +76,9 @@ class AboutScreen extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// About OpenStreetMap section
_buildAboutOSMSection(context),
const SizedBox(height: 24),
// Information dialogs section // Information dialogs section
_buildDialogButtons(context), _buildDialogButtons(context),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -96,6 +99,8 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'), _buildLinkText(context, 'Privacy Policy', 'https://deflock.me/privacy'),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildLinkText(context, 'Report issue with OSM base map', 'https://www.openstreetmap.org/fixthemap'),
const SizedBox(height: 8),
_buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'), _buildLinkText(context, 'DeFlock Discord', 'https://discord.gg/aV7v4R3sKT'),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'), _buildLinkText(context, 'Source Code', 'https://github.com/FoggedLens/deflock-app'),
@@ -121,6 +126,39 @@ class AboutScreen extends StatelessWidget {
); );
} }
Widget _buildAboutOSMSection(BuildContext context) {
final locService = LocalizationService.instance;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('auth.aboutOSM'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('auth.aboutOSMDescription'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _launchUrl('https://openstreetmap.org', context),
icon: const Icon(Icons.open_in_new),
label: Text(locService.t('auth.visitOSM')),
),
),
],
),
),
);
}
Widget _buildDialogButtons(BuildContext context) { Widget _buildDialogButtons(BuildContext context) {
final locService = LocalizationService.instance; final locService = LocalizationService.instance;
-41
View File
@@ -198,47 +198,6 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
// Information Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('auth.aboutOSM'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('auth.aboutOSMDescription'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final url = Uri.parse('https://openstreetmap.org');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
icon: const Icon(Icons.open_in_new),
label: Text(locService.t('auth.visitOSM')),
),
),
],
),
),
),
// Account deletion section - only show when logged in and not in simulate mode // Account deletion section - only show when logged in and not in simulate mode
if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[ if (appState.isLoggedIn && appState.uploadMode != UploadMode.simulate) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -104,6 +104,9 @@ class TileProviderSection extends StatelessWidget {
case 'edit': case 'edit':
_editProvider(context, provider); _editProvider(context, provider);
break; break;
case 'clear_cache':
_clearProviderCaches(context, provider);
break;
case 'delete': case 'delete':
_deleteProvider(context, provider); _deleteProvider(context, provider);
break; break;
@@ -120,6 +123,16 @@ class TileProviderSection extends StatelessWidget {
], ],
), ),
), ),
PopupMenuItem(
value: 'clear_cache',
child: Row(
children: [
const Icon(Icons.clear_all),
const SizedBox(width: 8),
Text(locService.t('tileProviders.clearCaches')),
],
),
),
PopupMenuItem( PopupMenuItem(
value: 'delete', value: 'delete',
child: Row( child: Row(
@@ -154,6 +167,37 @@ class TileProviderSection extends StatelessWidget {
); );
} }
void _clearProviderCaches(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('tileProviders.clearCaches')),
content: Text(locService.t('tileProviders.clearCachesConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await context.read<AppState>().clearTileProviderCaches(provider.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('tileProviders.cachesCleared', params: [provider.name])),
),
);
}
},
child: Text(locService.t('tileProviders.clearCaches')),
),
],
),
);
}
void _deleteProvider(BuildContext context, TileProvider provider) { void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance; final locService = LocalizationService.instance;
showDialog( showDialog(
+43 -34
View File
@@ -8,41 +8,66 @@ import '../models/node_profile.dart';
import '../models/osm_node.dart'; import '../models/osm_node.dart';
import '../dev_config.dart'; import '../dev_config.dart';
import 'http_client.dart'; import 'http_client.dart';
import 'service_policy.dart';
/// Simple Overpass API client with proper HTTP retry logic. /// Simple Overpass API client with retry and fallback logic.
/// Single responsibility: Make requests, handle network errors, return data. /// Single responsibility: Make requests, handle network errors, return data.
class OverpassService { class OverpassService {
static const String _endpoint = 'https://overpass-api.de/api/interpreter'; static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter';
static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter';
static const _policy = ResiliencePolicy(
maxRetries: 3,
httpTimeout: Duration(seconds: 45),
);
final http.Client _client; final http.Client _client;
/// Optional override endpoint. When null, uses [defaultEndpoint].
final String? _endpointOverride;
OverpassService({http.Client? client}) : _client = client ?? UserAgentClient(); OverpassService({http.Client? client, String? endpoint})
: _client = client ?? UserAgentClient(),
_endpointOverride = endpoint;
/// Resolve the primary endpoint: constructor override or default.
String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint;
/// Fetch surveillance nodes from Overpass API with proper retry logic. /// Fetch surveillance nodes from Overpass API with retry and fallback.
/// Throws NetworkError for retryable failures, NodeLimitError for area splitting. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting.
Future<List<OsmNode>> fetchNodes({ Future<List<OsmNode>> fetchNodes({
required LatLngBounds bounds, required LatLngBounds bounds,
required List<NodeProfile> profiles, required List<NodeProfile> profiles,
int maxRetries = 3, ResiliencePolicy? policy,
}) async { }) async {
if (profiles.isEmpty) return []; if (profiles.isEmpty) return [];
final query = _buildQuery(bounds, profiles); final query = _buildQuery(bounds, profiles);
final endpoint = _primaryEndpoint;
final canFallback = _endpointOverride == null;
final effectivePolicy = policy ?? _policy;
return executeWithFallback<List<OsmNode>>(
primaryUrl: endpoint,
fallbackUrl: canFallback ? fallbackEndpoint : null,
execute: (url) => _attemptFetch(url, query, effectivePolicy),
classifyError: _classifyError,
policy: effectivePolicy,
);
}
/// Single POST + parse attempt (no retry logic — handled by executeWithFallback).
Future<List<OsmNode>> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async {
debugPrint('[OverpassService] POST $endpoint');
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
debugPrint('[OverpassService] Attempt ${attempt + 1}/${maxRetries + 1} for ${profiles.length} profiles');
final response = await _client.post( final response = await _client.post(
Uri.parse(_endpoint), Uri.parse(endpoint),
body: {'data': query}, body: {'data': query},
).timeout(kOverpassQueryTimeout); ).timeout(policy.httpTimeout);
if (response.statusCode == 200) { if (response.statusCode == 200) {
return _parseResponse(response.body); return _parseResponse(response.body);
} }
// Check for specific error types
final errorBody = response.body; final errorBody = response.body;
// Node limit error - caller should split area // Node limit error - caller should split area
@@ -60,7 +85,7 @@ class OverpassService {
throw NodeLimitError('Query timed out - area too complex'); throw NodeLimitError('Query timed out - area too complex');
} }
// Rate limit - throw immediately, don't retry // Rate limit
if (response.statusCode == 429 || if (response.statusCode == 429 ||
errorBody.contains('rate limited') || errorBody.contains('rate limited') ||
errorBody.contains('too many requests')) { errorBody.contains('too many requests')) {
@@ -68,35 +93,19 @@ class OverpassService {
throw RateLimitError('Rate limited by Overpass API'); throw RateLimitError('Rate limited by Overpass API');
} }
// Other HTTP errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] HTTP ${response.statusCode} error, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('HTTP ${response.statusCode}: $errorBody'); throw NetworkError('HTTP ${response.statusCode}: $errorBody');
} catch (e) { } catch (e) {
// Handle specific error types without retry if (e is NodeLimitError || e is RateLimitError || e is NetworkError) {
if (e is NodeLimitError || e is RateLimitError) {
rethrow; rethrow;
} }
throw NetworkError('Network error: $e');
// Network/timeout errors - retry with backoff
if (attempt < maxRetries) {
final delay = Duration(milliseconds: (200 * (1 << attempt)).clamp(200, 5000));
debugPrint('[OverpassService] Network error ($e), retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
throw NetworkError('Network error after $maxRetries retries: $e');
} }
} }
throw NetworkError('Max retries exceeded'); static ErrorDisposition _classifyError(Object error) {
if (error is NodeLimitError) return ErrorDisposition.abort;
if (error is RateLimitError) return ErrorDisposition.fallback;
return ErrorDisposition.retry;
} }
/// Build Overpass QL query for given bounds and profiles /// Build Overpass QL query for given bounds and profiles
@@ -61,10 +61,13 @@ class ProviderTileCacheManager {
/// Delete a specific provider's cache directory and remove the store. /// Delete a specific provider's cache directory and remove the store.
static Future<void> deleteCache(String providerId, String tileTypeId) async { static Future<void> deleteCache(String providerId, String tileTypeId) async {
final key = '$providerId/$tileTypeId'; final key = '$providerId/$tileTypeId';
final store = _stores.remove(key); final store = _stores[key];
if (store != null) { if (store != null) {
// Use the store's clear method to properly reset its internal state
await store.clear(); await store.clear();
// Don't remove from registry - let it be reused with clean state
} else if (_baseCacheDir != null) { } else if (_baseCacheDir != null) {
// Fallback for stores not in registry
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
if (await cacheDir.exists()) { if (await cacheDir.exists()) {
await cacheDir.delete(recursive: true); await cacheDir.delete(recursive: true);
+64 -3
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -27,6 +28,10 @@ class ProviderTileCacheStore implements MapCachingProvider {
/// [putTile] call to avoid blocking construction. /// [putTile] call to avoid blocking construction.
int? _estimatedSize; int? _estimatedSize;
/// Semaphore to limit concurrent file I/O operations and prevent
/// "too many open files" errors during heavy tile loading.
static final _ioSemaphore = _Semaphore(20); // Max 20 concurrent file operations
/// Throttle: don't re-scan more than once per minute. /// Throttle: don't re-scan more than once per minute.
DateTime? _lastPruneCheck; DateTime? _lastPruneCheck;
@@ -52,9 +57,16 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta')); final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try { try {
// Use semaphore to limit concurrent file I/O operations
final result = await _ioSemaphore.execute(() async {
final bytes = await tileFile.readAsBytes(); final bytes = await tileFile.readAsBytes();
final metaJson = json.decode(await metaFile.readAsString()) final metaJson = json.decode(await metaFile.readAsString())
as Map<String, dynamic>; as Map<String, dynamic>;
return (bytes: bytes, metaJson: metaJson);
});
final bytes = result.bytes;
final metaJson = result.metaJson;
final metadata = CachedMapTileMetadata( final metadata = CachedMapTileMetadata(
staleAt: DateTime.fromMillisecondsSinceEpoch( staleAt: DateTime.fromMillisecondsSinceEpoch(
@@ -120,10 +132,13 @@ class ProviderTileCacheStore implements MapCachingProvider {
// Write .tile before .meta: if we crash between the two writes, the // 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. // read path's both-must-exist check sees a miss rather than an orphan .meta.
// Use semaphore to limit concurrent file I/O and prevent "too many open files" errors.
await _ioSemaphore.execute(() async {
if (bytes != null) { if (bytes != null) {
await tileFile.writeAsBytes(bytes); await tileFile.writeAsBytes(bytes);
} }
await metaFile.writeAsString(metaJson); await metaFile.writeAsString(metaJson);
});
// Reset size estimate so it resyncs from disk on next check. // Reset size estimate so it resyncs from disk on next check.
// This avoids drift from overwrites where the old size isn't subtracted. // This avoids drift from overwrites where the old size isn't subtracted.
@@ -250,14 +265,19 @@ class ProviderTileCacheStore implements MapCachingProvider {
final metaFile = File(p.join(cacheDirectory, '$key.meta')); final metaFile = File(p.join(cacheDirectory, '$key.meta'));
try { try {
final deletedBytes = await _ioSemaphore.execute(() async {
await entry.file.delete(); await entry.file.delete();
freedBytes += entry.stat.size; var bytes = entry.stat.size;
evictedKeys.add(key);
if (await metaFile.exists()) { if (await metaFile.exists()) {
final metaStat = await metaFile.stat(); final metaStat = await metaFile.stat();
await metaFile.delete(); await metaFile.delete();
freedBytes += metaStat.size; bytes += metaStat.size;
} }
return bytes;
});
freedBytes += deletedBytes;
evictedKeys.add(key);
} catch (e) { } catch (e) {
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e'); debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
} }
@@ -313,3 +333,44 @@ class ProviderTileCacheStore implements MapCachingProvider {
@visibleForTesting @visibleForTesting
Future<void> forceEviction() => _evictIfNeeded(); Future<void> forceEviction() => _evictIfNeeded();
} }
/// Simple semaphore to limit concurrent operations and prevent resource exhaustion.
class _Semaphore {
final int maxCount;
int _currentCount;
final Queue<Completer<void>> _waitQueue = Queue<Completer<void>>();
_Semaphore(this.maxCount) : _currentCount = maxCount;
/// Acquire a permit. Returns a Future that completes when a permit is available.
Future<void> acquire() {
if (_currentCount > 0) {
_currentCount--;
return Future.value();
} else {
final completer = Completer<void>();
_waitQueue.add(completer);
return completer.future;
}
}
/// Release a permit, potentially unblocking a waiting operation.
void release() {
if (_waitQueue.isNotEmpty) {
final completer = _waitQueue.removeFirst();
completer.complete();
} else {
_currentCount++;
}
}
/// Execute a function while holding a permit.
Future<T> execute<T>(Future<T> Function() operation) async {
await acquire();
try {
return await operation();
} finally {
release();
}
}
}
+55 -15
View File
@@ -5,8 +5,8 @@ import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart'; import '../app_state.dart';
import '../dev_config.dart';
import 'http_client.dart'; import 'http_client.dart';
import 'service_policy.dart';
class RouteResult { class RouteResult {
final List<LatLng> waypoints; final List<LatLng> waypoints;
@@ -26,14 +26,27 @@ class RouteResult {
} }
class RoutingService { class RoutingService {
static const String _baseUrl = 'https://alprwatch.org/api/v1/deflock/directions'; static const String defaultUrl = 'https://api.dontgetflocked.com/api/v1/deflock/directions';
final http.Client _client; static const String fallbackUrl = 'https://alprwatch.org/api/v1/deflock/directions';
static const _policy = ResiliencePolicy(
maxRetries: 1,
httpTimeout: Duration(seconds: 30),
);
RoutingService({http.Client? client}) : _client = client ?? UserAgentClient(); final http.Client _client;
/// Optional override URL. When null, uses [defaultUrl].
final String? _baseUrlOverride;
RoutingService({http.Client? client, String? baseUrl})
: _client = client ?? UserAgentClient(),
_baseUrlOverride = baseUrl;
void close() => _client.close(); void close() => _client.close();
// Calculate route between two points using alprwatch /// Resolve the primary URL to use: constructor override or default.
String get _primaryUrl => _baseUrlOverride ?? defaultUrl;
// Calculate route between two points
Future<RouteResult> calculateRoute({ Future<RouteResult> calculateRoute({
required LatLng start, required LatLng start,
required LatLng end, required LatLng end,
@@ -54,7 +67,6 @@ class RoutingService {
}; };
}).toList(); }).toList();
final uri = Uri.parse(_baseUrl);
final params = { final params = {
'start': { 'start': {
'longitude': start.longitude, 'longitude': start.longitude,
@@ -66,10 +78,24 @@ class RoutingService {
}, },
'avoidance_distance': avoidanceDistance, 'avoidance_distance': avoidanceDistance,
'enabled_profiles': enabledProfiles, 'enabled_profiles': enabledProfiles,
'show_exclusion_zone': false, // for debugging: if true, returns a GeoJSON Feature MultiPolygon showing what areas are avoided in calculating the route 'show_exclusion_zone': false,
}; };
debugPrint('[RoutingService] alprwatch request: $uri $params'); final primaryUrl = _primaryUrl;
final canFallback = _baseUrlOverride == null;
return executeWithFallback<RouteResult>(
primaryUrl: primaryUrl,
fallbackUrl: canFallback ? fallbackUrl : null,
execute: (url) => _postRoute(url, params),
classifyError: _classifyError,
policy: _policy,
);
}
Future<RouteResult> _postRoute(String url, Map<String, dynamic> params) async {
final uri = Uri.parse(url);
debugPrint('[RoutingService] POST $uri');
try { try {
final response = await _client.post( final response = await _client.post(
@@ -78,7 +104,7 @@ class RoutingService {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: json.encode(params) body: json.encode(params)
).timeout(kNavigationRoutingTimeout); ).timeout(_policy.httpTimeout);
if (response.statusCode != 200) { if (response.statusCode != 200) {
if (kDebugMode) { if (kDebugMode) {
@@ -91,22 +117,23 @@ class RoutingService {
: body; : body;
debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated'); debugPrint('[RoutingService] Error response body ($maxLen char max): $truncated');
} }
throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}',
statusCode: response.statusCode);
} }
final data = json.decode(response.body) as Map<String, dynamic>; final data = json.decode(response.body) as Map<String, dynamic>;
debugPrint('[RoutingService] alprwatch response data: $data'); debugPrint('[RoutingService] response data: $data');
// Check alprwatch response status // Check response status
final ok = data['ok'] as bool? ?? false; final ok = data['ok'] as bool? ?? false;
if ( ! ok ) { if ( ! ok ) {
final message = data['error'] as String? ?? 'Unknown routing error'; final message = data['error'] as String? ?? 'Unknown routing error';
throw RoutingException('alprwatch error: $message'); throw RoutingException('API error: $message', isApiError: true);
} }
final route = data['result']['route'] as Map<String, dynamic>?; final route = data['result']['route'] as Map<String, dynamic>?;
if (route == null) { if (route == null) {
throw RoutingException('No route found between these points'); throw RoutingException('No route found between these points', isApiError: true);
} }
final waypoints = (route['coordinates'] as List<dynamic>?) final waypoints = (route['coordinates'] as List<dynamic>?)
@@ -138,12 +165,25 @@ class RoutingService {
} }
} }
} }
static ErrorDisposition _classifyError(Object error) {
if (error is! RoutingException) return ErrorDisposition.retry;
if (error.isApiError) return ErrorDisposition.abort;
final status = error.statusCode;
if (status != null && status >= 400 && status < 500) {
if (status == 429) return ErrorDisposition.fallback;
return ErrorDisposition.abort;
}
return ErrorDisposition.retry;
}
} }
class RoutingException implements Exception { class RoutingException implements Exception {
final String message; final String message;
final int? statusCode;
final bool isApiError;
const RoutingException(this.message); const RoutingException(this.message, {this.statusCode, this.isApiError = false});
@override @override
String toString() => 'RoutingException: $message'; String toString() => 'RoutingException: $message';
+94 -47
View File
@@ -152,11 +152,10 @@ class ServicePolicy {
'attributionUrl: $attributionUrl)'; 'attributionUrl: $attributionUrl)';
} }
/// Resolves URLs and tile providers to their applicable [ServicePolicy]. /// Resolves service URLs to their applicable [ServicePolicy].
/// ///
/// Built-in patterns cover all OSMF official services and common third-party /// Built-in patterns cover all OSMF official services and common third-party
/// tile providers. Custom overrides can be registered for self-hosted endpoints /// tile providers. Falls back to permissive defaults for unrecognized hosts.
/// via [registerCustomPolicy].
class ServicePolicyResolver { class ServicePolicyResolver {
/// Host → ServiceType mapping for known services. /// Host → ServiceType mapping for known services.
static final Map<String, ServiceType> _hostPatterns = { static final Map<String, ServiceType> _hostPatterns = {
@@ -166,6 +165,7 @@ class ServicePolicyResolver {
'tile.openstreetmap.org': ServiceType.osmTileServer, 'tile.openstreetmap.org': ServiceType.osmTileServer,
'nominatim.openstreetmap.org': ServiceType.nominatim, 'nominatim.openstreetmap.org': ServiceType.nominatim,
'overpass-api.de': ServiceType.overpass, 'overpass-api.de': ServiceType.overpass,
'overpass.deflock.org': ServiceType.overpass,
'taginfo.openstreetmap.org': ServiceType.tagInfo, 'taginfo.openstreetmap.org': ServiceType.tagInfo,
'tiles.virtualearth.net': ServiceType.bingTiles, 'tiles.virtualearth.net': ServiceType.bingTiles,
'api.mapbox.com': ServiceType.mapboxTiles, 'api.mapbox.com': ServiceType.mapboxTiles,
@@ -183,25 +183,14 @@ class ServicePolicyResolver {
ServiceType.custom: const ServicePolicy(), 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]. /// Resolve a URL to its applicable [ServicePolicy].
/// ///
/// Checks custom overrides first, then built-in host patterns. Falls back /// Checks built-in host patterns. Falls back to [ServicePolicy.custom]
/// to [ServicePolicy.custom] for unrecognized hosts. /// for unrecognized hosts.
static ServicePolicy resolve(String url) { static ServicePolicy resolve(String url) {
final host = _extractHost(url); final host = _extractHost(url);
if (host == null) return const ServicePolicy(); 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) { for (final entry in _hostPatterns.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) { if (host == entry.key || host.endsWith('.${entry.key}')) {
return _policies[entry.value] ?? const ServicePolicy(); return _policies[entry.value] ?? const ServicePolicy();
@@ -218,14 +207,6 @@ class ServicePolicyResolver {
final host = _extractHost(url); final host = _extractHost(url);
if (host == null) return ServiceType.custom; 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) { for (final entry in _hostPatterns.entries) {
if (host == entry.key || host.endsWith('.${entry.key}')) { if (host == entry.key || host.endsWith('.${entry.key}')) {
return entry.value; return entry.value;
@@ -239,29 +220,6 @@ class ServicePolicyResolver {
static ServicePolicy resolveByType(ServiceType type) => static ServicePolicy resolveByType(ServiceType type) =>
_policies[type] ?? const ServicePolicy(); _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. /// Extract the host from a URL or URL template.
static String? _extractHost(String url) { static String? _extractHost(String url) {
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' // Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
@@ -283,6 +241,95 @@ class ServicePolicyResolver {
} }
} }
/// How the retry/fallback engine should handle an error.
enum ErrorDisposition {
/// Stop immediately. Don't retry, don't try fallback. (400, business logic)
abort,
/// Don't retry same server, but DO try fallback endpoint. (429 rate limit)
fallback,
/// Retry with backoff against same server, then fallback if exhausted. (5xx, network)
retry,
}
/// Retry and fallback configuration for resilient HTTP services.
class ResiliencePolicy {
final int maxRetries;
final Duration httpTimeout;
final Duration _retryBackoffBase;
final int _retryBackoffMaxMs;
const ResiliencePolicy({
this.maxRetries = 1,
this.httpTimeout = const Duration(seconds: 30),
Duration retryBackoffBase = const Duration(milliseconds: 200),
int retryBackoffMaxMs = 5000,
}) : _retryBackoffBase = retryBackoffBase,
_retryBackoffMaxMs = retryBackoffMaxMs;
Duration retryDelay(int attempt) {
final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt))
.clamp(0, _retryBackoffMaxMs);
return Duration(milliseconds: ms);
}
}
/// Execute a request with retry and fallback logic.
///
/// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times.
/// 2. On each failure, calls [classifyError] to determine disposition:
/// - [ErrorDisposition.abort]: rethrows immediately
/// - [ErrorDisposition.fallback]: skips retries, tries fallback (if available)
/// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted
/// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error,
/// repeats the retry loop against the fallback.
Future<T> executeWithFallback<T>({
required String primaryUrl,
required String? fallbackUrl,
required Future<T> Function(String url) execute,
required ErrorDisposition Function(Object error) classifyError,
ResiliencePolicy policy = const ResiliencePolicy(),
}) async {
try {
return await _executeWithRetries(primaryUrl, execute, classifyError, policy);
} catch (e) {
// _executeWithRetries rethrows abort/fallback/exhausted-retry errors.
// Re-classify only to distinguish abort (which must not fall back) from
// fallback/retry-exhausted (which should). This is the one intentional
// re-classification — _executeWithRetries cannot short-circuit past the
// outer try/catch.
if (classifyError(e) == ErrorDisposition.abort) rethrow;
if (fallbackUrl == null) rethrow;
debugPrint('[Resilience] Primary failed ($e), trying fallback');
return _executeWithRetries(fallbackUrl, execute, classifyError, policy);
}
}
Future<T> _executeWithRetries<T>(
String url,
Future<T> Function(String url) execute,
ErrorDisposition Function(Object error) classifyError,
ResiliencePolicy policy,
) async {
for (int attempt = 0; attempt <= policy.maxRetries; attempt++) {
try {
return await execute(url);
} catch (e) {
final disposition = classifyError(e);
if (disposition == ErrorDisposition.abort) rethrow;
if (disposition == ErrorDisposition.fallback) rethrow; // caller handles fallback
// disposition == retry
if (attempt < policy.maxRetries) {
final delay = policy.retryDelay(attempt);
debugPrint('[Resilience] Attempt ${attempt + 1} failed, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
continue;
}
rethrow; // retries exhausted, let caller try fallback
}
}
throw StateError('Unreachable'); // loop always returns or throws
}
/// Reusable per-service rate limiter and concurrency controller. /// Reusable per-service rate limiter and concurrency controller.
/// ///
/// Enforces the rate limits and concurrency constraints defined in each /// Enforces the rate limits and concurrency constraints defined in each
+3 -3
View File
@@ -250,11 +250,11 @@ class NavigationState extends ChangeNotifier {
_calculateRoute(); _calculateRoute();
} }
/// Calculate route using alprwatch /// Calculate route via RoutingService (primary + fallback endpoints).
void _calculateRoute() { void _calculateRoute() {
if (_routeStart == null || _routeEnd == null) return; if (_routeStart == null || _routeEnd == null) return;
debugPrint('[NavigationState] Calculating route with alprwatch...'); debugPrint('[NavigationState] Calculating route...');
_isCalculating = true; _isCalculating = true;
_routingError = null; _routingError = null;
notifyListeners(); notifyListeners();
@@ -271,7 +271,7 @@ class NavigationState extends ChangeNotifier {
_showingOverview = true; _showingOverview = true;
_provisionalPinLocation = null; // Hide provisional pin _provisionalPinLocation = null; // Hide provisional pin
debugPrint('[NavigationState] alprwatch route calculated: ${routeResult.toString()}'); debugPrint('[NavigationState] Route calculated: ${routeResult.toString()}');
notifyListeners(); notifyListeners();
}).catchError((error) { }).catchError((error) {
+12
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import '../models/tile_provider.dart'; import '../models/tile_provider.dart';
import '../services/provider_tile_cache_manager.dart';
import '../dev_config.dart'; import '../dev_config.dart';
import '../keys.dart'; import '../keys.dart';
@@ -332,6 +333,17 @@ class SettingsState extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Clear all tile caches for a specific provider
Future<void> clearTileProviderCaches(String providerId) async {
final provider = _tileProviders.firstWhereOrNull((p) => p.id == providerId);
if (provider == null) return;
// Clear cache for each tile type in this provider
for (final tileType in provider.tileTypes) {
await ProviderTileCacheManager.deleteCache(providerId, tileType.id);
}
}
/// Set follow-me mode /// Set follow-me mode
Future<void> setFollowMeMode(FollowMeMode mode) async { Future<void> setFollowMeMode(FollowMeMode mode) async {
if (_followMeMode != mode) { if (_followMeMode != mode) {
+2 -2
View File
@@ -253,12 +253,12 @@ class UploadQueueState extends ChangeNotifier {
} }
// Add a node deletion to the upload queue // Add a node deletion to the upload queue
void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode}) { void addFromNodeDeletion(OsmNode node, {required UploadMode uploadMode, String? changesetComment}) {
final upload = PendingUpload( final upload = PendingUpload(
coord: node.coord, coord: node.coord,
direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API direction: node.directionDeg.isNotEmpty ? node.directionDeg.first : 0, // Direction not used for deletions but required for API
profile: null, // No profile needed for deletions - just delete by node ID profile: null, // No profile needed for deletions - just delete by node ID
changesetComment: 'Delete a surveillance node', // Default comment for deletions changesetComment: changesetComment ?? 'Delete a surveillance node', // Use provided comment or default
uploadMode: uploadMode, uploadMode: uploadMode,
operation: UploadOperation.delete, operation: UploadOperation.delete,
originalNodeId: node.id, originalNodeId: node.id,
+82 -19
View File
@@ -65,30 +65,17 @@ class NodeTagSheet extends StatelessWidget {
} }
void deleteNode() async { void deleteNode() async {
final shouldDelete = await showDialog<bool>( final result = await showDialog<({bool confirmed, String comment})>(
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) => _DeleteNodeDialog(
return AlertDialog( nodeId: node.id.toString(),
title: Text(locService.t('node.confirmDeleteTitle')), locService: locService,
content: Text(locService.t('node.confirmDeleteMessage', params: [node.id.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(locService.cancel),
), ),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(locService.t('actions.delete')),
),
],
);
},
); );
if (shouldDelete == true && context.mounted) { if ((result?.confirmed ?? false) && context.mounted) {
Navigator.pop(context); // Close this sheet first Navigator.pop(context); // Close this sheet first
appState.deleteNode(node); appState.deleteNode(node, changesetComment: result!.comment);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))), SnackBar(content: Text(locService.t('node.deleteQueuedForUpload'))),
); );
@@ -261,3 +248,79 @@ class NodeTagSheet extends StatelessWidget {
); );
} }
} }
class _DeleteNodeDialog extends StatefulWidget {
final String nodeId;
final LocalizationService locService;
const _DeleteNodeDialog({
required this.nodeId,
required this.locService,
});
@override
State<_DeleteNodeDialog> createState() => _DeleteNodeDialogState();
}
class _DeleteNodeDialogState extends State<_DeleteNodeDialog> {
late final TextEditingController _commentController;
@override
void initState() {
super.initState();
_commentController = TextEditingController();
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.locService.t('node.confirmDeleteTitle')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.locService.t('node.confirmDeleteMessage', params: [widget.nodeId])),
const SizedBox(height: 16),
Text(
widget.locService.t('node.deleteReasonLabel'),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
TextField(
controller: _commentController,
decoration: InputDecoration(
hintText: widget.locService.t('node.deleteReasonHint'),
border: const OutlineInputBorder(),
isDense: true,
),
maxLines: 2,
textCapitalization: TextCapitalization.sentences,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop((confirmed: false, comment: '')),
child: Text(widget.locService.cancel),
),
TextButton(
onPressed: () {
final comment = _commentController.text.trim();
final finalComment = comment.isEmpty
? 'Delete a surveillance node'
: 'Delete a surveillance node: $comment';
Navigator.of(context).pop((confirmed: true, comment: finalComment));
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(widget.locService.t('actions.delete')),
),
],
);
}
}
+8 -8
View File
@@ -77,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -564,18 +564,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -929,10 +929,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: deflockapp name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none" publish_to: "none"
version: 2.9.1+52 # The thing after the + is the version code, incremented with each release version: 2.9.2+53 # The thing after the + is the version code, incremented with each release
environment: environment:
sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+)
+188 -12
View File
@@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:deflockapp/models/node_profile.dart'; import 'package:deflockapp/models/node_profile.dart';
import 'package:deflockapp/services/overpass_service.dart'; import 'package:deflockapp/services/overpass_service.dart';
import 'package:deflockapp/services/service_policy.dart';
class MockHttpClient extends Mock implements http.Client {} class MockHttpClient extends Mock implements http.Client {}
@@ -36,6 +37,7 @@ void main() {
setUp(() { setUp(() {
mockClient = MockHttpClient(); mockClient = MockHttpClient();
// Initialize OverpassService with a mock HTTP client for testing
service = OverpassService(client: mockClient); service = OverpassService(client: mockClient);
}); });
@@ -246,9 +248,9 @@ void main() {
stubErrorResponse( stubErrorResponse(
400, 'Error: too many nodes (limit is 50000) in query'); 400, 'Error: too many nodes (limit is 50000) in query');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()), throwsA(isA<NodeLimitError>()),
); );
}); });
@@ -256,9 +258,9 @@ void main() {
test('response with "timeout" throws NodeLimitError', () async { test('response with "timeout" throws NodeLimitError', () async {
stubErrorResponse(400, 'runtime error: timeout in query execution'); stubErrorResponse(400, 'runtime error: timeout in query execution');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()), throwsA(isA<NodeLimitError>()),
); );
}); });
@@ -267,9 +269,9 @@ void main() {
() async { () async {
stubErrorResponse(400, 'runtime limit exceeded'); stubErrorResponse(400, 'runtime limit exceeded');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()), throwsA(isA<NodeLimitError>()),
); );
}); });
@@ -277,9 +279,9 @@ void main() {
test('HTTP 429 throws RateLimitError', () async { test('HTTP 429 throws RateLimitError', () async {
stubErrorResponse(429, 'Too Many Requests'); stubErrorResponse(429, 'Too Many Requests');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<RateLimitError>()), throwsA(isA<RateLimitError>()),
); );
}); });
@@ -287,9 +289,9 @@ void main() {
test('response with "rate limited" throws RateLimitError', () async { test('response with "rate limited" throws RateLimitError', () async {
stubErrorResponse(503, 'You are rate limited'); stubErrorResponse(503, 'You are rate limited');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<RateLimitError>()), throwsA(isA<RateLimitError>()),
); );
}); });
@@ -298,9 +300,9 @@ void main() {
() async { () async {
stubErrorResponse(500, 'Internal Server Error'); stubErrorResponse(500, 'Internal Server Error');
expect( await expectLater(
() => service.fetchNodes( () => service.fetchNodes(
bounds: bounds, profiles: profiles, maxRetries: 0), bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()), throwsA(isA<NetworkError>()),
); );
}); });
@@ -313,4 +315,178 @@ void main() {
verifyNever(() => mockClient.post(any(), body: any(named: 'body'))); verifyNever(() => mockClient.post(any(), body: any(named: 'body')));
}); });
}); });
group('fallback behavior', () {
test('falls back to overpass-api.de on NetworkError after retries', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Internal Server Error', 500);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0));
expect(nodes, hasLength(1));
// primary (1 attempt, 0 retries) + fallback (1 attempt) = 2
expect(callCount, equals(2));
});
test('does NOT fallback on NodeLimitError', () async {
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async => http.Response(
'Error: too many nodes (limit is 50000) in query',
400,
));
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NodeLimitError>()),
);
// Only one call — no fallback (abort disposition)
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(1);
});
test('RateLimitError triggers fallback without retrying primary', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Too Many Requests', 429);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
expect(nodes, hasLength(1));
// 1 primary (no retry on fallback disposition) + 1 fallback = 2
expect(callCount, equals(2));
});
test('primary fails then fallback also fails -> error propagated', () async {
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async =>
http.Response('Internal Server Error', 500));
await expectLater(
() => service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
// primary + fallback
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(2);
});
test('does NOT fallback when using custom endpoint', () async {
final customService = OverpassService(
client: mockClient,
endpoint: 'https://custom.example.com/api/interpreter',
);
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((_) async =>
http.Response('Internal Server Error', 500));
await expectLater(
() => customService.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)),
throwsA(isA<NetworkError>()),
);
// Only one call - no fallback with custom endpoint
verify(() => mockClient.post(any(), body: any(named: 'body')))
.called(1);
});
test('retries exhaust before fallback kicks in', () async {
int callCount = 0;
when(() => mockClient.post(any(), body: any(named: 'body')))
.thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'overpass.deflock.org') {
return http.Response('Server Error', 500);
}
// Fallback succeeds
return http.Response(
jsonEncode({
'elements': [
{
'type': 'node',
'id': 1,
'lat': 38.9,
'lon': -77.0,
'tags': {'man_made': 'surveillance'},
},
]
}),
200,
);
});
final nodes = await service.fetchNodes(
bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2));
expect(nodes, hasLength(1));
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
expect(callCount, equals(4));
});
});
group('default endpoints', () {
test('default endpoint is overpass.deflock.org', () {
expect(OverpassService.defaultEndpoint,
equals('https://overpass.deflock.org/api/interpreter'));
});
test('fallback endpoint is overpass-api.de', () {
expect(OverpassService.fallbackEndpoint,
equals('https://overpass-api.de/api/interpreter'));
});
});
} }
+324 -27
View File
@@ -41,6 +41,30 @@ void main() {
AppState.instance = MockAppState(); AppState.instance = MockAppState();
}); });
/// Helper: stub a successful routing response
void stubSuccessResponse() {
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
));
}
group('RoutingService', () { group('RoutingService', () {
test('empty tags are filtered from request body', () async { test('empty tags are filtered from request body', () async {
// Profile with empty tag values (like builtin-flock has camera:mount: '') // Profile with empty tag values (like builtin-flock has camera:mount: '')
@@ -57,29 +81,7 @@ void main() {
]; ];
when(() => mockAppState.enabledProfiles).thenReturn(profiles); when(() => mockAppState.enabledProfiles).thenReturn(profiles);
// Capture the request body stubSuccessResponse();
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 1000.0,
'duration': 600.0,
},
},
}),
200,
);
});
await service.calculateRoute(start: start, end: end); await service.calculateRoute(start: start, end: end);
@@ -147,7 +149,7 @@ void main() {
reasonPhrase: 'Bad Request', reasonPhrase: 'Bad Request',
)); ));
expect( await expectLater(
() => service.calculateRoute(start: start, end: end), () => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having( throwsA(isA<RoutingException>().having(
(e) => e.message, (e) => e.message,
@@ -166,7 +168,7 @@ void main() {
body: any(named: 'body'), body: any(named: 'body'),
)).thenThrow(http.ClientException('Connection refused')); )).thenThrow(http.ClientException('Connection refused'));
expect( await expectLater(
() => service.calculateRoute(start: start, end: end), () => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having( throwsA(isA<RoutingException>().having(
(e) => e.message, (e) => e.message,
@@ -176,7 +178,7 @@ void main() {
); );
}); });
test('API-level error surfaces alprwatch message', () async { test('API-level error surfaces message', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]); when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post( when(() => mockClient.post(
@@ -191,7 +193,7 @@ void main() {
200, 200,
)); ));
expect( await expectLater(
() => service.calculateRoute(start: start, end: end), () => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having( throwsA(isA<RoutingException>().having(
(e) => e.message, (e) => e.message,
@@ -201,4 +203,299 @@ void main() {
); );
}); });
}); });
group('fallback behavior', () {
test('falls back to secondary on server error (500) after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Internal Server Error', 500,
reasonPhrase: 'Internal Server Error');
}
// Fallback succeeds
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [
[-77.0, 38.9],
[-77.1, 39.0],
],
'distance': 5000.0,
'duration': 300.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(5000.0));
// 2 primary attempts (1 + 1 retry) + 1 fallback = 3
expect(callCount, equals(3));
});
test('falls back on 502 (GraphHopper unavailable) after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Bad Gateway', 502, reasonPhrase: 'Bad Gateway');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 100.0,
'duration': 60.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(100.0));
// 2 primary attempts + 1 fallback = 3
expect(callCount, equals(3));
});
test('falls back on network error after retries', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
throw http.ClientException('Connection refused');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 100.0,
'duration': 60.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(100.0));
// 2 primary attempts + 1 fallback = 3
expect(callCount, equals(3));
});
test('429 triggers fallback without retrying primary', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
int callCount = 0;
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((invocation) async {
callCount++;
final uri = invocation.positionalArguments[0] as Uri;
if (uri.host == 'api.dontgetflocked.com') {
return http.Response('Too Many Requests', 429,
reasonPhrase: 'Too Many Requests');
}
return http.Response(
json.encode({
'ok': true,
'result': {
'route': {
'coordinates': [[-77.0, 38.9]],
'distance': 200.0,
'duration': 120.0,
},
},
}),
200,
);
});
final result = await service.calculateRoute(start: start, end: end);
expect(result.distanceMeters, equals(200.0));
// 1 primary (no retry on 429/fallback disposition) + 1 fallback = 2
expect(callCount, equals(2));
});
test('does NOT fallback on 400 (validation error)', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Bad Request: missing start', 400,
reasonPhrase: 'Bad Request'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 400)),
);
// Only one call — no retry, no fallback (abort disposition)
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('does NOT fallback on 403 (all 4xx except 429 abort)', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Forbidden', 403,
reasonPhrase: 'Forbidden'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 403)),
);
// Only one call — no retry, no fallback (abort disposition)
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('does NOT fallback on API-level business logic errors', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
json.encode({
'ok': false,
'error': 'No route found',
}),
200,
));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.isApiError, 'isApiError', true)),
);
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(1);
});
test('primary fails then fallback also fails -> error propagated', () async {
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Internal Server Error', 500,
reasonPhrase: 'Internal Server Error'));
await expectLater(
() => service.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>().having(
(e) => e.statusCode, 'statusCode', 500)),
);
// 2 primary attempts + 2 fallback attempts = 4
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(4);
});
test('does NOT fallback when using custom baseUrl', () async {
final customService = RoutingService(
client: mockClient,
baseUrl: 'https://custom.example.com/route',
);
when(() => mockAppState.enabledProfiles).thenReturn([]);
when(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).thenAnswer((_) async => http.Response(
'Service Unavailable', 503,
reasonPhrase: 'Service Unavailable'));
await expectLater(
() => customService.calculateRoute(start: start, end: end),
throwsA(isA<RoutingException>()),
);
// 2 attempts (1 + 1 retry), no fallback with custom URL
verify(() => mockClient.post(
any(),
headers: any(named: 'headers'),
body: any(named: 'body'),
)).called(2);
});
});
group('RoutingException', () {
test('statusCode is preserved', () {
const e = RoutingException('test', statusCode: 502);
expect(e.statusCode, 502);
expect(e.isApiError, false);
});
test('isApiError flag works', () {
const e = RoutingException('test', isApiError: true);
expect(e.isApiError, true);
expect(e.statusCode, isNull);
});
});
} }
+193 -61
View File
@@ -5,10 +5,6 @@ import 'package:deflockapp/services/service_policy.dart';
void main() { void main() {
group('ServicePolicyResolver', () { group('ServicePolicyResolver', () {
setUp(() {
ServicePolicyResolver.clearCustomPolicies();
});
group('resolveType', () { group('resolveType', () {
test('resolves OSM editing API from production URL', () { test('resolves OSM editing API from production URL', () {
expect( expect(
@@ -200,63 +196,6 @@ void main() {
}); });
}); });
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', () { group('ServiceRateLimiter', () {
@@ -423,4 +362,197 @@ void main() {
expect(policy.attributionUrl, 'https://example.com/license'); expect(policy.attributionUrl, 'https://example.com/license');
}); });
}); });
group('ResiliencePolicy', () {
test('retryDelay uses exponential backoff', () {
const policy = ResiliencePolicy(
retryBackoffBase: Duration(milliseconds: 100),
retryBackoffMaxMs: 2000,
);
expect(policy.retryDelay(0), const Duration(milliseconds: 100));
expect(policy.retryDelay(1), const Duration(milliseconds: 200));
expect(policy.retryDelay(2), const Duration(milliseconds: 400));
});
test('retryDelay clamps to max', () {
const policy = ResiliencePolicy(
retryBackoffBase: Duration(milliseconds: 1000),
retryBackoffMaxMs: 3000,
);
expect(policy.retryDelay(0), const Duration(milliseconds: 1000));
expect(policy.retryDelay(1), const Duration(milliseconds: 2000));
expect(policy.retryDelay(2), const Duration(milliseconds: 3000)); // clamped
expect(policy.retryDelay(10), const Duration(milliseconds: 3000)); // clamped
});
});
group('executeWithFallback', () {
const policy = ResiliencePolicy(
maxRetries: 2,
retryBackoffBase: Duration.zero, // no delay in tests
);
test('abort error stops immediately, no fallback', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
throw Exception('bad request');
},
classifyError: (_) => ErrorDisposition.abort,
policy: policy,
),
throwsA(isA<Exception>()),
);
expect(callCount, 1); // no retries, no fallback
});
test('fallback error skips retries, goes to fallback', () async {
final urlsSeen = <String>[];
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
urlsSeen.add(url);
if (url.contains('primary')) {
throw Exception('rate limited');
}
return Future.value('ok from fallback');
},
classifyError: (_) => ErrorDisposition.fallback,
policy: policy,
);
expect(result, 'ok from fallback');
// 1 primary (no retries) + 1 fallback = 2
expect(urlsSeen, ['https://primary.example.com', 'https://fallback.example.com']);
});
test('retry error retries N times then falls back', () async {
final urlsSeen = <String>[];
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
urlsSeen.add(url);
if (url.contains('primary')) {
throw Exception('server error');
}
return Future.value('ok from fallback');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'ok from fallback');
// 3 primary attempts (1 + 2 retries) + 1 fallback = 4
expect(urlsSeen.where((u) => u.contains('primary')).length, 3);
expect(urlsSeen.where((u) => u.contains('fallback')).length, 1);
});
test('no fallback URL rethrows after retries', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: null,
execute: (url) {
callCount++;
throw Exception('server error');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
),
throwsA(isA<Exception>()),
);
// 3 attempts (1 + 2 retries), then rethrow
expect(callCount, 3);
});
test('fallback disposition with no fallback URL rethrows immediately', () async {
int callCount = 0;
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: null,
execute: (url) {
callCount++;
throw Exception('rate limited');
},
classifyError: (_) => ErrorDisposition.fallback,
policy: policy,
),
throwsA(isA<Exception>()),
);
// Only 1 attempt — fallback disposition skips retries, and no fallback URL
expect(callCount, 1);
});
test('both fail propagates last error', () async {
await expectLater(
() => executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
if (url.contains('fallback')) {
throw Exception('fallback also failed');
}
throw Exception('primary failed');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
),
throwsA(isA<Exception>().having(
(e) => e.toString(), 'message', contains('fallback also failed'))),
);
});
test('success on first try returns immediately', () async {
int callCount = 0;
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
return Future.value('success');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'success');
expect(callCount, 1);
});
test('success after retry does not try fallback', () async {
int callCount = 0;
final result = await executeWithFallback<String>(
primaryUrl: 'https://primary.example.com',
fallbackUrl: 'https://fallback.example.com',
execute: (url) {
callCount++;
if (callCount == 1) throw Exception('transient');
return Future.value('recovered');
},
classifyError: (_) => ErrorDisposition.retry,
policy: policy,
);
expect(result, 'recovered');
expect(callCount, 2); // 1 fail + 1 success, no fallback
});
});
} }
+8 -8
View File
@@ -36,7 +36,7 @@ void main() {
]; ];
final result = dataManager.getNodesForRendering( final result = dataManager.getNodesForRendering(
currentZoom: 14.0, currentZoom: 14,
mapBounds: bounds, mapBounds: bounds,
uploadMode: UploadMode.production, uploadMode: UploadMode.production,
maxNodes: 3, maxNodes: 3,
@@ -58,7 +58,7 @@ void main() {
]; ];
final result = dataManager.getNodesForRendering( final result = dataManager.getNodesForRendering(
currentZoom: 14.0, currentZoom: 14,
mapBounds: bounds, mapBounds: bounds,
uploadMode: UploadMode.production, uploadMode: UploadMode.production,
maxNodes: 10, maxNodes: 10,
@@ -73,7 +73,7 @@ void main() {
testNodes = [nodeAt(1, 38.5, -77.5)]; testNodes = [nodeAt(1, 38.5, -77.5)];
final result = dataManager.getNodesForRendering( final result = dataManager.getNodesForRendering(
currentZoom: 5.0, currentZoom: 5,
mapBounds: bounds, mapBounds: bounds,
uploadMode: UploadMode.production, uploadMode: UploadMode.production,
maxNodes: 10, maxNodes: 10,
@@ -93,7 +93,7 @@ void main() {
testNodes = List.from(nodes); testNodes = List.from(nodes);
final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5)); final swBounds = LatLngBounds(LatLng(37.5, -78.5), LatLng(38.5, -77.5));
final swResult = dataManager.getNodesForRendering( final swResult = dataManager.getNodesForRendering(
currentZoom: 14.0, currentZoom: 14,
mapBounds: swBounds, mapBounds: swBounds,
uploadMode: UploadMode.production, uploadMode: UploadMode.production,
maxNodes: 1, maxNodes: 1,
@@ -105,7 +105,7 @@ void main() {
testNodes = List.from(nodes); testNodes = List.from(nodes);
final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5)); final neBounds = LatLngBounds(LatLng(38.5, -77.5), LatLng(39.5, -76.5));
final neResult = dataManager.getNodesForRendering( final neResult = dataManager.getNodesForRendering(
currentZoom: 14.0, currentZoom: 14,
mapBounds: neBounds, mapBounds: neBounds,
uploadMode: UploadMode.production, uploadMode: UploadMode.production,
maxNodes: 1, maxNodes: 1,
@@ -126,13 +126,13 @@ void main() {
testNodes = makeNodes(); testNodes = makeNodes();
final result1 = dataManager.getNodesForRendering( final result1 = dataManager.getNodesForRendering(
currentZoom: 14.0, mapBounds: bounds, currentZoom: 14, mapBounds: bounds,
uploadMode: UploadMode.production, maxNodes: 3, uploadMode: UploadMode.production, maxNodes: 3,
); );
testNodes = makeNodes(); testNodes = makeNodes();
final result2 = dataManager.getNodesForRendering( final result2 = dataManager.getNodesForRendering(
currentZoom: 14.0, mapBounds: bounds, currentZoom: 14, mapBounds: bounds,
uploadMode: UploadMode.production, maxNodes: 3, uploadMode: UploadMode.production, maxNodes: 3,
); );
@@ -151,7 +151,7 @@ void main() {
]; ];
final result = dataManager.getNodesForRendering( final result = dataManager.getNodesForRendering(
currentZoom: 14.0, currentZoom: 14,
mapBounds: bounds, mapBounds: bounds,
uploadMode: UploadMode.production, uploadMode: UploadMode.production,
maxNodes: 10, maxNodes: 10,