diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 00000000..3ca2474e --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,44 @@ +# Manual-trigger workflow to run Gatling load tests against overpass.deflock.org. +# +# Trigger from the Actions tab → "Load Test" → "Run workflow". +# The HTML report is uploaded as a downloadable artifact (retained 30 days). +# +# Only one load test can run at a time (concurrency group prevents overlapping +# runs from skewing results or overwhelming the Overpass instance). + +name: Load Test + +on: + workflow_dispatch: + +concurrency: + group: load-test + cancel-in-progress: true + +jobs: + load-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + # Caches Gradle wrapper and dependencies between runs + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Gatling load test + working-directory: load-tests + run: ./gradlew gatlingRun + + - name: Upload Gatling report + if: always() + uses: actions/upload-artifact@v4 + with: + name: gatling-report + path: load-tests/build/reports/gatling/ + retention-days: 30 diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 182466ff..093354b9 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -142,7 +142,7 @@ jobs: build-ios: name: Build iOS needs: get-version - runs-on: macos-latest + runs-on: macos-26 steps: - name: Checkout repository uses: actions/checkout@v5 @@ -290,7 +290,7 @@ jobs: upload-to-stores: name: Upload to App Stores needs: [get-version, build-android-aab, build-ios] - runs-on: macos-latest # Need macOS for iOS uploads + runs-on: macos-26 # Need macOS for iOS uploads if: needs.get-version.outputs.should_upload_to_stores == 'true' steps: - name: Download AAB artifact for Google Play diff --git a/.gitignore b/.gitignore index d03c8937..5417a608 100644 --- a/.gitignore +++ b/.gitignore @@ -73,12 +73,13 @@ fuchsia/build/ web/build/ # ─────────────────────────────── -# IDE / Editor Settings +# IDE / Editor / AI Tool Settings # ─────────────────────────────── .idea/ .idea/**/workspace.xml .idea/**/tasks.xml .vscode/ +.claude/settings.local.json # Swap files *.swp *.swo @@ -102,4 +103,4 @@ build_keys.conf linux/ macos/ web/ -windows/ \ No newline at end of file +windows/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 339fc1ca..a700f92a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,28 +17,27 @@ if (keystorePropertiesFile.exists()) { android { namespace = "me.deflock.deflockapp" - // Matches current stable Flutter (compileSdk 34 as of July 2025) compileSdk = 36 // NDK only needed if you build native plugins; keep your pinned version ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } } defaultConfig { // Application ID (package name) applicationId = "me.deflock.deflockapp" - // ──────────────────────────────────────────────────────────── - // oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23 - // ──────────────────────────────────────────────────────────── + // oauth2_client 4.x & flutter_web_auth_2 5.x require minSdk 23 minSdk = maxOf(flutter.minSdkVersion, 23) targetSdk = 36 @@ -76,6 +75,5 @@ flutter { } dependencies { - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") } - diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b4792..e4ef43fb 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10a..da20e6da 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,8 +18,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/assets/changelog.json b/assets/changelog.json index b8586da4..cddd8d0d 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,7 +1,23 @@ { + "2.9.0": { + "content": [ + "• Caching, tile retries, offline areas, now working properly. Map imagery should load correctly." + ] + }, + "2.8.1": { + "content": [ + "• Fixed bug where the \"existing tags\" profile would incorrectly add default FOV ranges during submission", + "• Added drag handles so profiles can be reordered to customize dropdown order when submitting" + ] + }, + "2.8.0": { + "content": [ + "• Update dependencies and build chain tools; no code changes" + ] + }, "2.7.2": { "content": [ - "• Now following OSM UserAgent guidelines." + "• Now following OSM UserAgent guidelines" ] }, "2.7.1": { diff --git a/do_builds.sh b/do_builds.sh index b3c5850d..94dbac67 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -28,7 +28,7 @@ read_from_file() { echo "$v" return 0 fi - done < "$file" + done < <(cat "$file"; echo) return 1 } diff --git a/lib/app_state.dart b/lib/app_state.dart index 0e8ae093..a208d638 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -407,6 +407,10 @@ class AppState extends ChangeNotifier { _profileState.addOrUpdateProfile(p); } + void reorderProfiles(int oldIndex, int newIndex) { + _profileState.reorderProfiles(oldIndex, newIndex); + } + void deleteProfile(NodeProfile p) { _profileState.deleteProfile(p); } diff --git a/lib/keys.dart b/lib/keys.dart index ed9a52d5..7c193815 100644 --- a/lib/keys.dart +++ b/lib/keys.dart @@ -1,16 +1,20 @@ // OpenStreetMap OAuth client IDs for this app. // These must be provided via --dart-define at build time. +/// Whether OSM OAuth secrets were provided at build time. +/// When false, the app should force simulate mode. +bool get kHasOsmSecrets { + const prod = String.fromEnvironment('OSM_PROD_CLIENTID'); + const sandbox = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); + return prod.isNotEmpty && sandbox.isNotEmpty; +} + String get kOsmProdClientId { const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID'); - if (fromBuild.isNotEmpty) return fromBuild; - - throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id'); + return fromBuild; } String get kOsmSandboxClientId { const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); - if (fromBuild.isNotEmpty) return fromBuild; - - throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id'); + return fromBuild; } \ No newline at end of file diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 47b41da1..cd9bdae6 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -144,7 +144,10 @@ "offlineModeWarning": "Downloads im Offline-Modus deaktiviert. Deaktivieren Sie den Offline-Modus, um neue Bereiche herunterzuladen.", "areaTooBigMessage": "Zoomen Sie auf mindestens Stufe {} heran, um Offline-Bereiche herunterzuladen. Downloads großer Gebiete können die App zum Absturz bringen.", "downloadStarted": "Download gestartet! Lade Kacheln und Knoten...", - "downloadFailed": "Download konnte nicht gestartet werden: {}" + "downloadFailed": "Download konnte nicht gestartet werden: {}", + "offlineNotPermitted": "Der {}-Server erlaubt keine Offline-Downloads. Wechseln Sie zu einem Kachelanbieter, der Offline-Nutzung unterstützt (z. B. Bing Maps, Mapbox oder ein selbst gehosteter Kachelserver).", + "currentTileProvider": "aktuelle Kachel", + "noTileProviderSelected": "Kein Kachelanbieter ausgewählt. Bitte wählen Sie einen Kartenstil, bevor Sie einen Offlinebereich herunterladen." }, "downloadStarted": { "title": "Download gestartet", @@ -292,13 +295,16 @@ "addProfileChoiceMessage": "Wie möchten Sie ein Profil hinzufügen?", "createCustomProfile": "Benutzerdefiniertes Profil Erstellen", "createCustomProfileDescription": "Erstellen Sie ein Profil von Grund auf mit Ihren eigenen Tags", - "importFromWebsite": "Von Webseite Importieren", + "importFromWebsite": "Von Webseite Importieren", "importFromWebsiteDescription": "Profile von deflock.me/identify durchsuchen und importieren" }, "mapTiles": { "title": "Karten-Kacheln", "manageProviders": "Anbieter Verwalten", - "attribution": "Karten-Zuschreibung" + "attribution": "Karten-Zuschreibung", + "mapAttribution": "Kartenquelle: {}", + "couldNotOpenLink": "Link konnte nicht geöffnet werden", + "openLicense": "Lizenz öffnen: {}" }, "profileEditor": { "viewProfile": "Profil Anzeigen", @@ -325,7 +331,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Neues Betreiber-Profil", - "editOperatorProfile": "Betreiber-Profil Bearbeiten", + "editOperatorProfile": "Betreiber-Profil Bearbeiten", "operatorName": "Betreiber-Name", "operatorNameHint": "z.B. Polizei Austin", "operatorNameRequired": "Betreiber-Name ist erforderlich", @@ -520,7 +526,7 @@ "updateFailed": "Aktualisierung der verdächtigen Standorte fehlgeschlagen", "neverFetched": "Nie abgerufen", "daysAgo": "vor {} Tagen", - "hoursAgo": "vor {} Stunden", + "hoursAgo": "vor {} Stunden", "minutesAgo": "vor {} Minuten", "justNow": "Gerade eben" }, @@ -528,7 +534,7 @@ "title": "Verdächtiger Standort #{}", "ticketNo": "Ticket-Nr.", "address": "Adresse", - "street": "Straße", + "street": "Straße", "city": "Stadt", "state": "Bundesland", "intersectingStreet": "Kreuzende Straße", @@ -552,4 +558,4 @@ "metricDescription": "Metrisch (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/en.json b/lib/localizations/en.json index fa62994e..7f7023b4 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -70,7 +70,7 @@ "submitAnyway": "Submit Anyway", "nodeType": { "alpr": "ALPR/ANPR Camera", - "publicCamera": "Public Surveillance Camera", + "publicCamera": "Public Surveillance Camera", "camera": "Surveillance Camera", "amenity": "{}", "device": "{} Device", @@ -181,7 +181,10 @@ "offlineModeWarning": "Downloads disabled while in offline mode. Disable offline mode to download new areas.", "areaTooBigMessage": "Zoom in to at least level {} to download offline areas. Large area downloads can cause the app to become unresponsive.", "downloadStarted": "Download started! Fetching tiles and nodes...", - "downloadFailed": "Failed to start download: {}" + "downloadFailed": "Failed to start download: {}", + "offlineNotPermitted": "The {} server does not permit offline downloads. Switch to a tile provider that allows offline use (e.g., Bing Maps, Mapbox, or a self-hosted tile server).", + "currentTileProvider": "current tile", + "noTileProviderSelected": "No tile provider is selected. Please select a map style before downloading an offline area." }, "downloadStarted": { "title": "Download Started", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "How would you like to add a profile?", "createCustomProfile": "Create Custom Profile", "createCustomProfileDescription": "Build a profile from scratch with your own tags", - "importFromWebsite": "Import from Website", + "importFromWebsite": "Import from Website", "importFromWebsiteDescription": "Browse and import profiles from deflock.me/identify" }, "mapTiles": { "title": "Map Tiles", "manageProviders": "Manage Providers", - "attribution": "Map Attribution" + "attribution": "Map Attribution", + "mapAttribution": "Map attribution: {}", + "couldNotOpenLink": "Could not open link", + "openLicense": "Open license: {}" }, "profileEditor": { "viewProfile": "View Profile", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "New Operator Profile", - "editOperatorProfile": "Edit Operator Profile", + "editOperatorProfile": "Edit Operator Profile", "operatorName": "Operator name", "operatorNameHint": "e.g., Austin Police Department", "operatorNameRequired": "Operator name is required", @@ -443,7 +449,7 @@ "mobileEditors": "Mobile Editors", "iDEditor": "iD Editor", "iDEditorSubtitle": "Full-featured web editor - always works", - "rapidEditor": "RapiD Editor", + "rapidEditor": "RapiD Editor", "rapidEditorSubtitle": "AI-assisted editing with Facebook data", "vespucci": "Vespucci", "vespucciSubtitle": "Advanced Android OSM editor", @@ -520,7 +526,7 @@ "updateFailed": "Failed to update suspected locations", "neverFetched": "Never fetched", "daysAgo": "{} days ago", - "hoursAgo": "{} hours ago", + "hoursAgo": "{} hours ago", "minutesAgo": "{} minutes ago", "justNow": "Just now" }, @@ -528,7 +534,7 @@ "title": "Suspected Location #{}", "ticketNo": "Ticket No", "address": "Address", - "street": "Street", + "street": "Street", "city": "City", "state": "State", "intersectingStreet": "Intersecting Street", @@ -552,4 +558,4 @@ "metricDescription": "Metric (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 8cfe386e..423ca6d8 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Descargas deshabilitadas en modo sin conexión. Deshabilite el modo sin conexión para descargar nuevas áreas.", "areaTooBigMessage": "Amplíe al menos al nivel {} para descargar áreas sin conexión. Las descargas de áreas grandes pueden hacer que la aplicación deje de responder.", "downloadStarted": "¡Descarga iniciada! Obteniendo mosaicos y nodos...", - "downloadFailed": "Error al iniciar la descarga: {}" + "downloadFailed": "Error al iniciar la descarga: {}", + "offlineNotPermitted": "El servidor {} no permite descargas sin conexión. Cambie a un proveedor de mosaicos que permita el uso sin conexión (p. ej., Bing Maps, Mapbox o un servidor de mosaicos propio).", + "currentTileProvider": "mosaico actual", + "noTileProviderSelected": "No hay proveedor de mosaicos seleccionado. Seleccione un estilo de mapa antes de descargar un área sin conexión." }, "downloadStarted": { "title": "Descarga Iniciada", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "¿Cómo desea añadir un perfil?", "createCustomProfile": "Crear Perfil Personalizado", "createCustomProfileDescription": "Crear un perfil desde cero con sus propias etiquetas", - "importFromWebsite": "Importar desde Sitio Web", + "importFromWebsite": "Importar desde Sitio Web", "importFromWebsiteDescription": "Explorar e importar perfiles desde deflock.me/identify" }, "mapTiles": { "title": "Tiles de Mapa", "manageProviders": "Gestionar Proveedores", - "attribution": "Atribución del Mapa" + "attribution": "Atribución del Mapa", + "mapAttribution": "Atribución del mapa: {}", + "couldNotOpenLink": "No se pudo abrir el enlace", + "openLicense": "Abrir licencia: {}" }, "profileEditor": { "viewProfile": "Ver Perfil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nuevo Perfil de Operador", - "editOperatorProfile": "Editar Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", "operatorName": "Nombre del operador", "operatorNameHint": "ej., Departamento de Policía de Austin", "operatorNameRequired": "El nombre del operador es requerido", @@ -520,7 +526,7 @@ "updateFailed": "Error al actualizar ubicaciones sospechosas", "neverFetched": "Nunca obtenido", "daysAgo": "hace {} días", - "hoursAgo": "hace {} horas", + "hoursAgo": "hace {} horas", "minutesAgo": "hace {} minutos", "justNow": "Ahora mismo" }, @@ -528,7 +534,7 @@ "title": "Ubicación Sospechosa #{}", "ticketNo": "No. de Ticket", "address": "Dirección", - "street": "Calle", + "street": "Calle", "city": "Ciudad", "state": "Estado", "intersectingStreet": "Calle que Intersecta", @@ -552,4 +558,4 @@ "metricDescription": "Métrico (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 2dcb8951..b314a5b0 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Téléchargements désactivés en mode hors ligne. Désactivez le mode hors ligne pour télécharger de nouvelles zones.", "areaTooBigMessage": "Zoomez au moins au niveau {} pour télécharger des zones hors ligne. Les téléchargements de grandes zones peuvent rendre l'application non réactive.", "downloadStarted": "Téléchargement démarré ! Récupération des tuiles et nœuds...", - "downloadFailed": "Échec du démarrage du téléchargement: {}" + "downloadFailed": "Échec du démarrage du téléchargement: {}", + "offlineNotPermitted": "Le serveur {} ne permet pas les téléchargements hors ligne. Passez à un fournisseur de tuiles qui autorise l'utilisation hors ligne (par ex., Bing Maps, Mapbox ou un serveur de tuiles auto-hébergé).", + "currentTileProvider": "tuile actuelle", + "noTileProviderSelected": "Aucun fournisseur de tuiles sélectionné. Veuillez choisir un style de carte avant de télécharger une zone hors ligne." }, "downloadStarted": { "title": "Téléchargement Démarré", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Comment souhaitez-vous ajouter un profil?", "createCustomProfile": "Créer Profil Personnalisé", "createCustomProfileDescription": "Créer un profil à partir de zéro avec vos propres balises", - "importFromWebsite": "Importer depuis Site Web", + "importFromWebsite": "Importer depuis Site Web", "importFromWebsiteDescription": "Parcourir et importer des profils depuis deflock.me/identify" }, "mapTiles": { "title": "Tuiles de Carte", "manageProviders": "Gérer Fournisseurs", - "attribution": "Attribution de Carte" + "attribution": "Attribution de Carte", + "mapAttribution": "Attribution de la carte : {}", + "couldNotOpenLink": "Impossible d'ouvrir le lien", + "openLicense": "Ouvrir la licence : {}" }, "profileEditor": { "viewProfile": "Voir Profil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nouveau Profil d'Opérateur", - "editOperatorProfile": "Modifier Profil d'Opérateur", + "editOperatorProfile": "Modifier Profil d'Opérateur", "operatorName": "Nom de l'opérateur", "operatorNameHint": "ex., Département de Police d'Austin", "operatorNameRequired": "Le nom de l'opérateur est requis", @@ -520,7 +526,7 @@ "updateFailed": "Échec de la mise à jour des emplacements suspects", "neverFetched": "Jamais récupéré", "daysAgo": "il y a {} jours", - "hoursAgo": "il y a {} heures", + "hoursAgo": "il y a {} heures", "minutesAgo": "il y a {} minutes", "justNow": "À l'instant" }, @@ -528,7 +534,7 @@ "title": "Emplacement Suspect #{}", "ticketNo": "N° de Ticket", "address": "Adresse", - "street": "Rue", + "street": "Rue", "city": "Ville", "state": "État", "intersectingStreet": "Rue Transversale", @@ -552,4 +558,4 @@ "metricDescription": "Métrique (km, m)", "imperialDescription": "Impérial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/it.json b/lib/localizations/it.json index c61fe7f1..6602d76c 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Download disabilitati in modalità offline. Disabilita la modalità offline per scaricare nuove aree.", "areaTooBigMessage": "Ingrandisci almeno al livello {} per scaricare aree offline. I download di aree grandi possono rendere l'app non reattiva.", "downloadStarted": "Download avviato! Recupero tile e nodi...", - "downloadFailed": "Impossibile avviare il download: {}" + "downloadFailed": "Impossibile avviare il download: {}", + "offlineNotPermitted": "Il server {} non consente i download offline. Passa a un fornitore di tile che consenta l'uso offline (ad es., Bing Maps, Mapbox o un server di tile auto-ospitato).", + "currentTileProvider": "tile attuale", + "noTileProviderSelected": "Nessun provider di tile selezionato. Seleziona uno stile di mappa prima di scaricare un'area offline." }, "downloadStarted": { "title": "Download Avviato", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Come desideri aggiungere un profilo?", "createCustomProfile": "Crea Profilo Personalizzato", "createCustomProfileDescription": "Crea un profilo da zero con i tuoi tag", - "importFromWebsite": "Importa da Sito Web", + "importFromWebsite": "Importa da Sito Web", "importFromWebsiteDescription": "Sfoglia e importa profili da deflock.me/identify" }, "mapTiles": { "title": "Tile Mappa", "manageProviders": "Gestisci Fornitori", - "attribution": "Attribuzione Mappa" + "attribution": "Attribuzione Mappa", + "mapAttribution": "Attribuzione mappa: {}", + "couldNotOpenLink": "Impossibile aprire il link", + "openLicense": "Apri licenza: {}" }, "profileEditor": { "viewProfile": "Visualizza Profilo", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Nuovo Profilo Operatore", - "editOperatorProfile": "Modifica Profilo Operatore", + "editOperatorProfile": "Modifica Profilo Operatore", "operatorName": "Nome operatore", "operatorNameHint": "es., Dipartimento di Polizia di Austin", "operatorNameRequired": "Il nome dell'operatore è obbligatorio", @@ -520,7 +526,7 @@ "updateFailed": "Aggiornamento posizioni sospette fallito", "neverFetched": "Mai recuperato", "daysAgo": "{} giorni fa", - "hoursAgo": "{} ore fa", + "hoursAgo": "{} ore fa", "minutesAgo": "{} minuti fa", "justNow": "Proprio ora" }, @@ -528,7 +534,7 @@ "title": "Posizione Sospetta #{}", "ticketNo": "N. Ticket", "address": "Indirizzo", - "street": "Via", + "street": "Via", "city": "Città", "state": "Stato", "intersectingStreet": "Via che Interseca", @@ -552,4 +558,4 @@ "metricDescription": "Metrico (km, m)", "imperialDescription": "Imperiale (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 558cdbad..f9d0cd55 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Downloads uitgeschakeld in offline modus. Schakel offline modus uit om nieuwe gebieden te downloaden.", "areaTooBigMessage": "Zoom in tot ten minste niveau {} om offline gebieden te downloaden. Grote gebied downloads kunnen ervoor zorgen dat de app niet meer reageert.", "downloadStarted": "Download gestart! Tiles en nodes ophalen...", - "downloadFailed": "Download starten mislukt: {}" + "downloadFailed": "Download starten mislukt: {}", + "offlineNotPermitted": "De {}-server staat geen offline downloads toe. Schakel over naar een tegelserver die offline gebruik toestaat (bijv. Bing Maps, Mapbox of een zelf gehoste tegelserver).", + "currentTileProvider": "huidige tegel", + "noTileProviderSelected": "Geen tegelprovider geselecteerd. Selecteer een kaartstijl voordat u een offlinegebied downloadt." }, "downloadStarted": { "title": "Download Gestart", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Kaart Tiles", "manageProviders": "Beheer Providers", - "attribution": "Kaart Attributie" + "attribution": "Kaart Attributie", + "mapAttribution": "Kaartbron: {}", + "couldNotOpenLink": "Kon link niet openen", + "openLicense": "Open licentie: {}" }, "profileEditor": { "viewProfile": "Bekijk Profiel", @@ -552,4 +558,4 @@ "metricDescription": "Metrisch (km, m)", "imperialDescription": "Imperiaal (mijl, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 3513368e..76fc22b4 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Pobieranie wyłączone w trybie offline. Wyłącz tryb offline, aby pobierać nowe obszary.", "areaTooBigMessage": "Przybliż do co najmniej poziomu {}, aby pobierać obszary offline. Duże pobieranie obszarów może sprawić, że aplikacja przestanie odpowiadać.", "downloadStarted": "Pobieranie rozpoczęte! Pobieranie kafelków i węzłów...", - "downloadFailed": "Nie udało się rozpocząć pobierania: {}" + "downloadFailed": "Nie udało się rozpocząć pobierania: {}", + "offlineNotPermitted": "Serwer {} nie zezwala na pobieranie offline. Przełącz się na dostawcę kafelków, który obsługuje tryb offline (np. Bing Maps, Mapbox lub samodzielnie hostowany serwer kafelków).", + "currentTileProvider": "bieżący kafelek", + "noTileProviderSelected": "Nie wybrano dostawcy kafelków. Wybierz styl mapy przed pobraniem obszaru offline." }, "downloadStarted": { "title": "Pobieranie Rozpoczęte", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Kafelki Mapy", "manageProviders": "Zarządzaj Dostawcami", - "attribution": "Atrybucja Mapy" + "attribution": "Atrybucja Mapy", + "mapAttribution": "Źródło mapy: {}", + "couldNotOpenLink": "Nie udało się otworzyć linku", + "openLicense": "Otwórz licencję: {}" }, "profileEditor": { "viewProfile": "Zobacz Profil", @@ -552,4 +558,4 @@ "metricDescription": "Metryczny (km, m)", "imperialDescription": "Imperialny (mila, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 38e611b7..8366a549 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Downloads desabilitados no modo offline. Desative o modo offline para baixar novas áreas.", "areaTooBigMessage": "Amplie para pelo menos o nível {} para baixar áreas offline. Downloads de áreas grandes podem tornar o aplicativo não responsivo.", "downloadStarted": "Download iniciado! Buscando tiles e nós...", - "downloadFailed": "Falha ao iniciar o download: {}" + "downloadFailed": "Falha ao iniciar o download: {}", + "offlineNotPermitted": "O servidor {} não permite downloads offline. Mude para um provedor de tiles que permita uso offline (por ex., Bing Maps, Mapbox ou um servidor de tiles próprio).", + "currentTileProvider": "tile atual", + "noTileProviderSelected": "Nenhum provedor de tiles selecionado. Selecione um estilo de mapa antes de baixar uma área offline." }, "downloadStarted": { "title": "Download Iniciado", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "Como gostaria de adicionar um perfil?", "createCustomProfile": "Criar Perfil Personalizado", "createCustomProfileDescription": "Construir um perfil do zero com suas próprias tags", - "importFromWebsite": "Importar do Site", + "importFromWebsite": "Importar do Site", "importFromWebsiteDescription": "Navegar e importar perfis do deflock.me/identify" }, "mapTiles": { "title": "Tiles do Mapa", "manageProviders": "Gerenciar Provedores", - "attribution": "Atribuição do Mapa" + "attribution": "Atribuição do Mapa", + "mapAttribution": "Atribuição do mapa: {}", + "couldNotOpenLink": "Não foi possível abrir o link", + "openLicense": "Abrir licença: {}" }, "profileEditor": { "viewProfile": "Ver Perfil", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "Novo Perfil de Operador", - "editOperatorProfile": "Editar Perfil de Operador", + "editOperatorProfile": "Editar Perfil de Operador", "operatorName": "Nome do operador", "operatorNameHint": "ex., Departamento de Polícia de Austin", "operatorNameRequired": "Nome do operador é obrigatório", @@ -520,7 +526,7 @@ "updateFailed": "Falha ao atualizar localizações suspeitas", "neverFetched": "Nunca buscado", "daysAgo": "{} dias atrás", - "hoursAgo": "{} horas atrás", + "hoursAgo": "{} horas atrás", "minutesAgo": "{} minutos atrás", "justNow": "Agora mesmo" }, @@ -528,7 +534,7 @@ "title": "Localização Suspeita #{}", "ticketNo": "N° do Ticket", "address": "Endereço", - "street": "Rua", + "street": "Rua", "city": "Cidade", "state": "Estado", "intersectingStreet": "Rua que Cruza", @@ -552,4 +558,4 @@ "metricDescription": "Métrico (km, m)", "imperialDescription": "Imperial (mi, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index f2934682..06fc9adf 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Çevrimdışı moddayken indirmeler devre dışı. Yeni alanları indirmek için çevrimdışı modu devre dışı bırakın.", "areaTooBigMessage": "Çevrimdışı alanları indirmek için en az {} seviyesine yakınlaştırın. Büyük alan indirmeleri uygulamanın yanıt vermemesine neden olabilir.", "downloadStarted": "İndirme başladı! Döşemeler ve düğümler getiriliyor...", - "downloadFailed": "İndirme başlatılamadı: {}" + "downloadFailed": "İndirme başlatılamadı: {}", + "offlineNotPermitted": "{} sunucusu çevrimdışı indirmelere izin vermiyor. Çevrimdışı kullanıma izin veren bir döşeme sağlayıcısına geçin (ör. Bing Maps, Mapbox veya kendi barındırdığınız bir döşeme sunucusu).", + "currentTileProvider": "mevcut döşeme", + "noTileProviderSelected": "Döşeme sağlayıcı seçilmedi. Çevrimdışı alan indirmeden önce lütfen bir harita stili seçin." }, "downloadStarted": { "title": "İndirme Başladı", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Harita Döşemeleri", "manageProviders": "Sağlayıcıları Yönet", - "attribution": "Harita Atfı" + "attribution": "Harita Atfı", + "mapAttribution": "Harita kaynağı: {}", + "couldNotOpenLink": "Bağlantı açılamadı", + "openLicense": "Lisansı aç: {}" }, "profileEditor": { "viewProfile": "Profili Görüntüle", @@ -552,4 +558,4 @@ "metricDescription": "Metrik (km, m)", "imperialDescription": "İmperial (mil, ft)" } -} \ No newline at end of file +} diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index e8f208e9..499f1a24 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -181,7 +181,10 @@ "offlineModeWarning": "Завантаження вимкнено в офлайн режимі. Вимкніть офлайн режим для завантаження нових областей.", "areaTooBigMessage": "Збільште масштаб до принаймні рівня {} для завантаження офлайн областей. Великі завантаження областей можуть призвести до того, що додаток перестане відповідати.", "downloadStarted": "Завантаження почалося! Отримання плиток та вузлів...", - "downloadFailed": "Не вдалося почати завантаження: {}" + "downloadFailed": "Не вдалося почати завантаження: {}", + "offlineNotPermitted": "Сервер {} не дозволяє офлайн-завантаження. Перейдіть на постачальника плиток, який дозволяє офлайн-використання (наприклад, Bing Maps, Mapbox або власний сервер плиток).", + "currentTileProvider": "поточна плитка", + "noTileProviderSelected": "Постачальник плиток не вибраний. Виберіть стиль карти перед завантаженням офлайн-області." }, "downloadStarted": { "title": "Завантаження Почалося", @@ -335,7 +338,10 @@ "mapTiles": { "title": "Плитки Карти", "manageProviders": "Управляти Постачальниками", - "attribution": "Атрибуція Карти" + "attribution": "Атрибуція Карти", + "mapAttribution": "Джерело карти: {}", + "couldNotOpenLink": "Не вдалося відкрити посилання", + "openLicense": "Відкрити ліцензію: {}" }, "profileEditor": { "viewProfile": "Переглянути Профіль", @@ -552,4 +558,4 @@ "metricDescription": "Метричні (км, м)", "imperialDescription": "Імперські (миля, фут)" } -} \ No newline at end of file +} diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index ab446840..00695588 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -181,7 +181,10 @@ "offlineModeWarning": "离线模式下禁用下载。禁用离线模式以下载新区域。", "areaTooBigMessage": "请放大至至少第{}级来下载离线区域。下载大区域可能导致应用程序无响应。", "downloadStarted": "下载已开始!正在获取瓦片和节点...", - "downloadFailed": "启动下载失败:{}" + "downloadFailed": "启动下载失败:{}", + "offlineNotPermitted": "{}服务器不允许离线下载。请切换到允许离线使用的瓦片提供商(例如 Bing Maps、Mapbox 或自托管的瓦片服务器)。", + "currentTileProvider": "当前瓦片", + "noTileProviderSelected": "未选择瓦片提供商。请在下载离线区域之前选择地图样式。" }, "downloadStarted": { "title": "下载已开始", @@ -329,13 +332,16 @@ "addProfileChoiceMessage": "您希望如何添加配置文件?", "createCustomProfile": "创建自定义配置文件", "createCustomProfileDescription": "从头开始构建带有您自己标签的配置文件", - "importFromWebsite": "从网站导入", + "importFromWebsite": "从网站导入", "importFromWebsiteDescription": "浏览并从 deflock.me/identify 导入配置文件" }, "mapTiles": { "title": "地图瓦片", "manageProviders": "管理提供商", - "attribution": "地图归属" + "attribution": "地图归属", + "mapAttribution": "地图来源:{}", + "couldNotOpenLink": "无法打开链接", + "openLicense": "打开许可证:{}" }, "profileEditor": { "viewProfile": "查看配置文件", @@ -362,7 +368,7 @@ }, "operatorProfileEditor": { "newOperatorProfile": "新建运营商配置文件", - "editOperatorProfile": "编辑运营商配置文件", + "editOperatorProfile": "编辑运营商配置文件", "operatorName": "运营商名称", "operatorNameHint": "例如,奥斯汀警察局", "operatorNameRequired": "运营商名称为必填项", @@ -520,7 +526,7 @@ "updateFailed": "疑似位置更新失败", "neverFetched": "从未获取", "daysAgo": "{}天前", - "hoursAgo": "{}小时前", + "hoursAgo": "{}小时前", "minutesAgo": "{}分钟前", "justNow": "刚刚" }, @@ -528,7 +534,7 @@ "title": "疑似位置 #{}", "ticketNo": "工单号", "address": "地址", - "street": "街道", + "street": "街道", "city": "城市", "state": "州/省", "intersectingStreet": "交叉街道", @@ -552,4 +558,4 @@ "metricDescription": "公制 (公里, 米)", "imperialDescription": "英制 (英里, 英尺)" } -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 9bd2d565..ca0445b8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'screens/release_notes_screen.dart'; import 'screens/osm_account_screen.dart'; import 'screens/upload_queue_screen.dart'; import 'services/localization_service.dart'; +import 'services/provider_tile_cache_manager.dart'; import 'services/version_service.dart'; import 'services/deep_link_service.dart'; @@ -21,13 +22,16 @@ import 'services/deep_link_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - + // Initialize version service await VersionService().init(); - + // Initialize localization service await LocalizationService.instance.init(); + // Resolve platform cache directory for per-provider tile caching + await ProviderTileCacheManager.init(); + // Initialize deep link service await DeepLinkService().init(); DeepLinkService().setNavigatorKey(_navigatorKey); diff --git a/lib/migrations.dart b/lib/migrations.dart index ec8e2fa5..b74a52b4 100644 --- a/lib/migrations.dart +++ b/lib/migrations.dart @@ -114,6 +114,34 @@ class OneTimeMigrations { } } + /// Initialize profile ordering for existing users (v2.7.3) + static Future migrate_2_7_3(AppState appState) async { + try { + final prefs = await SharedPreferences.getInstance(); + const orderKey = 'profile_order'; + + // Check if user already has custom profile ordering + if (prefs.containsKey(orderKey)) { + debugPrint('[Migration] 2.7.3: Profile order already exists, skipping'); + return; + } + + // Initialize with current profile order (preserves existing UI order) + final currentProfiles = appState.profiles; + final initialOrder = currentProfiles.map((p) => p.id).toList(); + + if (initialOrder.isNotEmpty) { + await prefs.setStringList(orderKey, initialOrder); + debugPrint('[Migration] 2.7.3: Initialized profile order with ${initialOrder.length} profiles'); + } + + debugPrint('[Migration] 2.7.3 completed: initialized profile ordering'); + } catch (e) { + debugPrint('[Migration] 2.7.3 ERROR: Failed to initialize profile ordering: $e'); + // Don't rethrow - this is non-critical, profiles will just use default order + } + } + /// Get the migration function for a specific version static Future Function(AppState)? getMigrationForVersion(String version) { switch (version) { @@ -127,6 +155,8 @@ class OneTimeMigrations { return migrate_1_8_0; case '2.1.0': return migrate_2_1_0; + case '2.7.3': + return migrate_2_7_3; default: return null; } diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart index cdf996aa..cc8612ae 100644 --- a/lib/models/node_profile.dart +++ b/lib/models/node_profile.dart @@ -269,16 +269,33 @@ class NodeProfile { /// Used as the default `` option when editing nodes /// All existing tags will flow through as additionalExistingTags static NodeProfile createExistingTagsProfile(OsmNode node) { - // Calculate FOV from existing direction ranges if applicable + // Only assign FOV if the original direction string actually contained range notation + // (e.g., "90-270" or "55-125"), not if it was just single directions (e.g., "90") double? calculatedFov; - // If node has direction/FOV pairs, check if they all have the same FOV - if (node.directionFovPairs.isNotEmpty) { - final firstFov = node.directionFovPairs.first.fovDegrees; + final raw = node.tags['direction'] ?? node.tags['camera:direction']; + if (raw != null) { + // Check if any part of the direction string contains range notation (dash with numbers) + final parts = raw.split(';'); + bool hasRangeNotation = false; - // If all directions have the same FOV, use it for the profile - if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) { - calculatedFov = firstFov; + for (final part in parts) { + final trimmed = part.trim(); + // Look for range pattern: numbers-numbers (e.g., "90-270", "55-125") + if (trimmed.contains('-') && RegExp(r'^\d+\.?\d*-\d+\.?\d*$').hasMatch(trimmed)) { + hasRangeNotation = true; + break; + } + } + + // Only calculate FOV if the node originally had range notation + if (hasRangeNotation && node.directionFovPairs.isNotEmpty) { + final firstFov = node.directionFovPairs.first.fovDegrees; + + // If all directions have the same FOV, use it for the profile + if (node.directionFovPairs.every((df) => df.fovDegrees == firstFov)) { + calculatedFov = firstFov; + } } } @@ -290,7 +307,7 @@ class NodeProfile { requiresDirection: true, submittable: true, editable: false, - fov: calculatedFov, // Use calculated FOV from existing direction ranges + fov: calculatedFov, // Only use FOV if original had explicit range notation ); } diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 8f7d81c4..5304d33e 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; +import '../services/service_policy.dart'; + /// A specific tile type within a provider class TileType { final String id; @@ -10,7 +12,7 @@ class TileType { final Uint8List? previewTile; // Single tile image data for preview final int maxZoom; // Maximum zoom level for this tile type - const TileType({ + TileType({ required this.id, required this.name, required this.urlTemplate, @@ -76,6 +78,15 @@ class TileType { /// Check if this tile type needs an API key bool get requiresApiKey => urlTemplate.contains('{api_key}'); + /// The service policy that applies to this tile type's server. + /// Cached because [urlTemplate] is immutable. + late final ServicePolicy servicePolicy = + ServicePolicyResolver.resolve(urlTemplate); + + /// Whether this tile server's usage policy permits offline/bulk downloading. + /// Resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload; + Map toJson() => { 'id': id, 'name': name, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ff9702e6..acc3e03a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -578,37 +578,41 @@ class _HomeScreenState extends State with TickerProviderStateMixin { flex: 3, // 30% for secondary action child: AnimatedBuilder( animation: LocalizationService.instance, - builder: (context, child) => FittedBox( - fit: BoxFit.scaleDown, - child: ElevatedButton.icon( - icon: Icon(Icons.download_for_offline), - label: Text(LocalizationService.instance.download), - onPressed: () { - // Check minimum zoom level before opening download dialog - final currentZoom = _mapController.mapController.camera.zoom; - if (currentZoom < kMinZoomForOfflineDownload) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - LocalizationService.instance.t('download.areaTooBigMessage', - params: [kMinZoomForOfflineDownload.toString()]) + builder: (context, child) { + final appState = context.watch(); + final canDownload = appState.selectedTileType?.allowsOfflineDownload ?? false; + return FittedBox( + fit: BoxFit.scaleDown, + child: ElevatedButton.icon( + icon: Icon(Icons.download_for_offline), + label: Text(LocalizationService.instance.download), + onPressed: canDownload ? () { + // Check minimum zoom level before opening download dialog + final currentZoom = _mapController.mapController.camera.zoom; + if (currentZoom < kMinZoomForOfflineDownload) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + LocalizationService.instance.t('download.areaTooBigMessage', + params: [kMinZoomForOfflineDownload.toString()]) + ), ), - ), + ); + return; + } + + showDialog( + context: context, + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), ); - return; - } - - showDialog( - context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), - ); - }, - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), + } : null, + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), ), - ), - ), + ); + }, ), ), ], diff --git a/lib/screens/settings/sections/node_profiles_section.dart b/lib/screens/settings/sections/node_profiles_section.dart index ed997f01..89fcdafa 100644 --- a/lib/screens/settings/sections/node_profiles_section.dart +++ b/lib/screens/settings/sections/node_profiles_section.dart @@ -34,76 +34,101 @@ class NodeProfilesSection extends StatelessWidget { ), ], ), - ...appState.profiles.map( - (p) => ListTile( - leading: Checkbox( - value: appState.isEnabled(p), - onChanged: (v) => appState.toggleProfile(p, v ?? false), - ), - title: Text(p.name), - subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')), - trailing: !p.editable - ? PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - value: 'view', - child: Row( - children: [ - const Icon(Icons.visibility), - const SizedBox(width: 8), - Text(locService.t('profiles.view')), - ], - ), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: appState.profiles.length, + onReorder: (oldIndex, newIndex) { + appState.reorderProfiles(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final p = appState.profiles[index]; + return ListTile( + key: ValueKey(p.id), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + ReorderableDragStartListener( + index: index, + child: const Icon( + Icons.drag_handle, + color: Colors.grey, ), - ], - onSelected: (value) { - if (value == 'view') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileEditor(profile: p), + ), + const SizedBox(width: 8), + // Checkbox + Checkbox( + value: appState.isEnabled(p), + onChanged: (v) => appState.toggleProfile(p, v ?? false), + ), + ], + ), + title: Text(p.name), + subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')), + trailing: !p.editable + ? PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'view', + child: Row( + children: [ + const Icon(Icons.visibility), + const SizedBox(width: 8), + Text(locService.t('profiles.view')), + ], ), - ); - } - }, - ) - : PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: 8), - Text(locService.t('actions.edit')), - ], ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete, color: Colors.red), - const SizedBox(width: 8), - Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)), - ], + ], + onSelected: (value) { + if (value == 'view') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor(profile: p), + ), + ); + } + }, + ) + : PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 8), + Text(locService.t('actions.edit')), + ], + ), ), - ), - ], - onSelected: (value) { - if (value == 'edit') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileEditor(profile: p), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red), + const SizedBox(width: 8), + Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)), + ], ), - ); - } else if (value == 'delete') { - _showDeleteProfileDialog(context, p); - } - }, - ), - ), + ), + ], + onSelected: (value) { + if (value == 'edit') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProfileEditor(profile: p), + ), + ); + } else if (value == 'delete') { + _showDeleteProfileDialog(context, p); + } + }, + ), + ); + }, ), ], ); diff --git a/lib/screens/settings/sections/upload_mode_section.dart b/lib/screens/settings/sections/upload_mode_section.dart index 61da3793..568196e2 100644 --- a/lib/screens/settings/sections/upload_mode_section.dart +++ b/lib/screens/settings/sections/upload_mode_section.dart @@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget { subtitle: Text(locService.t('uploadMode.subtitle')), trailing: DropdownButton( value: appState.uploadMode, + // This entire section is gated behind kEnableDevelopmentModes + // in osm_account_screen.dart, so all modes are always available here. items: [ DropdownMenuItem( value: UploadMode.production, diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 481be1c1..e92f5d48 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -36,6 +36,7 @@ class AuthService { void setUploadMode(UploadMode mode) { _mode = mode; + if (mode == UploadMode.simulate || !kHasOsmSecrets) return; final isSandbox = (mode == UploadMode.sandbox); final authBase = isSandbox ? 'https://master.apis.dev.openstreetmap.org' @@ -150,7 +151,9 @@ class AuthService { // Force a fresh login by clearing stored tokens Future forceLogin() async { - await _helper.removeAllTokens(); + if (_mode != UploadMode.simulate) { + await _helper.removeAllTokens(); + } _displayName = null; return await login(); } diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart index 9244d70e..0ee3be49 100644 --- a/lib/services/changelog_service.dart +++ b/lib/services/changelog_service.dart @@ -225,6 +225,10 @@ class ChangelogService { versionsNeedingMigration.add('1.6.3'); } + if (needsMigration(lastSeenVersion, currentVersion, '2.7.3')) { + versionsNeedingMigration.add('2.7.3'); + } + // Future versions can be added here // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { // versionsNeedingMigration.add('2.0.0'); diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 707f0155..7b1d6ce7 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:ui'; import 'package:flutter_map/flutter_map.dart'; @@ -8,55 +9,110 @@ import 'package:http/http.dart'; import 'package:http/retry.dart'; import '../app_state.dart'; +import '../models/tile_provider.dart' as models; import 'http_client.dart'; import 'map_data_submodules/tiles_from_local.dart'; import 'offline_area_service.dart'; +/// Thrown when a tile load is cancelled (tile scrolled off screen). +/// TileLayerManager skips retry for these — the tile is already gone. +class TileLoadCancelledException implements Exception { + const TileLoadCancelledException(); +} + +/// Thrown when a tile is not available offline (no offline area or cache hit). +/// TileLayerManager skips retry for these — retrying won't help without network. +class TileNotAvailableOfflineException implements Exception { + const TileNotAvailableOfflineException(); +} + /// Custom tile provider that extends NetworkTileProvider to leverage its /// built-in disk cache, RetryClient, ETag revalidation, and abort support, /// while routing URLs through our TileType logic and supporting offline tiles. /// +/// Each instance is configured for a specific tile provider/type combination +/// with frozen config — no AppState lookups at request time (except for the +/// global offlineMode toggle). +/// /// Two runtime paths: /// 1. **Common path** (no offline areas for current provider): delegates to /// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider /// pipeline (disk cache, ETag revalidation, RetryClient, abort support). /// 2. **Offline-first path** (has offline areas or offline mode): returns -/// DeflockOfflineTileImageProvider — checks fetchLocalTile() first, falls -/// back to HTTP via shared RetryClient on miss. +/// DeflockOfflineTileImageProvider — checks disk cache and local tiles +/// first, falls back to HTTP via shared RetryClient on miss. class DeflockTileProvider extends NetworkTileProvider { /// The shared HTTP client we own. We keep a reference because /// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient /// will be false (we passed it in), so super.dispose() won't close it. final Client _sharedHttpClient; - DeflockTileProvider._({required Client httpClient}) - : _sharedHttpClient = httpClient, + /// Frozen config for this provider instance. + final String providerId; + final models.TileType tileType; + final String? apiKey; + + /// Opaque fingerprint of the config this provider was created with. + /// Used by [TileLayerManager] to detect config drift after edits. + final String configFingerprint; + + /// Caching provider for the offline-first path. The same instance is passed + /// to super for the common path — we keep a reference here so we can also + /// use it in [DeflockOfflineTileImageProvider]. + final MapCachingProvider? _cachingProvider; + + /// Called when a tile loads successfully via the network in the offline-first + /// path. Used by [TileLayerManager] to reset exponential backoff. + VoidCallback? onNetworkSuccess; + + // ignore: use_super_parameters + DeflockTileProvider._({ + required Client httpClient, + required this.providerId, + required this.tileType, + this.apiKey, + MapCachingProvider? cachingProvider, + this.onNetworkSuccess, + this.configFingerprint = '', + }) : _sharedHttpClient = httpClient, + _cachingProvider = cachingProvider, super( httpClient: httpClient, - silenceExceptions: true, + cachingProvider: cachingProvider, + // Let errors propagate so flutter_map marks tiles as failed + // (loadError = true) rather than caching transparent images as + // "successfully loaded". The TileLayerManager wires a reset stream + // that retries failed tiles after a debounced delay. + silenceExceptions: false, ); - factory DeflockTileProvider() { + factory DeflockTileProvider({ + required String providerId, + required models.TileType tileType, + String? apiKey, + MapCachingProvider? cachingProvider, + VoidCallback? onNetworkSuccess, + String configFingerprint = '', + }) { final client = UserAgentClient(RetryClient(Client())); - return DeflockTileProvider._(httpClient: client); + return DeflockTileProvider._( + httpClient: client, + providerId: providerId, + tileType: tileType, + apiKey: apiKey, + cachingProvider: cachingProvider, + onNetworkSuccess: onNetworkSuccess, + configFingerprint: configFingerprint, + ); } @override String getTileUrl(TileCoordinates coordinates, TileLayer options) { - final appState = AppState.instance; - final selectedTileType = appState.selectedTileType; - final selectedProvider = appState.selectedTileProvider; - - if (selectedTileType == null || selectedProvider == null) { - // Fallback to base implementation if no provider configured - return super.getTileUrl(coordinates, options); - } - - return selectedTileType.getTileUrl( + return tileType.getTileUrl( coordinates.z, coordinates.x, coordinates.y, - apiKey: selectedProvider.apiKey, + apiKey: apiKey, ); } @@ -66,7 +122,7 @@ class DeflockTileProvider extends NetworkTileProvider { TileLayer options, Future cancelLoading, ) { - if (!_shouldCheckOfflineCache()) { + if (!_shouldCheckOfflineCache(coordinates.z)) { // Common path: no offline areas — delegate to NetworkTileProvider's // full pipeline (disk cache, ETag, RetryClient, abort support). return super.getImageWithCancelLoadingSupport( @@ -77,20 +133,18 @@ class DeflockTileProvider extends NetworkTileProvider { } // Offline-first path: check local tiles first, fall back to network. - final appState = AppState.instance; - final providerId = appState.selectedTileProvider?.id ?? 'unknown'; - final tileTypeId = appState.selectedTileType?.id ?? 'unknown'; - return DeflockOfflineTileImageProvider( coordinates: coordinates, options: options, httpClient: _sharedHttpClient, headers: headers, cancelLoading: cancelLoading, - isOfflineOnly: appState.offlineMode, + isOfflineOnly: AppState.instance.offlineMode, providerId: providerId, - tileTypeId: tileTypeId, + tileTypeId: tileType.id, tileUrl: getTileUrl(coordinates, options), + cachingProvider: _cachingProvider, + onNetworkSuccess: onNetworkSuccess, ); } @@ -101,44 +155,67 @@ class DeflockTileProvider extends NetworkTileProvider { /// /// This avoids the offline-first path (and its filesystem searches) when /// browsing online with providers that have no offline areas. - bool _shouldCheckOfflineCache() { - final appState = AppState.instance; - + bool _shouldCheckOfflineCache(int zoom) { // Always use offline path in offline mode - if (appState.offlineMode) { + if (AppState.instance.offlineMode) { return true; } // For online mode, only use offline path if we have relevant offline data - final currentProvider = appState.selectedTileProvider; - final currentTileType = appState.selectedTileType; - - if (currentProvider == null || currentTileType == null) { - return false; - } - + // at this zoom level — tiles outside any area's zoom range go through the + // common NetworkTileProvider path for better performance. final offlineService = OfflineAreaService(); - return offlineService.hasOfflineAreasForProvider( - currentProvider.id, - currentTileType.id, + return offlineService.hasOfflineAreasForProviderAtZoom( + providerId, + tileType.id, + zoom, ); } @override Future dispose() async { - try { - await super.dispose(); - } finally { - _sharedHttpClient.close(); - } + // Only call super — do NOT close _sharedHttpClient here. + // flutter_map calls dispose() whenever the TileLayer widget is recycled + // (e.g. provider switch causes a new FlutterMap key), but + // TileLayerManager caches and reuses provider instances across switches. + // Closing the HTTP client here would leave the cached instance broken — + // all future tile requests would fail with "Client closed". + // + // Since we passed our own httpClient to NetworkTileProvider, + // _isInternallyCreatedClient is false, so super.dispose() won't close it + // either. The client is closed in [shutdown], called by + // TileLayerManager.dispose() when the map is truly torn down. + await super.dispose(); + } + + /// Permanently close the HTTP client. Called by [TileLayerManager.dispose] + /// when the map widget is being torn down — NOT by flutter_map's widget + /// recycling. + void shutdown() { + _sharedHttpClient.close(); } } /// Image provider for the offline-first path. /// -/// Tries fetchLocalTile() first. On miss (and if online), falls back to an -/// HTTP GET via the shared RetryClient. Handles cancelLoading abort and -/// returns transparent tiles on errors (consistent with silenceExceptions). +/// Checks disk cache and offline areas before falling back to the network. +/// Caches successful network fetches to disk so panning back doesn't re-fetch. +/// On cancellation, lets in-flight downloads complete and caches the result +/// (fire-and-forget) instead of discarding downloaded bytes. +/// +/// **Online mode flow:** +/// 1. Disk cache (fast hash-based file read) → hit + fresh → return +/// 2. Offline areas (file scan) → hit → return +/// 3. Network fetch with conditional headers from stale cache entry +/// 4. On cancel → fire-and-forget cache write for the in-flight download +/// 5. On 304 → return stale cached bytes, update cache metadata +/// 6. On 200 → cache to disk, decode and return +/// 7. On error → throw (flutter_map marks tile as failed) +/// +/// **Offline mode flow:** +/// 1. Offline areas (primary source — guaranteed available) +/// 2. Disk cache (tiles cached from previous online sessions) +/// 3. Throw if both miss (flutter_map marks tile as failed) class DeflockOfflineTileImageProvider extends ImageProvider { final TileCoordinates coordinates; @@ -150,6 +227,8 @@ class DeflockOfflineTileImageProvider final String providerId; final String tileTypeId; final String tileUrl; + final MapCachingProvider? cachingProvider; + final VoidCallback? onNetworkSuccess; const DeflockOfflineTileImageProvider({ required this.coordinates, @@ -161,6 +240,8 @@ class DeflockOfflineTileImageProvider required this.providerId, required this.tileTypeId, required this.tileUrl, + this.cachingProvider, + this.onNetworkSuccess, }); @override @@ -173,19 +254,47 @@ class DeflockOfflineTileImageProvider ImageStreamCompleter loadImage( DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) { final chunkEvents = StreamController(); - final codecFuture = _loadAsync(key, decode, chunkEvents); - - codecFuture.whenComplete(() { - chunkEvents.close(); - }); return MultiFrameImageStreamCompleter( - codec: codecFuture, + // Chain whenComplete into the codec future so there's a single future + // for MultiFrameImageStreamCompleter to handle. Without this, the + // whenComplete creates an orphaned future whose errors go unhandled. + codec: _loadAsync(key, decode, chunkEvents).whenComplete(() { + chunkEvents.close(); + }), chunkEvents: chunkEvents.stream, scale: 1.0, ); } + /// Try to read a tile from the disk cache. Returns null on miss or error. + Future _getCachedTile() async { + if (cachingProvider == null || !cachingProvider!.isSupported) return null; + try { + return await cachingProvider!.getTile(tileUrl); + } on CachedMapTileReadFailure { + return null; + } catch (_) { + return null; + } + } + + /// Write a tile to the disk cache (best-effort, never throws). + void _putCachedTile({ + required Map responseHeaders, + Uint8List? bytes, + }) { + if (cachingProvider == null || !cachingProvider!.isSupported) return; + try { + final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders); + cachingProvider! + .putTile(url: tileUrl, metadata: metadata, bytes: bytes) + .catchError((_) {}); + } catch (_) { + // Best-effort: never fail the tile load due to cache write errors. + } + } + Future _loadAsync( DeflockOfflineTileImageProvider key, ImageDecoderCallback decode, @@ -194,67 +303,156 @@ class DeflockOfflineTileImageProvider Future decodeBytes(Uint8List bytes) => ImmutableBuffer.fromUint8List(bytes).then(decode); - Future transparent() => - decodeBytes(TileProvider.transparentImage); + // Track cancellation synchronously via Completer so the catch block + // can reliably check it without microtask ordering races. + final cancelled = Completer(); + cancelLoading.then((_) { + if (!cancelled.isCompleted) cancelled.complete(); + }).ignore(); try { - // Track cancellation - bool cancelled = false; - cancelLoading.then((_) => cancelled = true); - - // Try local tile first — pass captured IDs to avoid a race if the - // user switches provider while this async load is in flight. - try { - final localBytes = await fetchLocalTile( - z: coordinates.z, - x: coordinates.x, - y: coordinates.y, - providerId: providerId, - tileTypeId: tileTypeId, - ); - return await decodeBytes(Uint8List.fromList(localBytes)); - } catch (_) { - // Local miss — fall through to network if online + if (isOfflineOnly) { + return await _loadOffline(decodeBytes, cancelled); + } + return await _loadOnline(decodeBytes, cancelled); + } catch (e) { + // Cancelled tiles throw — flutter_map handles the error silently. + // Preserve TileNotAvailableOfflineException even if the tile was also + // cancelled — it has distinct semantics (genuine cache miss) that + // matter for diagnostics and future UI indicators. + if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) { + throw const TileLoadCancelledException(); } - if (cancelled) return await transparent(); - if (isOfflineOnly) return await transparent(); - - // Fall back to network via shared RetryClient. - // Race the download against cancelLoading so we stop waiting if the - // tile is pruned mid-flight (the underlying TCP connection is cleaned - // up naturally by the shared client). - final request = Request('GET', Uri.parse(tileUrl)); - request.headers.addAll(headers); + // Let real errors propagate so flutter_map marks loadError = true + rethrow; + } + } - final networkFuture = httpClient.send(request).then((response) async { - final bytes = await response.stream.toBytes(); - return (statusCode: response.statusCode, bytes: bytes); - }); + /// Online mode: disk cache → offline areas → network (with caching). + Future _loadOnline( + Future Function(Uint8List) decodeBytes, + Completer cancelled, + ) async { + // 1. Check disk cache — fast hash-based file read. + final cachedTile = await _getCachedTile(); + if (cachedTile != null && !cachedTile.metadata.isStale) { + return await decodeBytes(cachedTile.bytes); + } - final result = await Future.any([ - networkFuture, - cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))), - ]); + // 2. Check offline areas — file scan per area. + try { + final localBytes = await fetchLocalTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + providerId: providerId, + tileTypeId: tileTypeId, + ); + return await decodeBytes(Uint8List.fromList(localBytes)); + } catch (_) { + // Local miss — fall through to network + } - if (cancelled || result.statusCode == 0) return await transparent(); + // 3. If cancelled before network, bail. + if (cancelled.isCompleted) throw const TileLoadCancelledException(); - if (result.statusCode == 200 && result.bytes.isNotEmpty) { - return await decodeBytes(result.bytes); + // 4. Network fetch with conditional headers from stale cache entry. + final request = Request('GET', Uri.parse(tileUrl)); + request.headers.addAll(headers); + if (cachedTile != null) { + if (cachedTile.metadata.lastModified case final lastModified?) { + request.headers[HttpHeaders.ifModifiedSinceHeader] = + HttpDate.format(lastModified); } - - return await transparent(); - } catch (e) { - // Don't log routine offline misses - if (!e.toString().contains('offline')) { - debugPrint( - '[DeflockTileProvider] Offline-first tile failed ' - '${coordinates.z}/${coordinates.x}/${coordinates.y} ' - '(${e.runtimeType})'); + if (cachedTile.metadata.etag case final etag?) { + request.headers[HttpHeaders.ifNoneMatchHeader] = etag; } - return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage) - .then(decode); } + + // 5. Race the download against cancelLoading. + final networkFuture = httpClient.send(request).then((response) async { + final bytes = await response.stream.toBytes(); + return ( + statusCode: response.statusCode, + bytes: bytes, + headers: response.headers, + ); + }); + + final result = await Future.any([ + networkFuture, + cancelLoading.then((_) => ( + statusCode: 0, + bytes: Uint8List(0), + headers: {}, + )), + ]); + + // 6. On cancel — fire-and-forget cache write for the in-flight download + // instead of discarding the downloaded bytes. + if (cancelled.isCompleted || result.statusCode == 0) { + networkFuture.then((r) { + if (r.statusCode == 200 && r.bytes.isNotEmpty) { + _putCachedTile(responseHeaders: r.headers, bytes: r.bytes); + } + }).ignore(); + throw const TileLoadCancelledException(); + } + + // 7. On 304 Not Modified → return stale cached bytes, update metadata. + if (result.statusCode == HttpStatus.notModified && cachedTile != null) { + _putCachedTile(responseHeaders: result.headers); + onNetworkSuccess?.call(); + return await decodeBytes(cachedTile.bytes); + } + + // 8. On 200 OK → cache to disk, decode and return. + if (result.statusCode == 200 && result.bytes.isNotEmpty) { + _putCachedTile(responseHeaders: result.headers, bytes: result.bytes); + onNetworkSuccess?.call(); + return await decodeBytes(result.bytes); + } + + // 9. Network error — throw so flutter_map marks the tile as failed. + // Don't include tileUrl in the exception — it may contain API keys. + throw HttpException( + 'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} ' + 'returned status ${result.statusCode}', + ); + } + + /// Offline mode: offline areas → disk cache → throw. + Future _loadOffline( + Future Function(Uint8List) decodeBytes, + Completer cancelled, + ) async { + // 1. Check offline areas (primary source — guaranteed available). + try { + final localBytes = await fetchLocalTile( + z: coordinates.z, + x: coordinates.x, + y: coordinates.y, + providerId: providerId, + tileTypeId: tileTypeId, + ); + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + return await decodeBytes(Uint8List.fromList(localBytes)); + } on TileLoadCancelledException { + rethrow; + } catch (_) { + // Local miss — fall through to disk cache + } + + // 2. Check disk cache (tiles cached from previous online sessions). + if (cancelled.isCompleted) throw const TileLoadCancelledException(); + final cachedTile = await _getCachedTile(); + if (cachedTile != null) { + return await decodeBytes(cachedTile.bytes); + } + + // 3. Both miss — throw so flutter_map marks the tile as failed. + throw const TileNotAvailableOfflineException(); } @override @@ -263,9 +461,11 @@ class DeflockOfflineTileImageProvider return other is DeflockOfflineTileImageProvider && other.coordinates == coordinates && other.providerId == providerId && - other.tileTypeId == tileTypeId; + other.tileTypeId == tileTypeId && + other.isOfflineOnly == isOfflineOnly; } @override - int get hashCode => Object.hash(coordinates, providerId, tileTypeId); + int get hashCode => + Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly); } diff --git a/lib/services/map_data_submodules/nodes_from_osm_api.dart b/lib/services/map_data_submodules/nodes_from_osm_api.dart index 342abf6a..0c21dada 100644 --- a/lib/services/map_data_submodules/nodes_from_osm_api.dart +++ b/lib/services/map_data_submodules/nodes_from_osm_api.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:xml/xml.dart'; @@ -7,6 +8,7 @@ import '../../models/node_profile.dart'; import '../../models/osm_node.dart'; import '../../app_state.dart'; import '../http_client.dart'; +import '../service_policy.dart'; /// Fetches surveillance nodes from the direct OSM API using bbox query. /// This is a fallback for when Overpass is not available (e.g., sandbox mode). @@ -58,28 +60,36 @@ Future> _fetchFromOsmApi({ try { debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...'); debugPrint('[fetchOsmApiNodes] URL: $url'); - - final response = await _client.get(Uri.parse(url)); - + + // Enforce max 2 concurrent download threads per OSM API usage policy + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + final http.Response response; + try { + response = await _client.get(Uri.parse(url)); + } finally { + ServiceRateLimiter.release(ServiceType.osmEditingApi); + } + if (response.statusCode != 200) { debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}'); throw Exception('OSM API error: ${response.statusCode} - ${response.body}'); } - + // Parse XML response final document = XmlDocument.parse(response.body); final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults); - + if (nodes.isNotEmpty) { debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes'); } - + // Don't report success here - let the top level handle it return nodes; - + } catch (e) { debugPrint('[fetchOsmApiNodes] Exception: $e'); - + // Don't report status here - let the top level handle it rethrow; // Re-throw to let caller handle } diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 003d9a09..5113134d 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -1,7 +1,11 @@ import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:flutter/foundation.dart' show visibleForTesting; + import '../offline_area_service.dart'; import '../offline_areas/offline_area_models.dart'; -import '../offline_areas/offline_tile_utils.dart'; import '../../app_state.dart'; /// Fetch a tile from the newest offline area that matches the given provider, or throw if not found. @@ -19,7 +23,7 @@ Future> fetchLocalTile({ final appState = AppState.instance; final currentProviderId = providerId ?? appState.selectedTileProvider?.id; final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id; - + final offlineService = OfflineAreaService(); await offlineService.ensureInitialized(); final areas = offlineService.offlineAreas; @@ -28,20 +32,21 @@ Future> fetchLocalTile({ for (final area in areas) { if (area.status != OfflineAreaStatus.complete) continue; if (z < area.minZoom || z > area.maxZoom) continue; - + // Only consider areas that match the current provider/type if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue; - // Get tile coverage for area at this zoom only - final coveredTiles = computeTileList(area.bounds, z, z); - final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y); - if (hasTile) { - final tilePath = _tilePath(area.directory, z, x, y); - final file = File(tilePath); - if (await file.exists()) { - final stat = await file.stat(); - candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); - } + // O(1) bounds check instead of enumerating all tiles at this zoom level + if (!tileInBounds(area.bounds, z, x, y)) continue; + + final tilePath = _tilePath(area.directory, z, x, y); + final file = File(tilePath); + try { + final stat = await file.stat(); + if (stat.type == FileSystemEntityType.notFound) continue; + candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified)); + } on FileSystemException { + continue; } } if (candidates.isEmpty) { @@ -51,6 +56,34 @@ Future> fetchLocalTile({ return await candidates.first.file.readAsBytes(); } +/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds. +/// +/// Uses the same Mercator projection math as [latLonToTile] in +/// offline_tile_utils.dart, but only computes the bounding tile range +/// instead of enumerating every tile at that zoom level. +/// +/// Note: Y axis is inverted in tile coordinates — north = lower Y. +@visibleForTesting +bool tileInBounds(LatLngBounds bounds, int z, int x, int y) { + final n = pow(2.0, z); + final west = bounds.west; + final east = bounds.east; + final north = bounds.north; + final south = bounds.south; + + final minX = ((west + 180.0) / 360.0 * n).floor(); + final maxX = ((east + 180.0) / 360.0 * n).floor(); + // North → lower Y (Mercator projection inverts latitude) + final minY = ((1.0 - log(tan(north * pi / 180.0) + + 1.0 / cos(north * pi / 180.0)) / + pi) / 2.0 * n).floor(); + final maxY = ((1.0 - log(tan(south * pi / 180.0) + + 1.0 / cos(south * pi / 180.0)) / + pi) / 2.0 * n).floor(); + + return x >= minX && x <= maxX && y >= minY && y <= maxY; +} + String _tilePath(String areaDir, int z, int x, int y) => '$areaDir/tiles/$z/$x/$y.png'; diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 93008256..d458d760 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -33,14 +33,37 @@ class OfflineAreaService { if (!_initialized) { return false; // No offline areas loaded yet } - - return _areas.any((area) => + + return _areas.any((area) => area.status == OfflineAreaStatus.complete && area.tileProviderId == providerId && area.tileTypeId == tileTypeId ); } + + /// Like [hasOfflineAreasForProvider] but also checks that at least one area + /// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the + /// offline-first path for tiles that will never be found locally. + bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) { + if (!_initialized) return false; + return _areas.any((area) => + area.status == OfflineAreaStatus.complete && + area.tileProviderId == providerId && + area.tileTypeId == tileTypeId && + zoom >= area.minZoom && + zoom <= area.maxZoom + ); + } + /// Reset service state and inject areas for unit tests. + @visibleForTesting + void setAreasForTesting(List areas) { + _areas + ..clear() + ..addAll(areas); + _initialized = true; + } + /// Cancel all active downloads (used when enabling offline mode) Future cancelActiveDownloads() async { final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList(); @@ -213,7 +236,7 @@ class OfflineAreaService { area = OfflineArea( id: id, name: name ?? area?.name ?? '', - bounds: bounds, + bounds: normalizeBounds(bounds), minZoom: minZoom, maxZoom: maxZoom, directory: directory, diff --git a/lib/services/offline_areas/offline_area_models.dart b/lib/services/offline_areas/offline_area_models.dart index 61906e74..38285c7d 100644 --- a/lib/services/offline_areas/offline_area_models.dart +++ b/lib/services/offline_areas/offline_area_models.dart @@ -1,6 +1,7 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import '../../models/osm_node.dart'; +import 'offline_tile_utils.dart' show normalizeBounds; /// Status of an offline area enum OfflineAreaStatus { downloading, complete, error, cancelled } @@ -71,10 +72,10 @@ class OfflineArea { }; static OfflineArea fromJson(Map json) { - final bounds = LatLngBounds( + final bounds = normalizeBounds(LatLngBounds( LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']), LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']), - ); + )); return OfflineArea( id: json['id'], name: json['name'] ?? '', diff --git a/lib/services/offline_areas/offline_tile_utils.dart b/lib/services/offline_areas/offline_tile_utils.dart index b3da9773..7b283f47 100644 --- a/lib/services/offline_areas/offline_tile_utils.dart +++ b/lib/services/offline_areas/offline_tile_utils.dart @@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds; /// Utility for tile calculations and lat/lon conversions for OSM offline logic -Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { - Set> tiles = {}; +/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero) +/// spans are expanded by epsilon. Call this before storing bounds so that +/// `tileInBounds` and [computeTileList] see consistent corner ordering. +LatLngBounds normalizeBounds(LatLngBounds bounds) { const double epsilon = 1e-7; - double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); - double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); - double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); - double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); - // Expand degenerate/flat areas a hair + var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude); + var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude); + var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude); + var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude); if ((latMax - latMin).abs() < epsilon) { latMin -= epsilon; latMax += epsilon; @@ -20,6 +21,16 @@ Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { lonMin -= epsilon; lonMax += epsilon; } + return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax)); +} + +Set> computeTileList(LatLngBounds bounds, int zMin, int zMax) { + Set> tiles = {}; + final normalized = normalizeBounds(bounds); + final double latMin = normalized.south; + final double latMax = normalized.north; + final double lonMin = normalized.west; + final double lonMax = normalized.east; for (int z = zMin; z <= zMax; z++) { final n = pow(2, z).toInt(); final minTileRaw = latLonToTileRaw(latMin, lonMin, z); diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart new file mode 100644 index 00000000..cdbce718 --- /dev/null +++ b/lib/services/provider_tile_cache_manager.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'provider_tile_cache_store.dart'; +import 'service_policy.dart'; + +/// Factory and registry for per-provider [ProviderTileCacheStore] instances. +/// +/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`. +/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to +/// resolve the platform cache directory. After init, [getOrCreate] is +/// synchronous — the cache store lazily creates its directory on first write. +class ProviderTileCacheManager { + static final Map _stores = {}; + static String? _baseCacheDir; + + /// Resolve the platform cache directory. Call once at startup. + static Future init() async { + if (_baseCacheDir != null) return; + final cacheDir = await getApplicationCacheDirectory(); + _baseCacheDir = p.join(cacheDir.path, 'tile_cache'); + } + + /// Whether the manager has been initialized. + static bool get isInitialized => _baseCacheDir != null; + + /// Get or create a cache store for a specific provider/tile type combination. + /// + /// Synchronous after [init] has been called. The cache store lazily creates + /// its directory on first write. + static ProviderTileCacheStore getOrCreate({ + required String providerId, + required String tileTypeId, + required ServicePolicy policy, + int? maxCacheBytes, + }) { + if (_baseCacheDir == null) { + throw StateError( + 'ProviderTileCacheManager.init() must be called before getOrCreate()', + ); + } + + final key = '$providerId/$tileTypeId'; + if (_stores.containsKey(key)) return _stores[key]!; + + final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId); + + final store = ProviderTileCacheStore( + cacheDirectory: cacheDir, + maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024, + overrideFreshAge: policy.minCacheTtl, + ); + + _stores[key] = store; + return store; + } + + /// Delete a specific provider's cache directory and remove the store. + static Future deleteCache(String providerId, String tileTypeId) async { + final key = '$providerId/$tileTypeId'; + final store = _stores.remove(key); + if (store != null) { + await store.clear(); + } else if (_baseCacheDir != null) { + final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId)); + if (await cacheDir.exists()) { + await cacheDir.delete(recursive: true); + } + } + } + + /// Get estimated cache sizes for all active stores. + /// + /// Returns a map of `providerId/tileTypeId` → size in bytes. + static Future> getCacheSizes() async { + final sizes = {}; + for (final entry in _stores.entries) { + sizes[entry.key] = await entry.value.estimatedSizeBytes; + } + return sizes; + } + + /// Remove a store from the registry (e.g., when a provider is disposed). + static void unregister(String providerId, String tileTypeId) { + _stores.remove('$providerId/$tileTypeId'); + } + + /// Clear all stores and reset the registry (for testing). + @visibleForTesting + static Future resetAll() async { + for (final store in _stores.values) { + await store.clear(); + } + _stores.clear(); + _baseCacheDir = null; + } + + /// Set the base cache directory directly (for testing). + @visibleForTesting + static void setBaseCacheDir(String dir) { + _baseCacheDir = dir; + } +} diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart new file mode 100644 index 00000000..192a13a8 --- /dev/null +++ b/lib/services/provider_tile_cache_store.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:path/path.dart' as p; +import 'package:uuid/uuid.dart'; + +/// Per-provider tile cache implementing flutter_map's [MapCachingProvider]. +/// +/// Each instance manages an isolated cache directory with: +/// - Deterministic UUID v5 key generation from tile URLs +/// - Optional TTL override from [ServicePolicy.minCacheTtl] +/// - Configurable max cache size with oldest-modified eviction +/// +/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON +/// metadata containing staleAt, lastModified, etag). +class ProviderTileCacheStore implements MapCachingProvider { + final String cacheDirectory; + final int maxCacheBytes; + final Duration? overrideFreshAge; + + static const _uuid = Uuid(); + + /// Running estimate of cache size in bytes. Initialized lazily on first + /// [putTile] call to avoid blocking construction. + int? _estimatedSize; + + /// Throttle: don't re-scan more than once per minute. + DateTime? _lastPruneCheck; + + /// One-shot latch for lazy directory creation (safe under concurrent calls). + Completer? _directoryReady; + + /// Guard against concurrent eviction runs. + bool _isEvicting = false; + + ProviderTileCacheStore({ + required this.cacheDirectory, + this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default + this.overrideFreshAge, + }); + + @override + bool get isSupported => true; + + @override + Future getTile(String url) async { + final key = keyFor(url); + final tileFile = File(p.join(cacheDirectory, '$key.tile')); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + try { + final bytes = await tileFile.readAsBytes(); + final metaJson = json.decode(await metaFile.readAsString()) + as Map; + + final metadata = CachedMapTileMetadata( + staleAt: DateTime.fromMillisecondsSinceEpoch( + metaJson['staleAt'] as int, + isUtc: true, + ), + lastModified: metaJson['lastModified'] != null + ? DateTime.fromMillisecondsSinceEpoch( + metaJson['lastModified'] as int, + isUtc: true, + ) + : null, + etag: metaJson['etag'] as String?, + ); + + return (bytes: bytes, metadata: metadata); + } on PathNotFoundException { + return null; + } catch (e) { + throw CachedMapTileReadFailure( + url: url, + description: 'Failed to read cached tile', + originalError: e, + ); + } + } + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) async { + await _ensureDirectory(); + + final key = keyFor(url); + final tileFile = File(p.join(cacheDirectory, '$key.tile')); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + // Apply minimum TTL override if configured (e.g., OSM 7-day minimum). + // Use the later of server-provided staleAt and our minimum to avoid + // accidentally shortening a longer server-provided freshness lifetime. + final effectiveMetadata = overrideFreshAge != null + ? (() { + final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!); + final staleAt = metadata.staleAt.isAfter(overrideStaleAt) + ? metadata.staleAt + : overrideStaleAt; + return CachedMapTileMetadata( + staleAt: staleAt, + lastModified: metadata.lastModified, + etag: metadata.etag, + ); + })() + : metadata; + + final metaJson = json.encode({ + 'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch, + 'lastModified': + effectiveMetadata.lastModified?.millisecondsSinceEpoch, + 'etag': effectiveMetadata.etag, + }); + + // Write .tile before .meta: if we crash between the two writes, the + // read path's both-must-exist check sees a miss rather than an orphan .meta. + if (bytes != null) { + await tileFile.writeAsBytes(bytes); + } + await metaFile.writeAsString(metaJson); + + // Reset size estimate so it resyncs from disk on next check. + // This avoids drift from overwrites where the old size isn't subtracted. + _estimatedSize = null; + + // Schedule lazy size check + _scheduleEvictionCheck(); + } + + /// Ensure the cache directory exists (lazy creation on first write). + /// + /// Uses a Completer latch so concurrent callers share a single create(). + /// Safe under Dart's single-threaded event loop: the null check and + /// assignment happen in the same synchronous block with no `await` + /// between them, so no other microtask can interleave. + Future _ensureDirectory() { + if (_directoryReady == null) { + final completer = Completer(); + _directoryReady = completer; + Directory(cacheDirectory).create(recursive: true).then( + (_) => completer.complete(), + onError: (Object error, StackTrace stackTrace) { + // Reset latch on error so later calls can retry directory creation. + if (identical(_directoryReady, completer)) { + _directoryReady = null; + } + completer.completeError(error, stackTrace); + }, + ); + } + return _directoryReady!.future; + } + + /// Generate a cache key from URL using UUID v5 (same as flutter_map built-in). + @visibleForTesting + static String keyFor(String url) => _uuid.v5(Namespace.url.value, url); + + /// Estimate total cache size (lazy, first call scans directory). + Future _getEstimatedSize() async { + if (_estimatedSize != null) return _estimatedSize!; + + final dir = Directory(cacheDirectory); + if (!await dir.exists()) { + _estimatedSize = 0; + return 0; + } + + var total = 0; + await for (final entity in dir.list()) { + if (entity is File) { + total += await entity.length(); + } + } + _estimatedSize = total; + return total; + } + + /// Schedule eviction if we haven't checked recently. + void _scheduleEvictionCheck() { + final now = DateTime.now(); + if (_lastPruneCheck != null && + now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) { + return; + } + _lastPruneCheck = now; + + // Fire-and-forget: eviction is best-effort background work. + // _estimatedSize may be momentarily stale between eviction start and + // completion, but this is acceptable — the guard only needs to be + // approximately correct to prevent unbounded growth, and the throttle + // ensures we re-check within a minute. + // ignore: discarded_futures + _evictIfNeeded(); + } + + /// Evict oldest-modified tiles if cache exceeds size limit. + /// + /// Sorts by file mtime (oldest first), not by last access — true LRU would + /// require touching files on every [getTile] read, adding I/O on the hot + /// path. In practice write-recency tracks usage well because tiles are + /// immutable and flutter_map holds visible tiles in memory. + /// + /// Guarded by [_isEvicting] to prevent concurrent runs from corrupting + /// [_estimatedSize]. + Future _evictIfNeeded() async { + if (_isEvicting) return; + _isEvicting = true; + try { + final currentSize = await _getEstimatedSize(); + if (currentSize <= maxCacheBytes) return; + + final dir = Directory(cacheDirectory); + if (!await dir.exists()) return; + + // Collect all files, separating .tile and .meta for eviction + orphan cleanup. + final tileFiles = []; + final metaFiles = {}; + await for (final entity in dir.list()) { + if (entity is File) { + if (entity.path.endsWith('.tile')) { + tileFiles.add(entity); + } else if (entity.path.endsWith('.meta')) { + metaFiles.add(p.basenameWithoutExtension(entity.path)); + } + } + } + + if (tileFiles.isEmpty) return; + + // Sort by modification time, oldest first + final stats = await Future.wait( + tileFiles.map((f) async => (file: f, stat: await f.stat())), + ); + stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified)); + + var freedBytes = 0; + final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80% + final evictedKeys = {}; + + for (final entry in stats) { + if (currentSize - freedBytes <= targetSize) break; + + final key = p.basenameWithoutExtension(entry.file.path); + final metaFile = File(p.join(cacheDirectory, '$key.meta')); + + try { + await entry.file.delete(); + freedBytes += entry.stat.size; + evictedKeys.add(key); + if (await metaFile.exists()) { + final metaStat = await metaFile.stat(); + await metaFile.delete(); + freedBytes += metaStat.size; + } + } catch (e) { + debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e'); + } + } + + // Clean up orphan .meta files (no matching .tile file). + // Exclude keys we just evicted — their .tile is gone so they're orphans. + final remainingTileKeys = tileFiles + .map((f) => p.basenameWithoutExtension(f.path)) + .toSet() + ..removeAll(evictedKeys); + for (final metaKey in metaFiles) { + if (!remainingTileKeys.contains(metaKey)) { + try { + final orphan = File(p.join(cacheDirectory, '$metaKey.meta')); + final orphanStat = await orphan.stat(); + await orphan.delete(); + freedBytes += orphanStat.size; + } catch (_) { + // Best-effort cleanup + } + } + } + + _estimatedSize = currentSize - freedBytes; + debugPrint( + '[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB ' + 'from $cacheDirectory', + ); + } catch (e) { + debugPrint('[ProviderTileCacheStore] Eviction error: $e'); + } finally { + _isEvicting = false; + } + } + + /// Delete all cached tiles in this store's directory. + Future clear() async { + final dir = Directory(cacheDirectory); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _estimatedSize = null; + _directoryReady = null; // Allow lazy re-creation + _lastPruneCheck = null; // Reset throttle so next write can trigger eviction + } + + /// Get the current estimated cache size in bytes. + Future get estimatedSizeBytes => _getEstimatedSize(); + + /// Force an eviction check, bypassing the throttle. + /// Only exposed for testing — production code uses [_scheduleEvictionCheck]. + @visibleForTesting + Future forceEviction() => _evictIfNeeded(); +} diff --git a/lib/services/search_service.dart b/lib/services/search_service.dart index 8ba0e40e..64597670 100644 --- a/lib/services/search_service.dart +++ b/lib/services/search_service.dart @@ -5,13 +5,31 @@ import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; import 'http_client.dart'; +import 'service_policy.dart'; + +/// Cached search result with expiry. +class _CachedResult { + final List results; + final DateTime cachedAt; + + _CachedResult(this.results) : cachedAt = DateTime.now(); + + bool get isExpired => + DateTime.now().difference(cachedAt) > const Duration(minutes: 5); +} class SearchService { static const String _baseUrl = 'https://nominatim.openstreetmap.org'; static const int _maxResults = 5; static const Duration _timeout = Duration(seconds: 10); final _client = UserAgentClient(); - + + /// Client-side result cache, keyed by normalized query + viewbox. + /// Required by Nominatim usage policy. Static so all SearchService + /// instances share the cache and don't generate redundant requests. + static final Map _resultCache = {}; + + /// Search for places using Nominatim geocoding service Future> search(String query, {LatLngBounds? viewbox}) async { if (query.trim().isEmpty) { @@ -27,23 +45,23 @@ class SearchService { // Otherwise, use Nominatim API return await _searchNominatim(query.trim(), viewbox: viewbox); } - + /// Try to parse various coordinate formats SearchResult? _tryParseCoordinates(String query) { // Remove common separators and normalize final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim(); final parts = normalized.split(RegExp(r'\s+')); - + if (parts.length != 2) return null; - + final lat = double.tryParse(parts[0]); final lon = double.tryParse(parts[1]); - + if (lat == null || lon == null) return null; - + // Basic validation for Earth coordinates if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; - + return SearchResult( displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}', coordinates: LatLng(lat, lon), @@ -51,17 +69,17 @@ class SearchService { type: 'point', ); } - - /// Search using Nominatim API - Future> _searchNominatim(String query, {LatLngBounds? viewbox}) async { - final params = { - 'q': query, - 'format': 'json', - 'limit': _maxResults.toString(), - 'addressdetails': '1', - 'extratags': '1', - }; + /// Search using Nominatim API with rate limiting and result caching. + /// + /// Nominatim usage policy requires: + /// - Max 1 request per second + /// - Client-side result caching + /// - No auto-complete / typeahead + Future> _searchNominatim(String query, {LatLngBounds? viewbox}) async { + // Normalize the viewbox first so both the cache key and the request + // params use the same effective values (rounded + min-span expanded). + String? viewboxParam; if (viewbox != null) { double round1(double v) => (v * 10).round() / 10; var west = round1(viewbox.west); @@ -80,31 +98,83 @@ class SearchService { north = mid + 0.25; } - params['viewbox'] = '$west,$north,$east,$south'; + viewboxParam = '$west,$north,$east,$south'; + } + + final cacheKey = _buildCacheKey(query, viewboxParam); + + // Check cache first (Nominatim policy requires client-side caching) + final cached = _resultCache[cacheKey]; + if (cached != null && !cached.isExpired) { + debugPrint('[SearchService] Cache hit for "$query"'); + return cached.results; + } + + final params = { + 'q': query, + 'format': 'json', + 'limit': _maxResults.toString(), + 'addressdetails': '1', + 'extratags': '1', + }; + + if (viewboxParam != null) { + params['viewbox'] = viewboxParam; } final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params); - + debugPrint('[SearchService] Searching Nominatim: $uri'); - + + // Rate limit: max 1 request/sec per Nominatim policy + await ServiceRateLimiter.acquire(ServiceType.nominatim); try { final response = await _client.get(uri).timeout(_timeout); - + if (response.statusCode != 200) { throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}'); } - + final List jsonResults = json.decode(response.body); final results = jsonResults .map((json) => SearchResult.fromNominatim(json as Map)) .toList(); - + + // Cache the results + _resultCache[cacheKey] = _CachedResult(results); + _pruneCache(); + debugPrint('[SearchService] Found ${results.length} results'); return results; - - } catch (e) { + } catch (e, stackTrace) { debugPrint('[SearchService] Search failed: $e'); - throw Exception('Search failed: $e'); + Error.throwWithStackTrace(e, stackTrace); + } finally { + ServiceRateLimiter.release(ServiceType.nominatim); + } + } + + /// Build a cache key from the query and the already-normalized viewbox string. + /// + /// The viewbox should be the same `west,north,east,south` string sent to + /// Nominatim (after rounding and min-span expansion) so that requests with + /// different raw bounds but the same effective viewbox share a cache entry. + String _buildCacheKey(String query, String? viewboxParam) { + final normalizedQuery = query.trim().toLowerCase(); + if (viewboxParam == null) return normalizedQuery; + return '$normalizedQuery|$viewboxParam'; + } + + /// Remove expired entries and limit cache size. + void _pruneCache() { + _resultCache.removeWhere((_, cached) => cached.isExpired); + // Limit cache to 50 entries to prevent unbounded growth + if (_resultCache.length > 50) { + final sortedKeys = _resultCache.keys.toList() + ..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt)); + for (final key in sortedKeys.take(_resultCache.length - 50)) { + _resultCache.remove(key); + } } } -} \ No newline at end of file +} diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart new file mode 100644 index 00000000..acf8a246 --- /dev/null +++ b/lib/services/service_policy.dart @@ -0,0 +1,402 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Identifies the type of external service being accessed. +/// Used by [ServicePolicyResolver] to determine the correct compliance policy. +enum ServiceType { + // OSMF official services + osmEditingApi, // api.openstreetmap.org — editing & data queries + osmTileServer, // tile.openstreetmap.org — raster tiles + nominatim, // nominatim.openstreetmap.org — geocoding + overpass, // overpass-api.de — read-only data queries + tagInfo, // taginfo.openstreetmap.org — tag metadata + + // Third-party tile services + bingTiles, // *.tiles.virtualearth.net + mapboxTiles, // api.mapbox.com + + // Everything else + custom, // user's own infrastructure / unknown +} + +/// Defines the compliance rules for a specific service. +/// +/// Each policy captures the rate limits, caching requirements, offline +/// permissions, and attribution obligations mandated by the service operator. +/// When the app talks to official OSMF infrastructure the strict policies +/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom] +/// provides permissive defaults. +class ServicePolicy { + /// Max concurrent HTTP connections to this service. + /// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114). + final int maxConcurrentRequests; + + /// Minimum interval between consecutive requests. Null means no rate limit. + final Duration? minRequestInterval; + + /// Whether this endpoint permits offline/bulk downloading of tiles. + final bool allowsOfflineDownload; + + /// Whether the client must cache responses (e.g., Nominatim policy). + final bool requiresClientCaching; + + /// Minimum cache TTL to enforce regardless of server headers. + /// Null means "use server-provided max-age as-is". + final Duration? minCacheTtl; + + /// License/attribution URL to display in the attribution dialog. + /// Null means no special attribution link is needed. + final String? attributionUrl; + + const ServicePolicy({ + this.maxConcurrentRequests = 8, + this.minRequestInterval, + this.allowsOfflineDownload = true, + this.requiresClientCaching = false, + this.minCacheTtl, + this.attributionUrl, + }); + + /// OSM editing API (api.openstreetmap.org) + /// Policy: max 2 concurrent download threads. + /// https://operations.osmfoundation.org/policies/api/ + const ServicePolicy.osmEditingApi() + : maxConcurrentRequests = 2, + minRequestInterval = null, + allowsOfflineDownload = true, // n/a for API + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// OSM tile server (tile.openstreetmap.org) + /// Policy: min 7-day cache, must honor cache headers. + /// Concurrency managed by flutter_map's NetworkTileProvider. + /// https://operations.osmfoundation.org/policies/tiles/ + const ServicePolicy.osmTileServer() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, + requiresClientCaching = true, + minCacheTtl = const Duration(days: 7), + attributionUrl = 'https://www.openstreetmap.org/copyright'; + + /// Nominatim geocoding (nominatim.openstreetmap.org) + /// Policy: max 1 req/sec, single machine only, results must be cached. + /// https://operations.osmfoundation.org/policies/nominatim/ + const ServicePolicy.nominatim() + : maxConcurrentRequests = 1, + minRequestInterval = const Duration(seconds: 1), + allowsOfflineDownload = true, // n/a for geocoding + requiresClientCaching = true, + minCacheTtl = null, + attributionUrl = 'https://www.openstreetmap.org/copyright'; + + /// Overpass API (overpass-api.de) + /// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore. + const ServicePolicy.overpass() + : maxConcurrentRequests = 0, // managed by NodeDataManager + minRequestInterval = null, // managed by NodeDataManager + allowsOfflineDownload = true, // n/a for data queries + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// TagInfo API (taginfo.openstreetmap.org) + const ServicePolicy.tagInfo() + : maxConcurrentRequests = 2, + minRequestInterval = null, + allowsOfflineDownload = true, // n/a + requiresClientCaching = true, // already cached in NSIService + minCacheTtl = null, + attributionUrl = null; + + /// Bing Maps tiles (*.tiles.virtualearth.net) + const ServicePolicy.bingTiles() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, // check Bing ToS separately + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// Mapbox tiles (api.mapbox.com) + const ServicePolicy.mapboxTiles() + : maxConcurrentRequests = 0, // managed by flutter_map + minRequestInterval = null, + allowsOfflineDownload = true, // permitted with valid token + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + + /// Custom/self-hosted service — permissive defaults. + const ServicePolicy.custom({ + int maxConcurrent = 8, + bool allowsOffline = true, + Duration? minInterval, + String? attribution, + }) : maxConcurrentRequests = maxConcurrent, + minRequestInterval = minInterval, + allowsOfflineDownload = allowsOffline, + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = attribution; + + @override + String toString() => 'ServicePolicy(' + 'maxConcurrent: $maxConcurrentRequests, ' + 'minInterval: $minRequestInterval, ' + 'offlineDownload: $allowsOfflineDownload, ' + 'clientCaching: $requiresClientCaching, ' + 'minCacheTtl: $minCacheTtl, ' + 'attributionUrl: $attributionUrl)'; +} + +/// Resolves URLs and tile providers to their applicable [ServicePolicy]. +/// +/// Built-in patterns cover all OSMF official services and common third-party +/// tile providers. Custom overrides can be registered for self-hosted endpoints +/// via [registerCustomPolicy]. +class ServicePolicyResolver { + /// Host → ServiceType mapping for known services. + static final Map _hostPatterns = { + 'api.openstreetmap.org': ServiceType.osmEditingApi, + 'api06.dev.openstreetmap.org': ServiceType.osmEditingApi, + 'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi, + 'tile.openstreetmap.org': ServiceType.osmTileServer, + 'nominatim.openstreetmap.org': ServiceType.nominatim, + 'overpass-api.de': ServiceType.overpass, + 'taginfo.openstreetmap.org': ServiceType.tagInfo, + 'tiles.virtualearth.net': ServiceType.bingTiles, + 'api.mapbox.com': ServiceType.mapboxTiles, + }; + + /// ServiceType → policy mapping. + static final Map _policies = { + ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(), + ServiceType.osmTileServer: const ServicePolicy.osmTileServer(), + ServiceType.nominatim: const ServicePolicy.nominatim(), + ServiceType.overpass: const ServicePolicy.overpass(), + ServiceType.tagInfo: const ServicePolicy.tagInfo(), + ServiceType.bingTiles: const ServicePolicy.bingTiles(), + ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(), + ServiceType.custom: const ServicePolicy(), + }; + + /// Custom host overrides registered at runtime (for self-hosted services). + static final Map _customOverrides = {}; + + /// Resolve a URL to its applicable [ServicePolicy]. + /// + /// Checks custom overrides first, then built-in host patterns. Falls back + /// to [ServicePolicy.custom] for unrecognized hosts. + static ServicePolicy resolve(String url) { + final host = _extractHost(url); + if (host == null) return const ServicePolicy(); + + // Check custom overrides first (exact or subdomain matching) + for (final entry in _customOverrides.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return entry.value; + } + } + + // Check built-in patterns (support subdomain matching) + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return _policies[entry.value] ?? const ServicePolicy(); + } + } + + return const ServicePolicy(); + } + + /// Resolve a URL to its [ServiceType]. + /// + /// Returns [ServiceType.custom] for unrecognized hosts. + static ServiceType resolveType(String url) { + final host = _extractHost(url); + if (host == null) return ServiceType.custom; + + // Check custom overrides first — a registered custom policy means + // the host is treated as ServiceType.custom with custom rules. + for (final entry in _customOverrides.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return ServiceType.custom; + } + } + + for (final entry in _hostPatterns.entries) { + if (host == entry.key || host.endsWith('.${entry.key}')) { + return entry.value; + } + } + + return ServiceType.custom; + } + + /// Look up the [ServicePolicy] for a known [ServiceType]. + static ServicePolicy resolveByType(ServiceType type) => + _policies[type] ?? const ServicePolicy(); + + /// Register a custom policy override for a host pattern. + /// + /// Use this to configure self-hosted services: + /// ```dart + /// ServicePolicyResolver.registerCustomPolicy( + /// 'tiles.myserver.com', + /// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20), + /// ); + /// ``` + static void registerCustomPolicy(String hostPattern, ServicePolicy policy) { + _customOverrides[hostPattern] = policy; + } + + /// Remove a custom policy override. + static void removeCustomPolicy(String hostPattern) { + _customOverrides.remove(hostPattern); + } + + /// Clear all custom policy overrides (useful for testing). + static void clearCustomPolicies() { + _customOverrides.clear(); + } + + /// Extract the host from a URL or URL template. + static String? _extractHost(String url) { + // Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + // and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...' + try { + // Strip template variables from subdomain part for parsing + final cleaned = url + .replaceAll(RegExp(r'\{0_3\}'), '0') + .replaceAll(RegExp(r'\{1_4\}'), '1') + .replaceAll(RegExp(r'\{quadkey\}'), 'quadkey') + .replaceAll(RegExp(r'\{z\}'), '0') + .replaceAll(RegExp(r'\{x\}'), '0') + .replaceAll(RegExp(r'\{y\}'), '0') + .replaceAll(RegExp(r'\{api_key\}'), 'key'); + return Uri.parse(cleaned).host.toLowerCase(); + } catch (_) { + return null; + } + } +} + +/// Reusable per-service rate limiter and concurrency controller. +/// +/// Enforces the rate limits and concurrency constraints defined in each +/// service's [ServicePolicy]. Call [acquire] before making a request and +/// [release] after the request completes. +/// +/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0 +/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere +/// (flutter_map, PR #114) are passed through without blocking. +class ServiceRateLimiter { + /// Injectable clock for testing. Defaults to [DateTime.now]. + /// + /// Override with a deterministic clock (e.g. from `FakeAsync`) so tests + /// don't rely on wall-clock time and stay fast and stable under CI load. + @visibleForTesting + static DateTime Function() clock = DateTime.now; + + /// Per-service timestamps of the last acquired request slot / request start + /// (used for rate limiting in [acquire], not updated on completion). + static final Map _lastRequestTime = {}; + + /// Per-service concurrency semaphores. + static final Map _semaphores = {}; + + /// Acquire a slot: wait for rate limit compliance, then take a connection slot. + /// + /// Blocks if: + /// 1. The minimum interval between requests hasn't elapsed yet, or + /// 2. All concurrent connection slots are in use. + static Future acquire(ServiceType service) async { + final policy = ServicePolicyResolver.resolveByType(service); + + // Concurrency: acquire a semaphore slot first so that at most + // [policy.maxConcurrentRequests] callers proceed concurrently. + // The min-interval check below is only race-free when + // maxConcurrentRequests == 1 (currently only Nominatim). For services + // with higher concurrency the interval is approximate, which is + // acceptable — their policies don't specify a min interval. + _Semaphore? semaphore; + if (policy.maxConcurrentRequests > 0) { + semaphore = _semaphores.putIfAbsent( + service, + () => _Semaphore(policy.maxConcurrentRequests), + ); + await semaphore.acquire(); + } + + try { + // Rate limit: wait if we sent a request too recently + if (policy.minRequestInterval != null) { + final lastTime = _lastRequestTime[service]; + if (lastTime != null) { + final elapsed = clock().difference(lastTime); + final remaining = policy.minRequestInterval! - elapsed; + if (remaining > Duration.zero) { + debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms'); + await Future.delayed(remaining); + } + } + } + + // Record request time + _lastRequestTime[service] = clock(); + } catch (_) { + // Release the semaphore slot if the rate-limit delay fails, + // to avoid permanently leaking a slot. + semaphore?.release(); + rethrow; + } + } + + /// Release a connection slot after request completes. + static void release(ServiceType service) { + _semaphores[service]?.release(); + } + + /// Reset all rate limiter state (for testing). + @visibleForTesting + static void reset() { + _lastRequestTime.clear(); + _semaphores.clear(); + clock = DateTime.now; + } +} + +/// Simple async counting semaphore for concurrency limiting. +class _Semaphore { + final int _maxCount; + int _currentCount = 0; + final List> _waiters = []; + + _Semaphore(this._maxCount); + + Future acquire() async { + if (_currentCount < _maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _waiters.add(completer); + await completer.future; + } + + void release() { + if (_waiters.isNotEmpty) { + final next = _waiters.removeAt(0); + next.complete(); + } else if (_currentCount > 0) { + _currentCount--; + } else { + throw StateError( + 'Semaphore.release() called more times than acquire(); ' + 'currentCount is already zero.', + ); + } + } +} diff --git a/lib/state/profile_state.dart b/lib/state/profile_state.dart index 41493ab2..8b2c5c9e 100644 --- a/lib/state/profile_state.dart +++ b/lib/state/profile_state.dart @@ -6,9 +6,17 @@ import '../services/profile_service.dart'; class ProfileState extends ChangeNotifier { static const String _enabledPrefsKey = 'enabled_profiles'; + static const String _profileOrderPrefsKey = 'profile_order'; final List _profiles = []; final Set _enabled = {}; + List _customOrder = []; // List of profile IDs in user's preferred order + + // Test-only getters for accessing private state + @visibleForTesting + List get internalProfiles => _profiles; + @visibleForTesting + Set get internalEnabled => _enabled; // Callback for when a profile is deleted (used to clear stale sessions) void Function(NodeProfile)? _onProfileDeleted; @@ -18,10 +26,10 @@ class ProfileState extends ChangeNotifier { } // Getters - List get profiles => List.unmodifiable(_profiles); + List get profiles => List.unmodifiable(_getOrderedProfiles()); bool isEnabled(NodeProfile p) => _enabled.contains(p); List get enabledProfiles => - _profiles.where(isEnabled).toList(growable: false); + _getOrderedProfiles().where(isEnabled).toList(growable: false); // Initialize profiles from built-in and custom sources Future init({bool addDefaults = false}) async { @@ -34,7 +42,7 @@ class ProfileState extends ChangeNotifier { await ProfileService().save(_profiles); } - // Load enabled profile IDs from prefs + // Load enabled profile IDs and custom order from prefs final prefs = await SharedPreferences.getInstance(); final enabledIds = prefs.getStringList(_enabledPrefsKey); if (enabledIds != null && enabledIds.isNotEmpty) { @@ -44,6 +52,9 @@ class ProfileState extends ChangeNotifier { // By default, all are enabled _enabled.addAll(_profiles); } + + // Load custom order + _customOrder = prefs.getStringList(_profileOrderPrefsKey) ?? []; } void toggleProfile(NodeProfile p, bool e) { @@ -70,7 +81,7 @@ class ProfileState extends ChangeNotifier { _enabled.add(p); _saveEnabledProfiles(); } - ProfileService().save(_profiles); + _saveProfilesToStorage(); notifyListeners(); } @@ -84,7 +95,7 @@ class ProfileState extends ChangeNotifier { _enabled.add(builtIn); } _saveEnabledProfiles(); - ProfileService().save(_profiles); + _saveProfilesToStorage(); // Notify about profile deletion so other parts can clean up _onProfileDeleted?.call(p); @@ -92,12 +103,79 @@ class ProfileState extends ChangeNotifier { notifyListeners(); } + // Reorder profiles (for drag-and-drop in settings) + void reorderProfiles(int oldIndex, int newIndex) { + final orderedProfiles = _getOrderedProfiles(); + + // Standard Flutter reordering logic + if (oldIndex < newIndex) { + newIndex -= 1; + } + final item = orderedProfiles.removeAt(oldIndex); + orderedProfiles.insert(newIndex, item); + + // Update custom order with new sequence + _customOrder = orderedProfiles.map((p) => p.id).toList(); + _saveCustomOrder(); + notifyListeners(); + } + + // Get profiles in custom order, with unordered profiles at the end + List _getOrderedProfiles() { + if (_customOrder.isEmpty) { + return List.from(_profiles); + } + + final ordered = []; + final profilesById = {for (final p in _profiles) p.id: p}; + + // Add profiles in custom order + for (final id in _customOrder) { + final profile = profilesById[id]; + if (profile != null) { + ordered.add(profile); + profilesById.remove(id); + } + } + + // Add any remaining profiles that weren't in the custom order + ordered.addAll(profilesById.values); + + return ordered; + } + // Save enabled profile IDs to disk Future _saveEnabledProfiles() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList( - _enabledPrefsKey, - _enabled.map((p) => p.id).toList(), - ); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _enabledPrefsKey, + _enabled.map((p) => p.id).toList(), + ); + } catch (e) { + // Fail gracefully in tests or if SharedPreferences isn't available + debugPrint('[ProfileState] Failed to save enabled profiles: $e'); + } + } + + // Save profiles to storage + Future _saveProfilesToStorage() async { + try { + await ProfileService().save(_profiles); + } catch (e) { + // Fail gracefully in tests or if storage isn't available + debugPrint('[ProfileState] Failed to save profiles: $e'); + } + } + + // Save custom order to disk + Future _saveCustomOrder() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_profileOrderPrefsKey, _customOrder); + } catch (e) { + // Fail gracefully in tests or if SharedPreferences isn't available + debugPrint('[ProfileState] Failed to save custom order: $e'); + } } } \ No newline at end of file diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index c2529b53..7fbc6d09 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import '../models/tile_provider.dart'; import '../dev_config.dart'; +import '../keys.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -41,7 +42,8 @@ class SettingsState extends ChangeNotifier { bool _offlineMode = false; bool _pauseQueueProcessing = false; int _maxNodes = kDefaultMaxNodes; - UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production; + // Default must account for missing secrets (preview builds) even before init() runs + UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.follow; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; @@ -150,8 +152,16 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } - // In production builds, force production mode if development modes are disabled - if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) { + // Override persisted upload mode when the current build configuration + // doesn't support it. This handles two cases: + // 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes + // 2. Production builds — force production (prefs may have sandbox/simulate + // from a previous dev build on the same device) + if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) { + debugPrint('SettingsState: No OSM secrets available, forcing simulate mode'); + _uploadMode = UploadMode.simulate; + await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); + } else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) { debugPrint('SettingsState: Development modes disabled, forcing production mode'); _uploadMode = UploadMode.production; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); @@ -258,11 +268,10 @@ class SettingsState extends ChangeNotifier { } Future setUploadMode(UploadMode mode) async { - // In production builds, only allow production mode - if (!kEnableDevelopmentModes && mode != UploadMode.production) { - debugPrint('SettingsState: Development modes disabled, forcing production mode'); - mode = UploadMode.production; - } + // The upload mode dropdown is only visible when kEnableDevelopmentModes is + // true (gated in osm_account_screen.dart), so no secrets/dev-mode guards + // are needed here. The init() method handles forcing the correct mode on + // startup for production builds and builds without OAuth secrets. _uploadMode = mode; final prefs = await SharedPreferences.getInstance(); diff --git a/lib/widgets/download_area_dialog.dart b/lib/widgets/download_area_dialog.dart index 0adbdb28..a370aaf4 100644 --- a/lib/widgets/download_area_dialog.dart +++ b/lib/widgets/download_area_dialog.dart @@ -262,16 +262,44 @@ class _DownloadAreaDialogState extends State { ElevatedButton( onPressed: isOfflineMode ? null : () async { try { + // Get current tile provider info + final appState = context.read(); + final selectedProvider = appState.selectedTileProvider; + final selectedTileType = appState.selectedTileType; + + // Guard: provider and tile type must be non-null for a + // useful offline area (fetchLocalTile requires exact match). + if (selectedProvider == null || selectedTileType == null) { + if (!context.mounted) return; + final navigator = Navigator.of(context); + navigator.pop(); + showDialog( + context: navigator.context, + builder: (context) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.error, color: Colors.red), + const SizedBox(width: 10), + Text(locService.t('download.title')), + ], + ), + content: Text(locService.t('download.noTileProviderSelected')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.ok')), + ), + ], + ), + ); + return; + } + final id = DateTime.now().toIso8601String().replaceAll(':', '-'); final appDocDir = await OfflineAreaService().getOfflineAreaDir(); if (!context.mounted) return; final dir = "${appDocDir.path}/$id"; - // Get current tile provider info - final appState = context.read(); - final selectedProvider = appState.selectedTileProvider; - final selectedTileType = appState.selectedTileType; - // Fire and forget: don't await download, so dialog closes immediately // ignore: unawaited_futures OfflineAreaService().downloadArea( @@ -282,10 +310,10 @@ class _DownloadAreaDialogState extends State { directory: dir, onProgress: (progress) {}, onComplete: (status) {}, - tileProviderId: selectedProvider?.id, - tileProviderName: selectedProvider?.name, - tileTypeId: selectedTileType?.id, - tileTypeName: selectedTileType?.name, + tileProviderId: selectedProvider.id, + tileProviderName: selectedProvider.name, + tileTypeId: selectedTileType.id, + tileTypeName: selectedTileType.name, ); Navigator.pop(context); showDialog( diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 0d9772f9..5e952bb0 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; @@ -26,16 +27,63 @@ class MapOverlays extends StatelessWidget { this.onSearchPressed, }); - /// Show full attribution text in a dialog + /// Show full attribution text in a dialog with license link. void _showAttributionDialog(BuildContext context, String attribution) { final locService = LocalizationService.instance; + + // Get the license URL from the current tile provider's service policy + final appState = AppState.instance; + final tileType = appState.selectedTileType; + final attributionUrl = tileType?.servicePolicy.attributionUrl; + showDialog( context: context, builder: (context) => AlertDialog( title: Text(locService.t('mapTiles.attribution')), - content: SelectableText( - attribution, - style: const TextStyle(fontSize: 14), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + attribution, + style: const TextStyle(fontSize: 14), + ), + if (attributionUrl != null) ...[ + const SizedBox(height: 12), + Semantics( + link: true, + label: locService.t('mapTiles.openLicense', params: [attributionUrl]), + child: InkWell( + onTap: () async { + try { + final uri = Uri.parse(attributionUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))), + ); + } + } + }, + child: Text( + attributionUrl, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ], ), actions: [ TextButton( @@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget { Positioned( bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom), left: leftPositionWithSafeArea(10, safeArea), - child: GestureDetector( - onTap: () => _showAttributionDialog(context, attribution!), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + child: Semantics( + button: true, + label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]), + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), + child: InkWell( borderRadius: BorderRadius.circular(4), - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - constraints: const BoxConstraints(maxWidth: 250), - child: Text( - attribution!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurface, + onTap: () => _showAttributionDialog(context, attribution!), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Text( + attribution!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), ), - overflow: TextOverflow.ellipsis, - maxLines: 1, ), ), ), diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index 75acc72b..78cda5e2 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -1,68 +1,124 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import '../../models/tile_provider.dart' as models; import '../../services/deflock_tile_provider.dart'; +import '../../services/provider_tile_cache_manager.dart'; -/// Manages tile layer creation, caching, and provider switching. -/// Uses DeFlock's custom tile provider for clean integration. +/// Manages tile layer creation with per-provider caching and provider switching. +/// +/// Each tile provider/type combination gets its own [DeflockTileProvider] +/// instance with isolated caching (separate cache directory, configurable size +/// limit, and policy-driven TTL enforcement). Providers are created lazily on +/// first use and cached for instant switching. class TileLayerManager { - DeflockTileProvider? _tileProvider; + final Map _providers = {}; int _mapRebuildKey = 0; + String? _lastProviderId; String? _lastTileTypeId; bool? _lastOfflineMode; - /// Get the current map rebuild key for cache busting + /// Stream that triggers flutter_map to drop all tiles and reload. + /// Fired after a debounced delay when tile errors are detected. + final StreamController _resetController = + StreamController.broadcast(); + + /// Debounce timer for scheduling a tile reset after errors. + Timer? _retryTimer; + + /// Current retry delay — starts at [_minRetryDelay] and doubles on each + /// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay] + /// when a tile loads successfully. + Duration _retryDelay = const Duration(seconds: 2); + + static const _minRetryDelay = Duration(seconds: 2); + static const _maxRetryDelay = Duration(seconds: 60); + + /// Get the current map rebuild key for cache busting. int get mapRebuildKey => _mapRebuildKey; - /// Initialize the tile layer manager + /// Current retry delay (exposed for testing). + @visibleForTesting + Duration get retryDelay => _retryDelay; + + /// Stream of reset events (exposed for testing). + @visibleForTesting + Stream get resetStream => _resetController.stream; + + /// Initialize the tile layer manager. + /// + /// [ProviderTileCacheManager.init] is called in main() before any widgets + /// build, so this is a no-op retained for API compatibility. void initialize() { - // Don't create tile provider here - create it fresh for each build + // Cache directory is already resolved in main(). } - /// Dispose of resources + /// Dispose of all provider resources. + /// + /// Synchronous to match Flutter's [State.dispose] contract. Calls + /// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP + /// client. (We don't call provider.dispose() here — flutter_map already + /// called it when the TileLayer widget was removed, and it's safe to call + /// again but unnecessary.) void dispose() { - _tileProvider?.dispose(); + _retryTimer?.cancel(); + _resetController.close(); + for (final provider in _providers.values) { + provider.shutdown(); + } + _providers.clear(); } /// Check if cache should be cleared and increment rebuild key if needed. /// Returns true if cache was cleared (map should be rebuilt). bool checkAndClearCacheIfNeeded({ + required String? currentProviderId, required String? currentTileTypeId, required bool currentOfflineMode, }) { bool shouldClear = false; String? reason; - if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) { + if (_lastProviderId != currentProviderId) { + reason = 'provider ($currentProviderId)'; + shouldClear = true; + } else if (_lastTileTypeId != currentTileTypeId) { reason = 'tile type ($currentTileTypeId)'; shouldClear = true; - } else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) { + } else if (_lastOfflineMode != currentOfflineMode) { reason = 'offline mode ($currentOfflineMode)'; shouldClear = true; } if (shouldClear) { - // Force map rebuild with new key to bust flutter_map cache + // Force map rebuild with new key to bust flutter_map cache. + // We don't dispose providers here — they're reusable across switches. _mapRebuildKey++; - // Dispose old provider before creating a fresh one (closes HTTP client) - _tileProvider?.dispose(); - _tileProvider = null; + // Reset backoff so the new provider starts with a clean slate. + // Cancel any pending retry timer — it belongs to the old provider's errors. + _retryDelay = _minRetryDelay; + _retryTimer?.cancel(); debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey'); } + _lastProviderId = currentProviderId; _lastTileTypeId = currentTileTypeId; _lastOfflineMode = currentOfflineMode; return shouldClear; } - /// Clear the tile request queue (call after cache clear) + /// Clear the tile request queue (call after cache clear). + /// + /// In the old architecture this incremented [_mapRebuildKey] a second time + /// to force a rebuild after the provider was disposed and recreated. With + /// per-provider caching, [checkAndClearCacheIfNeeded] already increments the + /// key, so this is now a no-op. Kept for API compatibility with map_view. void clearTileQueue() { - // With NetworkTileProvider, clearing is handled by FlutterMap's internal cache - // We just need to increment the rebuild key to bust the cache - _mapRebuildKey++; - debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey'); + // No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey. } /// Clear tile queue immediately (for zoom changes, etc.) @@ -70,19 +126,85 @@ class TileLayerManager { // No immediate clearing needed — NetworkTileProvider aborts obsolete requests } - /// Clear only tiles that are no longer visible in the current bounds + /// Clear only tiles that are no longer visible in the current bounds. void clearStaleRequests({required LatLngBounds currentBounds}) { // No selective clearing needed — NetworkTileProvider aborts obsolete requests } + /// Called by flutter_map when a tile fails to load. Schedules a debounced + /// reset so that all failed tiles get retried after the burst of errors + /// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap. + /// + /// Skips retry for [TileLoadCancelledException] (tile scrolled off screen) + /// and [TileNotAvailableOfflineException] (no cached data, retrying won't + /// help without network). + @visibleForTesting + void onTileLoadError( + TileImage tile, + Object error, + StackTrace? stackTrace, + ) { + // Cancelled tiles are already gone — no retry needed. + if (error is TileLoadCancelledException) return; + + // Offline misses won't resolve by retrying — tile isn't cached. + if (error is TileNotAvailableOfflineException) return; + + debugPrint( + '[TileLayerManager] Tile error at ' + '${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, ' + 'scheduling retry in ${_retryDelay.inSeconds}s', + ); + scheduleRetry(); + } + + /// Schedule a debounced tile reset with exponential backoff. + /// + /// Cancels any pending retry timer and starts a new one at the current + /// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped + /// at [_maxRetryDelay]). + @visibleForTesting + void scheduleRetry() { + _retryTimer?.cancel(); + _retryTimer = Timer(_retryDelay, () { + if (!_resetController.isClosed) { + debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles'); + _resetController.add(null); + } + // Back off for next failure cycle + _retryDelay = Duration( + milliseconds: min( + _retryDelay.inMilliseconds * 2, + _maxRetryDelay.inMilliseconds, + ), + ); + }); + } + + /// Reset backoff to minimum delay. Called when a tile loads successfully + /// via the offline-first path, indicating connectivity has been restored. + /// + /// Note: the common path (`NetworkTileImageProvider`) does not call this, + /// so backoff resets only when the offline-first path succeeds over the + /// network. In practice this is fine — the common path's `RetryClient` + /// handles its own retries, and the reset stream only retries tiles that + /// flutter_map has already marked as `loadError`. + void onTileLoadSuccess() { + _retryDelay = _minRetryDelay; + } + /// Build tile layer widget with current provider and type. - /// Uses DeFlock's custom tile provider for clean integration with our offline/online system. + /// + /// Gets or creates a [DeflockTileProvider] for the given provider/type + /// combination, each with its own isolated cache. Widget buildTileLayer({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, }) { - // Create a fresh tile provider instance if we don't have one or cache was cleared - _tileProvider ??= DeflockTileProvider(); + final tileProvider = _getOrCreateProvider( + selectedProvider: selectedProvider, + selectedTileType: selectedTileType, + ); // Use the actual urlTemplate from the selected tile type. Our getTileUrl() // override handles the real URL generation; flutter_map uses urlTemplate @@ -94,7 +216,89 @@ class TileLayerManager { urlTemplate: urlTemplate, userAgentPackageName: 'me.deflock.deflockapp', maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0, - tileProvider: _tileProvider!, + tileProvider: tileProvider, + // Wire the reset stream so failed tiles get retried after a delay. + reset: _resetController.stream, + errorTileCallback: onTileLoadError, + // Clean up error tiles when they scroll off screen. + evictErrorTileStrategy: EvictErrorTileStrategy.notVisible, ); } + + /// Build a config fingerprint for drift detection. + /// + /// If any of these fields change (e.g. user edits the URL template or + /// rotates an API key) the cached [DeflockTileProvider] must be replaced. + static String _configFingerprint( + models.TileProvider provider, + models.TileType tileType, + ) => + '${provider.id}/${tileType.id}' + '|${tileType.urlTemplate}' + '|${tileType.maxZoom}' + '|${provider.apiKey ?? ''}'; + + /// Get or create a [DeflockTileProvider] for the given provider/type. + /// + /// Providers are cached by `providerId/tileTypeId`. If the effective config + /// (URL template, max zoom, API key) has changed since the provider was + /// created, the stale instance is shut down and replaced. + DeflockTileProvider _getOrCreateProvider({ + required models.TileProvider? selectedProvider, + required models.TileType? selectedTileType, + }) { + if (selectedProvider == null || selectedTileType == null) { + // No provider configured — return a fallback with default config. + return _providers.putIfAbsent( + '_fallback', + () => DeflockTileProvider( + providerId: 'unknown', + tileType: models.TileType( + id: 'unknown', + name: 'Unknown', + urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}', + attribution: '', + ), + ), + ); + } + + final key = '${selectedProvider.id}/${selectedTileType.id}'; + final fingerprint = _configFingerprint(selectedProvider, selectedTileType); + + // Check for config drift: if the provider exists but its config has + // changed, shut down the stale instance so a fresh one is created below. + final existing = _providers[key]; + if (existing != null && existing.configFingerprint != fingerprint) { + debugPrint( + '[TileLayerManager] Config changed for $key — replacing provider', + ); + existing.shutdown(); + _providers.remove(key); + } + + return _providers.putIfAbsent(key, () { + final cachingProvider = ProviderTileCacheManager.isInitialized + ? ProviderTileCacheManager.getOrCreate( + providerId: selectedProvider.id, + tileTypeId: selectedTileType.id, + policy: selectedTileType.servicePolicy, + ) + : null; + + debugPrint( + '[TileLayerManager] Creating provider for $key ' + '(cache: ${cachingProvider != null ? "enabled" : "disabled"})', + ); + + return DeflockTileProvider( + providerId: selectedProvider.id, + tileType: selectedTileType, + apiKey: selectedProvider.apiKey, + cachingProvider: cachingProvider, + onNetworkSuccess: onTileLoadSuccess, + configFingerprint: fingerprint, + ); + }); + } } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 96ef2b3d..1c817a46 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -284,17 +284,12 @@ class MapViewState extends State { onProfilesChanged: _refreshNodesFromProvider, ); - // Check if tile type OR offline mode changed and clear cache if needed - final cacheCleared = _tileManager.checkAndClearCacheIfNeeded( + // Check if provider, tile type, or offline mode changed and clear cache if needed + _tileManager.checkAndClearCacheIfNeeded( + currentProviderId: appState.selectedTileProvider?.id, currentTileTypeId: appState.selectedTileType?.id, currentOfflineMode: appState.offlineMode, ); - - if (cacheCleared) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _tileManager.clearTileQueue(); - }); - } // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -396,7 +391,7 @@ class MapViewState extends State { if (_activePointers > 0) _activePointers--; }, child: FlutterMap( - key: ValueKey('map_${appState.offlineMode}_${appState.selectedTileType?.id ?? 'none'}_${_tileManager.mapRebuildKey}'), + key: ValueKey('map_${appState.selectedTileProvider?.id ?? 'none'}_${appState.selectedTileType?.id ?? 'none'}_${appState.offlineMode}_${_tileManager.mapRebuildKey}'), mapController: _controller.mapController, options: MapOptions( initialCenter: _gpsController.currentLocation ?? _positionManager.initialLocation ?? LatLng(37.7749, -122.4194), diff --git a/load-tests/.devcontainer/Dockerfile b/load-tests/.devcontainer/Dockerfile new file mode 100644 index 00000000..fcff07a6 --- /dev/null +++ b/load-tests/.devcontainer/Dockerfile @@ -0,0 +1,17 @@ +# Dev container image for running Gatling load tests. +# +# Based on Microsoft's Java 21 dev container, which includes: +# - JDK 21 (Eclipse Temurin) +# - Gradle (via the wrapper in the project) +# - Standard dev tools (git, curl, etc.) +# +# We add Coursier (the Scala package manager) for Scala tooling support +# in VS Code via the Metals extension. + +FROM mcr.microsoft.com/devcontainers/java:21 + +# Install Coursier for Scala tooling (used by the Metals VS Code extension). +# Note: the Scala compiler itself is managed by Gradle via the Gatling plugin, +# so we only need Coursier for IDE support. +RUN curl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz | gzip -d > /usr/local/bin/cs \ + && chmod +x /usr/local/bin/cs diff --git a/load-tests/.devcontainer/devcontainer.json b/load-tests/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f9f5d2f4 --- /dev/null +++ b/load-tests/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// Dev container for the Gatling load test project. +// +// This provides a ready-to-go JVM + Scala environment without needing +// to install anything locally. Open the load-tests/ folder in VS Code +// and select "Dev Containers: Reopen in Container". +// +// Includes: +// - JDK 21 (Temurin) +// - Scala via Coursier +// - VS Code extensions: Scala Metals (IDE support) + Gradle (build tasks) +{ + "name": "Deflock Load Tests", + "build": { + "dockerfile": "Dockerfile" + }, + "workspaceFolder": "/workspaces/deflock-app/load-tests", + "customizations": { + "vscode": { + "extensions": [ + "scalameta.metals", + "vscjava.vscode-gradle" + ] + } + }, + // Pre-download all Gradle + Gatling dependencies so the first + // `./gradlew gatlingRun` is fast. + "postCreateCommand": "./gradlew dependencies" +} diff --git a/load-tests/.gitignore b/load-tests/.gitignore new file mode 100644 index 00000000..686141c9 --- /dev/null +++ b/load-tests/.gitignore @@ -0,0 +1,5 @@ +# Gradle build outputs (includes Gatling HTML reports in build/reports/gatling/) +build/ + +# Gradle cache +.gradle/ diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 00000000..43175cf4 --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,140 @@ +# Deflock Load Tests + +Gatling load tests for validating [`overpass.deflock.org`](https://overpass.deflock.org) performance before rolling it out as the primary Overpass API endpoint for all Deflock app users. + +## What is this? + +The Deflock app fetches surveillance camera data from the [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) every time a user pans or zooms the map. We've deployed our own Overpass instance at `overpass.deflock.org` to reduce dependence on the public endpoint. These load tests validate that our instance can handle realistic traffic patterns before we switch users over to it. + +The tests use [Gatling](https://gatling.io), an open-source load testing framework. Gatling simulates virtual users sending HTTP requests and produces detailed HTML reports with latency percentiles, error rates, and throughput metrics. + +## Quick start + +### Prerequisites + +- **JDK 21+** — install via [SDKMAN](https://sdkman.io) (`sdk install java 21-tem`) or your package manager +- **No other tools needed** — Gradle and Scala are handled automatically by the included wrapper and build config + +Or use the included [dev container](#dev-container) to skip local setup entirely. + +### Run the tests + +```bash +cd load-tests +./gradlew gatlingRun +``` + +This takes about 10-15 seconds (6 sequential requests with 500ms pauses between them). When finished, Gatling prints a `file://` URL to the HTML report — open it in your browser. + +The report lands in `build/reports/gatling//index.html`. + +### Run via GitHub Actions + +1. Go to the **Actions** tab in GitHub +2. Select the **"Load Test"** workflow +3. Click **"Run workflow"** +4. When complete, download the **gatling-report** artifact (retained for 30 days) + +## How the test works + +### The simulation + +A single virtual user walks through map zoom levels from **z15** (a few city blocks) down to **z10** (a metro region). At each zoom level, it picks a random US city and sends the same Overpass API query that the Deflock app sends when a user views that area on the map. + +This zoom progression reveals how response time scales with viewport size — larger viewports contain more surveillance nodes, producing bigger API responses. + +### Test data + +Six US cities were chosen for high surveillance camera density in their downtown areas: + +| City | Center coordinates | Landmark | +|---|---|---| +| Denver | 39.75, -105.00 | 16th St Mall / Union Station | +| Los Angeles | 34.05, -118.25 | Pershing Square, DTLA | +| San Francisco | 37.79, -122.40 | Financial District | +| New York | 40.75, -73.98 | Midtown / 42nd & 6th Ave | +| Boston | 42.36, -71.06 | Downtown Crossing | +| Chicago | 41.88, -87.63 | State & Madison, The Loop | + +### Zoom levels and viewport sizes + +Each zoom level corresponds to a different viewport size on a typical mobile phone screen (~400x800px, portrait): + +| Zoom | Area covered | Lat x Lng span | +|---|---|---| +| 15 | A few city blocks (~1.5 x 3 km) | 0.026 x 0.017 deg | +| 14 | A neighborhood (~3 x 6 km) | 0.053 x 0.034 deg | +| 13 | A district (~6 x 12 km) | 0.105 x 0.069 deg | +| 12 | A mid-size city (~12 x 23 km) | 0.210 x 0.140 deg | +| 11 | A large city (~23 x 47 km) | 0.420 x 0.270 deg | +| 10 | A metro region (~47 x 93 km) | 0.840 x 0.550 deg | + +## Interpreting the report + +The Gatling HTML report includes several views. Here's what to look for: + +### Key metrics + +- **p50 (median) latency** — what a typical user experiences +- **p95 latency** — should be under 10s for a good user experience +- **p99 latency** — should be under 30s (the assertion threshold) +- **Error rate** — should be 0% under single-user load + +### Report sections + +- **Response time distribution** — histogram showing how many requests fell into each latency bucket +- **Response time percentiles over time** — trend lines for p50/p75/p95/p99 throughout the test +- **Requests per second** — throughput over the test duration +- **Individual request details** — click any request name (e.g., "Overpass z15 - Denver") to see its specific metrics + +### What "good" looks like + +From our baseline runs, typical single-user performance is: + +| Zoom | Expected latency | +|---|---| +| z15 (blocks) | ~400-600ms | +| z13-z14 (neighborhood) | ~600-1000ms | +| z10-z11 (city/metro) | ~1000-1600ms | + +## Planned scenarios + +| Scenario | Description | Status | +|---|---|---| +| Single-user zoom progression | Baseline latency at each zoom level | Current | +| Concurrent users | Ramp up multiple users to find capacity limits | Planned | +| Stress test | Push beyond expected capacity to find breaking points | Planned | + +## Project structure + +``` +load-tests/ +├── .devcontainer/ # VS Code dev container (JDK 21 + Scala) +│ ├── devcontainer.json +│ └── Dockerfile +├── build.gradle.kts # Build config (Gatling + Scala plugins) +├── settings.gradle.kts # Gradle project name +├── gradlew / gradlew.bat # Gradle wrapper (no global install needed) +├── gradle/wrapper/ # Gradle wrapper jar + config +├── src/gatling/ +│ ├── scala/deflock/ +│ │ ├── OverpassSimulation.scala # The simulation (test scenario) +│ │ ├── OverpassRequests.scala # HTTP request definitions +│ │ └── TestData.scala # City coordinates + zoom feeders +│ └── resources/ +│ ├── gatling.conf # Gatling charting config +│ └── logback-test.xml # Log level config (WARN by default) +└── build/reports/gatling/ # Generated HTML reports (.gitignored) +``` + +## Dev container + +If you don't want to install JDK locally, the included dev container provides a ready-to-go environment: + +1. Open the `load-tests/` folder in VS Code +2. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +3. Press `Ctrl+Shift+P` → "Dev Containers: Reopen in Container" +4. Wait for the container to build (first time takes a few minutes) +5. Open a terminal and run `./gradlew gatlingRun` + +The container includes JDK 21, Scala (via [Coursier](https://get-coursier.io)), and VS Code extensions for Scala (Metals) and Gradle. diff --git a/load-tests/build.gradle.kts b/load-tests/build.gradle.kts new file mode 100644 index 00000000..da02f1d8 --- /dev/null +++ b/load-tests/build.gradle.kts @@ -0,0 +1,20 @@ +// Gatling load test build configuration. +// +// Gatling (https://gatling.io) is a load testing framework that simulates +// virtual users sending HTTP requests and produces HTML performance reports. +// +// The `scala` plugin compiles our Scala simulation files. +// The `io.gatling.gradle` plugin adds the `gatlingRun` task and manages +// Gatling + Scala library dependencies automatically. +// +// Run tests: ./gradlew gatlingRun +// Reports: build/reports/gatling/ + +plugins { + scala + id("io.gatling.gradle") version "3.15.0" +} + +repositories { + mavenCentral() +} diff --git a/load-tests/gradle/wrapper/gradle-wrapper.jar b/load-tests/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d997cfc6 Binary files /dev/null and b/load-tests/gradle/wrapper/gradle-wrapper.jar differ diff --git a/load-tests/gradle/wrapper/gradle-wrapper.properties b/load-tests/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..dbc3ce4a --- /dev/null +++ b/load-tests/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/load-tests/gradlew b/load-tests/gradlew new file mode 100755 index 00000000..d06d3135 --- /dev/null +++ b/load-tests/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b5fe9efed6cae7b9f2fbdb2d380fb69af16bb752/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/load-tests/gradlew.bat b/load-tests/gradlew.bat new file mode 100644 index 00000000..bd8a8c05 --- /dev/null +++ b/load-tests/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/load-tests/settings.gradle.kts b/load-tests/settings.gradle.kts new file mode 100644 index 00000000..7bb784cb --- /dev/null +++ b/load-tests/settings.gradle.kts @@ -0,0 +1,2 @@ +// Gradle project name — only affects build output naming, not functionality. +rootProject.name = "deflock-load-tests" diff --git a/load-tests/src/gatling/resources/gatling.conf b/load-tests/src/gatling/resources/gatling.conf new file mode 100644 index 00000000..a35bec22 --- /dev/null +++ b/load-tests/src/gatling/resources/gatling.conf @@ -0,0 +1,19 @@ +// Gatling configuration. +// See https://docs.gatling.io/reference/script/core/configuration/ for all options. +// +// The Gradle plugin handles most settings (like report output directory). +// This file only overrides charting thresholds for the HTML report. + +gatling { + charting { + // These thresholds define the color bands in the response time + // distribution chart in the HTML report: + // Green: < lowerBound (under 1 second — great) + // Yellow: lowerBound to higherBound (1-5 seconds — acceptable) + // Red: > higherBound (over 5 seconds — needs attention) + indicators { + lowerBound = 1000 + higherBound = 5000 + } + } +} diff --git a/load-tests/src/gatling/resources/logback-test.xml b/load-tests/src/gatling/resources/logback-test.xml new file mode 100644 index 00000000..4eade1fe --- /dev/null +++ b/load-tests/src/gatling/resources/logback-test.xml @@ -0,0 +1,23 @@ + + + + + + %d{HH:mm:ss.SSS} [%-5level] %logger{36} - %msg%n + + + + + + + diff --git a/load-tests/src/gatling/scala/deflock/OverpassRequests.scala b/load-tests/src/gatling/scala/deflock/OverpassRequests.scala new file mode 100644 index 00000000..d4db538e --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/OverpassRequests.scala @@ -0,0 +1,100 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Reusable Overpass API request definitions for Gatling simulations. + * + * The request format here must match what the Deflock app actually sends. + * See lib/services/overpass_service.dart for the app's implementation. + * + * Key design decisions: + * - POST to /api/interpreter with form-encoded body (not GET with query params) + * - Query includes both surveillance nodes and their parent ways/relations + * - Timeout matches the app's ResiliencePolicy.httpTimeout (45s server + 5s client margin) + */ +object OverpassRequests { + + // --- Timeouts --- + // The Overpass QL query tells the server to abort after this many seconds. + // This matches kOverpassQueryTimeout in the app (lib/dev_config.dart). + val serverTimeoutSeconds = 45 + + // The HTTP client timeout is slightly longer than the server timeout so that + // we always receive the server's own timeout error response (a 200 with a + // "remark" field) rather than the client aborting the connection first. + val clientTimeout = (serverTimeoutSeconds + 5).seconds + + // --- Overpass tag filters --- + // These match the app's default enabled NodeProfiles. Each filter becomes + // a separate `node[...]` clause in the Overpass QL query, and the results + // are unioned together. To test different profiles, add/remove filters here. + // + // See: lib/models/node_profile.dart for the full list of app profiles. + val tagFilters: Seq[String] = Seq( + """["man_made"="surveillance"]""", + """["camera:type"="fixed"]""" + ) + + // --- Feeder session keys --- + // These constants are the variable names injected into each virtual user's + // session by the feeders in TestData. Using constants here (instead of raw + // strings) prevents typos that would silently break at runtime. + val CityName = "cityName" + val ZoomLevel = "zoomLevel" + val QueryBody = "queryBody" + + /** + * Build an Overpass QL query string for the given bounding box. + * + * The query structure matches OverpassService._buildQuery() in the app: + * 1. Fetch nodes matching any of the tag filters within the bbox + * 2. Fetch parent ways and relations for those nodes (out skel) + * + * Overpass bbox format is (south, west, north, east) — note this is + * different from many mapping libraries that use (west, south, east, north). + * + * @return A complete Overpass QL query string ready to POST + */ + def buildQuery(south: Double, west: Double, north: Double, east: Double): String = { + val nodeClauses = tagFilters.map { tags => + s" node$tags($south,$west,$north,$east);" + }.mkString("\n") + + s"""[out:json][timeout:$serverTimeoutSeconds]; + |( + |$nodeClauses + |); + |out body; + |( + | way(bn); + | rel(bn); + |); + |out skel;""".stripMargin + } + + /** + * The HTTP request definition that Gatling will execute. + * + * Uses Gatling's #{...} Expression Language syntax to inject session + * variables at request time. These variables are populated by the feeders + * in TestData — see feederForZoom(). + * + * The request name (e.g., "Overpass z15 - Denver") appears in the Gatling + * HTML report, making it easy to compare performance across zoom levels + * and cities. + * + * Checks: + * - HTTP 200 status (Overpass returns 200 even for empty results) + * - Response body contains an "elements" array (valid Overpass JSON) + */ + val overpassRequest = http("Overpass z#{zoomLevel} - #{cityName}") + .post("/api/interpreter") + .formParam("data", "#{queryBody}") + .requestTimeout(clientTimeout) + .check(status.is(200)) + .check(jsonPath("$.elements").exists) +} diff --git a/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala b/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala new file mode 100644 index 00000000..eafdc571 --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/OverpassSimulation.scala @@ -0,0 +1,77 @@ +package deflock + +import io.gatling.core.Predef._ +import io.gatling.http.Predef._ + +import scala.concurrent.duration._ + +/** + * Gatling simulation for load-testing the Deflock Overpass API endpoint. + * + * This simulation validates the performance of overpass.deflock.org before + * it becomes the primary endpoint for all Deflock app users. It replays + * realistic queries matching the app's actual request format. + * + * == How it works == + * + * A single virtual user walks through zoom levels from tightest (z15, a few + * city blocks) to widest (z10, a metro region). At each zoom level, it picks + * a random US city, builds a bounding box around that city's downtown, and + * sends the same Overpass query the app would send. + * + * This progression reveals how response time scales with viewport size — + * larger viewports return more surveillance nodes, producing bigger responses. + * + * == Running == + * + * {{{ + * cd load-tests + * ./gradlew gatlingRun + * }}} + * + * The HTML report will be in build/reports/gatling/ — open index.html. + * + * == Future scenarios (planned) == + * + * - Concurrent users: ramp up multiple virtual users to find capacity limits + * - Stress test: push beyond expected capacity to find breaking points + */ +class OverpassSimulation extends Simulation { + + // Target our self-hosted Overpass instance (not the public OSMF one). + // The User-Agent identifies load test traffic in server logs. + val httpProtocol = http + .baseUrl("https://overpass.deflock.org") + .userAgentHeader("DeFlock/LoadTest (+https://deflock.org)") + .acceptHeader("application/json") + + // Walk through zoom levels from tightest (z15) to widest (z10). + // At each level, a random city is selected and queried, with a 500ms + // pause between requests (matching the app's debounce interval). + // + // The .reduce(_.exec(_)) chains the zoom-level steps together into a + // single sequential scenario — Gatling's DSL builds an immutable chain + // of actions, and reduce folds them left-to-right into one chain. + val baselineScenario = scenario("Single-user zoom progression") + .exec( + TestData.zoomViewports.map { viewport => + feed(TestData.feederForZoom(viewport)) + .exec(OverpassRequests.overpassRequest) + .pause(500.milliseconds) + }.reduce(_.exec(_)) + ) + + // --- Test setup --- + // atOnceUsers(1): inject exactly 1 virtual user immediately (no ramp-up). + // This is a baseline test — we want clean, isolated measurements before + // adding concurrency in future scenarios. + setUp( + baselineScenario.inject(atOnceUsers(1)) + ).protocols(httpProtocol) + .assertions( + // p99 response time under 30 seconds (generous for Overpass) + global.responseTime.percentile(99).lt(30000), + // Less than 5% of requests should fail + global.failedRequests.percent.lt(5.0) + ) +} diff --git a/load-tests/src/gatling/scala/deflock/TestData.scala b/load-tests/src/gatling/scala/deflock/TestData.scala new file mode 100644 index 00000000..52f7a8dc --- /dev/null +++ b/load-tests/src/gatling/scala/deflock/TestData.scala @@ -0,0 +1,104 @@ +package deflock + +import io.gatling.core.Predef._ + +/** + * Center coordinates for a city's downtown area. + * + * These are the starting points for building map viewport bounding boxes. + * Each coordinate was verified against map data and chosen for its high + * density of surveillance infrastructure (cameras, ALPR, etc.), which + * produces realistic Overpass API response sizes. + * + * @param name Human-readable city name (appears in Gatling report labels) + * @param lat Latitude of the downtown center point + * @param lng Longitude of the downtown center point + */ +case class CityCenter(name: String, lat: Double, lng: Double) + +/** + * The dimensions of a map viewport at a given zoom level. + * + * These represent what a user sees on their phone screen at each zoom level. + * Larger viewports (lower zoom) fetch more data from the Overpass API, so + * we use these to measure how response time scales with area. + * + * @param zoom OSM/Slippy map zoom level (10 = metro region, 15 = a few blocks) + * @param latSpan Height of the viewport in degrees of latitude + * @param lngSpan Width of the viewport in degrees of longitude + */ +case class ZoomViewport(zoom: Int, latSpan: Double, lngSpan: Double) + +object TestData { + + /** + * US cities with verified downtown coordinates targeting high-surveillance areas. + * + * Each city was chosen because its downtown has significant camera density, + * producing realistic query results. The coordinates point to specific + * well-known locations in each city's central business district. + */ + val cities: Seq[CityCenter] = Seq( + CityCenter("Denver", 39.7478, -104.9995), // 16th St Mall / Union Station + CityCenter("Los Angeles", 34.0483, -118.2530), // Pershing Square, DTLA + CityCenter("San Francisco", 37.7946, -122.3999), // Financial District / Market & Montgomery + CityCenter("New York", 40.7549, -73.9840), // Midtown / 42nd & 6th Ave + CityCenter("Boston", 42.3567, -71.0588), // Downtown Crossing + CityCenter("Chicago", 41.8783, -87.6258) // State & Madison, The Loop + ) + + /** + * Map viewport sizes for zoom levels 10 through 15. + * + * Calculated for a ~400x800px mobile screen (portrait orientation) at ~40 deg N + * latitude using standard OSM/Slippy map tile math (Mercator projection, + * 256px tiles). Each zoom level doubles the tile count, halving the viewport span. + * + * | Zoom | Approx area covered | Example | + * |------|-----------------------|------------------------------| + * | 15 | ~1.5 x 3 km | A few city blocks | + * | 14 | ~3 x 6 km | A neighborhood | + * | 13 | ~6 x 12 km | A district | + * | 12 | ~12 x 23 km | A mid-size city | + * | 11 | ~23 x 47 km | A large city extent | + * | 10 | ~47 x 93 km | A metro region | + * + * Ordered from tightest to widest so the simulation can walk through them + * and show the performance impact of increasing viewport size. + */ + val zoomViewports: Seq[ZoomViewport] = Seq( + ZoomViewport(15, 0.026, 0.017), + ZoomViewport(14, 0.053, 0.034), + ZoomViewport(13, 0.105, 0.069), + ZoomViewport(12, 0.210, 0.140), + ZoomViewport(11, 0.420, 0.270), + ZoomViewport(10, 0.840, 0.550) + ) + + /** + * Create a Gatling feeder that picks a random city for a given zoom level. + * + * A "feeder" in Gatling is a data source that injects variables into the + * virtual user's session before each request. This one pre-computes the + * Overpass query body so it's built once at startup, not on every request. + * + * The bounding box is computed by centering the viewport on the city's + * downtown coordinates: south/north = lat +/- half the latSpan, etc. + * + * @param viewport The zoom level and its corresponding viewport dimensions + * @return A Gatling feeder that randomly selects a city and provides session + * variables: cityName, zoomLevel, and the pre-built queryBody + */ + def feederForZoom(viewport: ZoomViewport) = cities.map { city => + val south = city.lat - viewport.latSpan / 2 + val north = city.lat + viewport.latSpan / 2 + val west = city.lng - viewport.lngSpan / 2 + val east = city.lng + viewport.lngSpan / 2 + + Map( + OverpassRequests.CityName -> city.name, + OverpassRequests.ZoomLevel -> viewport.zoom, + OverpassRequests.QueryBody -> OverpassRequests.buildQuery(south, west, north, east) + ) + }.toIndexedSeq.random // .random makes Gatling pick a random city each iteration +} diff --git a/pubspec.lock b/pubspec.lock index 27e167b0..b61873af 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.0.0" app_links_linux: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: "direct main" description: @@ -117,10 +125,10 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -157,10 +165,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" desktop_webview_window: dependency: transitive description: @@ -170,7 +178,7 @@ packages: source: hosted version: "0.2.3" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -181,10 +189,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -258,10 +266,10 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: df33e784b09fae857c6261a5521dd42bd4d3342cb6200884bb70730638af5fd5 + sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8" url: "https://pub.dev" source: hosted - version: "8.2.1" + version: "8.2.2" flutter_map_animations: dependency: "direct main" description: @@ -274,34 +282,34 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.7" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874 + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "10.0.0-beta.4" + version: "10.0.0" flutter_secure_storage_darwin: dependency: transitive description: name: flutter_secure_storage_darwin - sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845 + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.2.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "9b4b73127e857cd3117d43a70fa3dddadb6e0b253be62e6a6ab85caa0742182c" + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: @@ -314,26 +322,26 @@ packages: dependency: transitive description: name: flutter_secure_storage_web - sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517" + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.3" flutter_test: dependency: "direct dev" description: flutter @@ -343,18 +351,18 @@ packages: dependency: "direct main" description: name: flutter_web_auth_2 - sha256: "2483d1fd3c45fe1262446e8d5f5490f01b864f2e7868ffe05b4727e263cc0182" + sha256: "432ff8c7b2834eaeec3378d99e24a0210b9ac2f453b3f7a7d739a5c09069fba3" url: "https://pub.dev" source: hosted - version: "5.0.0-alpha.3" + version: "5.0.1" flutter_web_auth_2_platform_interface: dependency: transitive description: name: flutter_web_auth_2_platform_interface - sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853" + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab url: "https://pub.dev" source: hosted - version: "5.0.0-alpha.0" + version: "5.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -408,6 +416,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gtk: dependency: transitive description: @@ -416,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" html: dependency: transitive description: @@ -428,10 +452,10 @@ packages: dependency: "direct main" description: name: http - sha256: "85ab0074f9bf2b24625906d8382bbec84d3d6919d285ba9c106b07b65791fb99" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0-beta.2" + version: "1.6.0" http_parser: dependency: transitive description: @@ -444,10 +468,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" intl: dependency: transitive description: @@ -460,10 +484,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" latlong2: dependency: "direct main" description: @@ -524,10 +548,18 @@ packages: dependency: transitive description: name: logger - sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -568,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -580,18 +620,26 @@ packages: dependency: "direct main" description: name: oauth2_client - sha256: d6a146049f36ef2da32bdc7a7a9e5671a0e66ea596d8f70a26de4cddfcab4d2e + sha256: "6667da827518047d99ce82cf7b23043ea4a4bac99fc6681d4a1bf6ee1dd9579f" + url: "https://pub.dev" + source: hosted + version: "4.2.3" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "9.3.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -628,18 +676,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -668,10 +716,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.2" platform: dependency: transitive description: @@ -692,10 +740,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" proj4dart: dependency: transitive description: @@ -708,10 +756,18 @@ packages: dependency: "direct main" description: name: provider - sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" random_string: dependency: transitive description: @@ -724,26 +780,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -785,18 +841,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.2" sqflite: dependency: "direct main" description: @@ -809,10 +857,10 @@ packages: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+2" sqflite_common: dependency: transitive description: @@ -913,10 +961,10 @@ packages: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" url_launcher: dependency: "direct main" description: @@ -929,34 +977,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -969,26 +1017,26 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.3" vector_graphics: dependency: transitive description: @@ -1009,10 +1057,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.0" vector_math: dependency: transitive description: @@ -1041,10 +1089,10 @@ packages: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.15.0" window_to_front: dependency: transitive description: @@ -1073,10 +1121,10 @@ packages: dependency: "direct main" description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -1086,5 +1134,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 6523ba46..6269a4b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.7.2+48 # The thing after the + is the version code, incremented with each release +version: 2.9.0+51 # The thing after the + is the version code, incremented with each release environment: - sdk: ">=3.8.0 <4.0.0" # RadioGroup widget requires Dart 3.8+ (Flutter 3.35+) + sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) dependencies: flutter: @@ -22,12 +22,12 @@ dependencies: flutter_local_notifications: ^17.2.2 url_launcher: ^6.3.0 flutter_linkify: ^6.0.0 - app_links: ^6.1.4 + app_links: ^7.0.0 # Auth, storage, prefs oauth2_client: ^4.2.0 - flutter_web_auth_2: 5.0.0-alpha.3 - flutter_secure_storage: 10.0.0-beta.4 + flutter_web_auth_2: ^5.0.1 + flutter_secure_storage: ^10.0.0 # Persistence shared_preferences: ^2.2.2 @@ -35,7 +35,7 @@ dependencies: path: ^1.8.3 path_provider: ^2.1.0 uuid: ^4.0.0 - package_info_plus: ^8.0.0 + package_info_plus: ^9.0.0 csv: ^6.0.0 collection: ^1.18.0 @@ -43,6 +43,7 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.4 + fake_async: ^1.3.0 flutter_launcher_icons: ^0.14.4 flutter_lints: ^6.0.0 flutter_native_splash: ^2.4.6 diff --git a/test/models/node_profile_test.dart b/test/models/node_profile_test.dart index 28b1fb81..64a8cbee 100644 --- a/test/models/node_profile_test.dart +++ b/test/models/node_profile_test.dart @@ -1,7 +1,14 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/osm_node.dart'; +import 'package:deflockapp/state/profile_state.dart'; void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + group('NodeProfile', () { test('toJson/fromJson round-trip preserves all fields', () { final profile = NodeProfile( @@ -72,5 +79,180 @@ void main() { expect(a.hashCode, equals(b.hashCode)); expect(a, isNot(equals(c))); }); + + group('createExistingTagsProfile', () { + test('should NOT assign FOV for nodes with single direction', () { + // This is the core bug fix: nodes with just "direction=90" should not get a default FOV + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90', + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Single direction nodes should not get default FOV'); + expect(profile.name, equals('')); + expect(profile.tags, isEmpty, reason: 'Existing tags profile should have empty tags'); + }); + + test('should assign FOV for nodes with range notation', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '55-125', // Range notation = explicit FOV + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNotNull, reason: 'Range notation should preserve FOV'); + expect(profile.fov, equals(70.0), reason: 'Range 55-125 should calculate to 70 degree FOV'); + }); + + test('should assign FOV for nodes with multiple consistent ranges', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '55-125;235-305', // Two ranges with same FOV + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, equals(70.0), reason: 'Multiple consistent ranges should preserve FOV'); + }); + + test('should NOT assign FOV for mixed single directions and ranges', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90;180-360', // Mix of single direction and range + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Mixed notation should not assign FOV'); + }); + + test('should NOT assign FOV for multiple single directions', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90;180;270', // Multiple single directions + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Multiple single directions should not get default FOV'); + }); + + test('should handle camera:direction tag', () { + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'camera:direction': '180', // Using camera:direction instead of direction + 'man_made': 'surveillance', + 'surveillance:type': 'camera', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + expect(profile.fov, isNull, reason: 'Single camera:direction should not get default FOV'); + }); + + test('should fix the specific bug: direction=90 should not become direction=55-125', () { + // This tests the exact bug scenario mentioned in the issue + final node = OsmNode( + id: 123, + coord: const LatLng(37.7749, -122.4194), + tags: { + 'direction': '90', // Single direction, should stay as single direction + 'man_made': 'surveillance', + 'surveillance:type': 'ALPR', + }, + ); + + final profile = NodeProfile.createExistingTagsProfile(node); + + // Key fix: profile should NOT have an FOV, so upload won't convert to range notation + expect(profile.fov, isNull, reason: 'direction=90 should not get converted to direction=55-125'); + + // Verify the node does have directionFovPairs (for rendering), but profile ignores them + expect(node.directionFovPairs, hasLength(1)); + expect(node.directionFovPairs.first.centerDegrees, equals(90.0)); + expect(node.directionFovPairs.first.fovDegrees, equals(70.0)); // Default FOV for rendering + }); + }); + + group('ProfileState reordering', () { + test('should reorder profiles correctly', () async { + final profileState = ProfileState(); + + // Add some test profiles directly to avoid storage operations + final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {}); + final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {}); + final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {}); + + // Add profiles directly to the internal list to avoid storage + profileState.internalProfiles.addAll([profileA, profileB, profileC]); + profileState.internalEnabled.addAll([profileA, profileB, profileC]); + + // Initial order should be A, B, C + expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); + + // Move profile at index 0 (A) to index 2 (should become B, A, C due to Flutter's reorder logic) + profileState.reorderProfiles(0, 2); + expect(profileState.profiles.map((p) => p.id), equals(['b', 'a', 'c'])); + + // Move profile at index 1 (A) to index 0 (should become A, B, C) + profileState.reorderProfiles(1, 0); + expect(profileState.profiles.map((p) => p.id), equals(['a', 'b', 'c'])); + }); + + test('should maintain enabled status after reordering', () { + final profileState = ProfileState(); + + final profileA = NodeProfile(id: 'a', name: 'Profile A', tags: const {}); + final profileB = NodeProfile(id: 'b', name: 'Profile B', tags: const {}); + final profileC = NodeProfile(id: 'c', name: 'Profile C', tags: const {}); + + // Add profiles directly to avoid storage operations + profileState.internalProfiles.addAll([profileA, profileB, profileC]); + profileState.internalEnabled.addAll([profileA, profileB, profileC]); + + // Disable profile B + profileState.internalEnabled.remove(profileB); + expect(profileState.isEnabled(profileB), isFalse); + + // Reorder profiles + profileState.reorderProfiles(0, 2); + + // Profile B should still be disabled after reordering + expect(profileState.isEnabled(profileB), isFalse); + expect(profileState.isEnabled(profileA), isTrue); + expect(profileState.isEnabled(profileC), isTrue); + }); + }); }); } diff --git a/test/services/deflock_tile_provider_test.dart b/test/services/deflock_tile_provider_test.dart index ee4cd36b..140bd0e6 100644 --- a/test/services/deflock_tile_provider_test.dart +++ b/test/services/deflock_tile_provider_test.dart @@ -1,46 +1,57 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/app_state.dart'; import 'package:deflockapp/models/tile_provider.dart' as models; import 'package:deflockapp/services/deflock_tile_provider.dart'; +import 'package:deflockapp/services/provider_tile_cache_store.dart'; class MockAppState extends Mock implements AppState {} +class MockMapCachingProvider extends Mock implements MapCachingProvider {} void main() { late DeflockTileProvider provider; late MockAppState mockAppState; + final osmTileType = models.TileType( + id: 'osm_street', + name: 'Street Map', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap', + maxZoom: 19, + ); + + final mapboxTileType = models.TileType( + id: 'mapbox_satellite', + name: 'Satellite', + urlTemplate: + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + attribution: '© Mapbox', + ); + setUp(() { mockAppState = MockAppState(); AppState.instance = mockAppState; - // Default stubs: online, OSM provider selected, no offline areas + // Default stubs: online, no offline areas when(() => mockAppState.offlineMode).thenReturn(false); - when(() => mockAppState.selectedTileProvider).thenReturn( - const models.TileProvider( - id: 'openstreetmap', - name: 'OpenStreetMap', - tileTypes: [], - ), - ); - when(() => mockAppState.selectedTileType).thenReturn( - const models.TileType( - id: 'osm_street', - name: 'Street Map', - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - attribution: '© OpenStreetMap', - maxZoom: 19, - ), - ); - provider = DeflockTileProvider(); + provider = DeflockTileProvider( + providerId: 'openstreetmap', + tileType: osmTileType, + ); }); tearDown(() async { - await provider.dispose(); + provider.shutdown(); AppState.instance = MockAppState(); }); @@ -49,7 +60,7 @@ void main() { expect(provider.supportsCancelLoading, isTrue); }); - test('getTileUrl() delegates to TileType.getTileUrl()', () { + test('getTileUrl() uses frozen tileType config', () { const coords = TileCoordinates(1, 2, 3); final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}'); @@ -58,23 +69,12 @@ void main() { expect(url, equals('https://tile.openstreetmap.org/3/1/2.png')); }); - test('getTileUrl() includes API key when present', () { - when(() => mockAppState.selectedTileProvider).thenReturn( - const models.TileProvider( - id: 'mapbox', - name: 'Mapbox', - apiKey: 'test_key_123', - tileTypes: [], - ), - ); - when(() => mockAppState.selectedTileType).thenReturn( - const models.TileType( - id: 'mapbox_satellite', - name: 'Satellite', - urlTemplate: - 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', - attribution: '© Mapbox', - ), + test('getTileUrl() includes API key when present', () async { + provider.shutdown(); + provider = DeflockTileProvider( + providerId: 'mapbox', + tileType: mapboxTileType, + apiKey: 'test_key_123', ); const coords = TileCoordinates(1, 2, 10); @@ -86,19 +86,6 @@ void main() { expect(url, contains('/10/1/2@2x')); }); - test('getTileUrl() falls back to super when no provider selected', () { - when(() => mockAppState.selectedTileProvider).thenReturn(null); - when(() => mockAppState.selectedTileType).thenReturn(null); - - const coords = TileCoordinates(1, 2, 3); - final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}'); - - final url = provider.getTileUrl(coords, options); - - // Super implementation uses the urlTemplate from TileLayer options - expect(url, equals('https://example.com/3/1/2')); - }); - test('routes to network path when no offline areas exist', () { // offlineMode = false, OfflineAreaService not initialized → no offline areas const coords = TileCoordinates(5, 10, 12); @@ -136,10 +123,19 @@ void main() { expect(offlineProvider.providerId, equals('openstreetmap')); expect(offlineProvider.tileTypeId, equals('osm_street')); }); + + test('frozen config is independent of AppState', () { + // Provider was created with OSM config — changing AppState should not affect it + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}'); + + final url = provider.getTileUrl(coords, options); + expect(url, equals('https://tile.openstreetmap.org/3/1/2.png')); + }); }); group('DeflockOfflineTileImageProvider', () { - test('equal for same coordinates and provider/type', () { + test('equal for same coordinates, provider/type, and offlineOnly', () { const coords = TileCoordinates(1, 2, 3); final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); final cancel = Future.value(); @@ -161,7 +157,7 @@ void main() { httpClient: http.Client(), headers: const {}, cancelLoading: cancel, - isOfflineOnly: true, // different — but not in == + isOfflineOnly: false, providerId: 'prov_a', tileTypeId: 'type_1', tileUrl: 'https://other.com/3/1/2', // different — but not in == @@ -171,6 +167,37 @@ void main() { expect(a.hashCode, equals(b.hashCode)); }); + test('not equal for different isOfflineOnly', () { + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancel = Future.value(); + + final online = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + final offline = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: true, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + + expect(online, isNot(equals(offline))); + }); + test('not equal for different coordinates', () { const coords1 = TileCoordinates(1, 2, 3); const coords2 = TileCoordinates(1, 2, 4); @@ -247,5 +274,298 @@ void main() { expect(base, isNot(equals(diffType))); expect(base.hashCode, isNot(equals(diffType.hashCode))); }); + + test('equality ignores cachingProvider and onNetworkSuccess', () { + const coords = TileCoordinates(1, 2, 3); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancel = Future.value(); + + final withCaching = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + cachingProvider: MockMapCachingProvider(), + onNetworkSuccess: () {}, + ); + final withoutCaching = DeflockOfflineTileImageProvider( + coordinates: coords, + options: options, + httpClient: http.Client(), + headers: const {}, + cancelLoading: cancel, + isOfflineOnly: false, + providerId: 'prov_a', + tileTypeId: 'type_1', + tileUrl: 'url', + ); + + expect(withCaching, equals(withoutCaching)); + expect(withCaching.hashCode, equals(withoutCaching.hashCode)); + }); + }); + + group('DeflockTileProvider caching integration', () { + test('passes cachingProvider through to offline path', () { + when(() => mockAppState.offlineMode).thenReturn(true); + + final mockCaching = MockMapCachingProvider(); + var successCalled = false; + + final cachingProvider = DeflockTileProvider( + providerId: 'openstreetmap', + tileType: osmTileType, + cachingProvider: mockCaching, + onNetworkSuccess: () => successCalled = true, + ); + + const coords = TileCoordinates(5, 10, 12); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancelLoading = Future.value(); + + final imageProvider = cachingProvider.getImageWithCancelLoadingSupport( + coords, + options, + cancelLoading, + ); + + expect(imageProvider, isA()); + final offlineProvider = imageProvider as DeflockOfflineTileImageProvider; + expect(offlineProvider.cachingProvider, same(mockCaching)); + expect(offlineProvider.onNetworkSuccess, isNotNull); + + // Invoke the callback to verify it's wired correctly + offlineProvider.onNetworkSuccess!(); + expect(successCalled, isTrue); + + cachingProvider.shutdown(); + }); + + test('offline provider has null caching when not provided', () { + when(() => mockAppState.offlineMode).thenReturn(true); + + const coords = TileCoordinates(5, 10, 12); + final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}'); + final cancelLoading = Future.value(); + + final imageProvider = provider.getImageWithCancelLoadingSupport( + coords, + options, + cancelLoading, + ); + + expect(imageProvider, isA()); + final offlineProvider = imageProvider as DeflockOfflineTileImageProvider; + expect(offlineProvider.cachingProvider, isNull); + expect(offlineProvider.onNetworkSuccess, isNull); + }); + }); + + group('DeflockOfflineTileImageProvider caching helpers', () { + late Directory tempDir; + late ProviderTileCacheStore cacheStore; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('tile_cache_test_'); + cacheStore = ProviderTileCacheStore(cacheDirectory: tempDir.path); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('disk cache integration: putTile then getTile round-trip', () async { + const url = 'https://tile.example.com/3/1/2.png'; + final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: DateTime.utc(2026, 2, 20), + etag: '"tile-etag"', + ); + + // Write to cache + await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes); + + // Read back + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); + expect(cached.metadata.etag, equals('"tile-etag"')); + expect(cached.metadata.isStale, isFalse); + }); + + test('disk cache: stale tiles are detectable', () async { + const url = 'https://tile.example.com/stale.png'; + final bytes = Uint8List.fromList([1, 2, 3]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + lastModified: null, + etag: null, + ); + + await cacheStore.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.metadata.isStale, isTrue); + // Bytes are still available even when stale (for conditional revalidation) + expect(cached.bytes, equals(bytes)); + }); + + test('disk cache: metadata-only update preserves bytes', () async { + const url = 'https://tile.example.com/revalidated.png'; + final bytes = Uint8List.fromList([10, 20, 30]); + + // Initial write with bytes + await cacheStore.putTile( + url: url, + metadata: CachedMapTileMetadata( + staleAt: DateTime.timestamp().subtract(const Duration(hours: 1)), + lastModified: null, + etag: '"v1"', + ), + bytes: bytes, + ); + + // Metadata-only update (simulating 304 Not Modified revalidation) + await cacheStore.putTile( + url: url, + metadata: CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: null, + etag: '"v2"', + ), + // No bytes — metadata only + ); + + final cached = await cacheStore.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); // original bytes preserved + expect(cached.metadata.etag, equals('"v2"')); // metadata updated + expect(cached.metadata.isStale, isFalse); // now fresh + }); + }); + + group('DeflockOfflineTileImageProvider load error paths', () { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + /// Load the tile via [loadImage] and return the first error from the + /// image stream. The decode callback should never be reached on error + /// paths, so we throw if it is. + Future loadAndExpectError( + DeflockOfflineTileImageProvider provider) { + final completer = Completer(); + final stream = provider.loadImage( + provider, + (buffer, {getTargetSize}) async => + throw StateError('decode should not be called'), + ); + stream.addListener(ImageStreamListener( + (_, _) { + if (!completer.isCompleted) { + completer + .completeError(StateError('expected error but got image')); + } + }, + onError: (error, _) { + if (!completer.isCompleted) completer.complete(error); + }, + )); + return completer.future; + } + + test('offline both-miss throws TileNotAvailableOfflineException', + () async { + // No offline areas, no cache → both miss. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Completer().future, // never cancels + isOfflineOnly: true, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('cancelled offline tile throws TileLoadCancelledException', + () async { + // cancelLoading already resolved → _loadAsync catch block detects + // cancellation and throws TileLoadCancelledException instead of + // the underlying TileNotAvailableOfflineException. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Future.value(), // already cancelled + isOfflineOnly: true, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('online cancel before network throws TileLoadCancelledException', + () async { + // Online mode: cache miss, local miss, then cancelled check fires + // before reaching the network fetch. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(1, 2, 3), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: http.Client(), + headers: const {}, + cancelLoading: Future.value(), // already cancelled + isOfflineOnly: false, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/3/1/2.png', + ), + ); + + expect(error, isA()); + }); + + test('network error throws HttpException', () async { + // Online mode: cache miss, local miss, not cancelled, network + // returns 500 → HttpException with tile coordinates and status. + final error = await loadAndExpectError( + DeflockOfflineTileImageProvider( + coordinates: const TileCoordinates(4, 5, 6), + options: TileLayer(urlTemplate: 'test/{z}/{x}/{y}'), + httpClient: MockClient((_) async => http.Response('', 500)), + headers: const {}, + cancelLoading: Completer().future, // never cancels + isOfflineOnly: false, + providerId: 'nonexistent', + tileTypeId: 'nonexistent', + tileUrl: 'https://example.com/6/4/5.png', + ), + ); + + expect(error, isA()); + expect((error as HttpException).message, contains('6/4/5')); + expect(error.message, contains('500')); + }); }); } diff --git a/test/services/offline_area_service_test.dart b/test/services/offline_area_service_test.dart new file mode 100644 index 00000000..54f58e3a --- /dev/null +++ b/test/services/offline_area_service_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; + +import 'package:deflockapp/services/offline_area_service.dart'; +import 'package:deflockapp/services/offline_areas/offline_area_models.dart'; + +OfflineArea _makeArea({ + String providerId = 'osm', + String tileTypeId = 'standard', + int minZoom = 5, + int maxZoom = 12, + OfflineAreaStatus status = OfflineAreaStatus.complete, +}) { + return OfflineArea( + id: 'test-$providerId-$tileTypeId-$minZoom-$maxZoom', + bounds: LatLngBounds(const LatLng(0, 0), const LatLng(1, 1)), + minZoom: minZoom, + maxZoom: maxZoom, + directory: '/tmp/test-area', + status: status, + tileProviderId: providerId, + tileTypeId: tileTypeId, + ); +} + +void main() { + final service = OfflineAreaService(); + + setUp(() { + service.setAreasForTesting([]); + }); + + group('hasOfflineAreasForProviderAtZoom', () { + test('returns true for zoom within range', () { + service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 5), isTrue); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isTrue); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 12), isTrue); + }); + + test('returns false for zoom outside range', () { + service.setAreasForTesting([_makeArea(minZoom: 5, maxZoom: 12)]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 4), isFalse); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isFalse); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 14), isFalse); + }); + + test('returns false for wrong provider', () { + service.setAreasForTesting([_makeArea(providerId: 'osm')]); + + expect(service.hasOfflineAreasForProviderAtZoom('other', 'standard', 8), isFalse); + }); + + test('returns false for wrong tile type', () { + service.setAreasForTesting([_makeArea(tileTypeId: 'standard')]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'satellite', 8), isFalse); + }); + + test('returns false for non-complete areas', () { + service.setAreasForTesting([ + _makeArea(status: OfflineAreaStatus.downloading), + _makeArea(status: OfflineAreaStatus.error), + ]); + + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse); + }); + + test('returns false when initialized with no areas', () { + service.setAreasForTesting([]); + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 8), isFalse); + }); + + test('matches when any area covers the zoom level', () { + service.setAreasForTesting([ + _makeArea(minZoom: 5, maxZoom: 8), + _makeArea(minZoom: 10, maxZoom: 14), + ]); + + // In first area's range + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 6), isTrue); + // In gap between areas + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 9), isFalse); + // In second area's range + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 13), isTrue); + // Beyond both areas + expect(service.hasOfflineAreasForProviderAtZoom('osm', 'standard', 15), isFalse); + }); + }); +} diff --git a/test/services/provider_tile_cache_store_test.dart b/test/services/provider_tile_cache_store_test.dart new file mode 100644 index 00000000..e0f974d7 --- /dev/null +++ b/test/services/provider_tile_cache_store_test.dart @@ -0,0 +1,517 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import 'package:deflockapp/services/provider_tile_cache_store.dart'; +import 'package:deflockapp/services/provider_tile_cache_manager.dart'; +import 'package:deflockapp/services/service_policy.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('tile_cache_test_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + await ProviderTileCacheManager.resetAll(); + }); + + group('ProviderTileCacheStore', () { + late ProviderTileCacheStore store; + + setUp(() { + store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + ); + }); + + test('isSupported is true', () { + expect(store.isSupported, isTrue); + }); + + test('getTile returns null for uncached URL', () async { + final result = await store.getTile('https://tile.example.com/1/2/3.png'); + expect(result, isNull); + }); + + test('putTile and getTile round-trip', () async { + const url = 'https://tile.example.com/1/2/3.png'; + final bytes = Uint8List.fromList([1, 2, 3, 4, 5]); + final staleAt = DateTime.utc(2026, 3, 1); + final metadata = CachedMapTileMetadata( + staleAt: staleAt, + lastModified: DateTime.utc(2026, 2, 20), + etag: '"abc123"', + ); + + await store.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); + expect( + cached.metadata.staleAt.millisecondsSinceEpoch, + equals(staleAt.millisecondsSinceEpoch), + ); + expect(cached.metadata.etag, equals('"abc123"')); + expect(cached.metadata.lastModified, isNotNull); + }); + + test('putTile without bytes updates metadata only', () async { + const url = 'https://tile.example.com/1/2/3.png'; + final bytes = Uint8List.fromList([1, 2, 3]); + final metadata1 = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: '"v1"', + ); + + // Write with bytes first + await store.putTile(url: url, metadata: metadata1, bytes: bytes); + + // Update metadata only + final metadata2 = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 4, 1), + lastModified: null, + etag: '"v2"', + ); + await store.putTile(url: url, metadata: metadata2); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.bytes, equals(bytes)); // bytes unchanged + expect(cached.metadata.etag, equals('"v2"')); // metadata updated + }); + + test('handles null lastModified and etag', () async { + const url = 'https://tile.example.com/simple.png'; + final bytes = Uint8List.fromList([10, 20, 30]); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + + await store.putTile(url: url, metadata: metadata, bytes: bytes); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect(cached!.metadata.lastModified, isNull); + expect(cached.metadata.etag, isNull); + }); + + test('creates cache directory lazily on first putTile', () async { + final subDir = p.join(tempDir.path, 'lazy', 'nested'); + final lazyStore = ProviderTileCacheStore(cacheDirectory: subDir); + + // Directory should not exist yet + expect(await Directory(subDir).exists(), isFalse); + + await lazyStore.putTile( + url: 'https://example.com/tile.png', + metadata: CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ), + bytes: Uint8List.fromList([1]), + ); + + // Directory should now exist + expect(await Directory(subDir).exists(), isTrue); + }); + + test('clear deletes all cached tiles', () async { + // Write some tiles + for (var i = 0; i < 5; i++) { + await store.putTile( + url: 'https://example.com/$i.png', + metadata: CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ), + bytes: Uint8List.fromList([i]), + ); + } + + // Verify tiles exist + expect(await store.getTile('https://example.com/0.png'), isNotNull); + + // Clear + await store.clear(); + + // Directory should be gone + expect(await Directory(tempDir.path).exists(), isFalse); + + // getTile should return null (directory gone) + expect(await store.getTile('https://example.com/0.png'), isNull); + }); + }); + + group('ProviderTileCacheStore TTL override', () { + test('overrideFreshAge bumps staleAt forward', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + overrideFreshAge: const Duration(days: 7), + ); + + const url = 'https://tile.example.com/osm.png'; + // Server says stale in 1 hour, but policy requires 7 days + final serverMetadata = CachedMapTileMetadata( + staleAt: DateTime.timestamp().add(const Duration(hours: 1)), + lastModified: null, + etag: null, + ); + + await store.putTile( + url: url, + metadata: serverMetadata, + bytes: Uint8List.fromList([1, 2, 3]), + ); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + + // staleAt should be ~7 days from now, not 1 hour + final expectedMin = DateTime.timestamp().add(const Duration(days: 6)); + expect(cached!.metadata.staleAt.isAfter(expectedMin), isTrue); + }); + + test('without overrideFreshAge, server staleAt is preserved', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + // No overrideFreshAge + ); + + const url = 'https://tile.example.com/bing.png'; + final serverStaleAt = DateTime.utc(2026, 3, 15, 12, 0); + final serverMetadata = CachedMapTileMetadata( + staleAt: serverStaleAt, + lastModified: null, + etag: null, + ); + + await store.putTile( + url: url, + metadata: serverMetadata, + bytes: Uint8List.fromList([1, 2, 3]), + ); + + final cached = await store.getTile(url); + expect(cached, isNotNull); + expect( + cached!.metadata.staleAt.millisecondsSinceEpoch, + equals(serverStaleAt.millisecondsSinceEpoch), + ); + }); + }); + + group('ProviderTileCacheStore isolation', () { + test('separate directories do not interfere', () async { + final dirA = p.join(tempDir.path, 'provider_a', 'type_1'); + final dirB = p.join(tempDir.path, 'provider_b', 'type_1'); + + final storeA = ProviderTileCacheStore(cacheDirectory: dirA); + final storeB = ProviderTileCacheStore(cacheDirectory: dirB); + + const url = 'https://tile.example.com/shared-url.png'; + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + + await storeA.putTile( + url: url, + metadata: metadata, + bytes: Uint8List.fromList([1, 1, 1]), + ); + await storeB.putTile( + url: url, + metadata: metadata, + bytes: Uint8List.fromList([2, 2, 2]), + ); + + final cachedA = await storeA.getTile(url); + final cachedB = await storeB.getTile(url); + + expect(cachedA!.bytes, equals(Uint8List.fromList([1, 1, 1]))); + expect(cachedB!.bytes, equals(Uint8List.fromList([2, 2, 2]))); + }); + }); + + group('ProviderTileCacheManager', () { + test('getOrCreate returns same instance for same key', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeA = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + final storeB = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + expect(identical(storeA, storeB), isTrue); + }); + + test('getOrCreate returns different instances for different keys', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeA = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + final storeB = ProviderTileCacheManager.getOrCreate( + providerId: 'bing', + tileTypeId: 'satellite', + policy: const ServicePolicy(), + ); + + expect(identical(storeA, storeB), isFalse); + }); + + test('passes overrideFreshAge from policy.minCacheTtl', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy.osmTileServer(), + ); + + expect(store.overrideFreshAge, equals(const Duration(days: 7))); + }); + + test('custom maxCacheBytes is applied', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store = ProviderTileCacheManager.getOrCreate( + providerId: 'big', + tileTypeId: 'tiles', + policy: const ServicePolicy(), + maxCacheBytes: 1024 * 1024 * 1024, // 1 GB + ); + + expect(store.maxCacheBytes, equals(1024 * 1024 * 1024)); + }); + + test('resetAll clears all stores from registry', () async { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final storeBefore = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + ProviderTileCacheManager.getOrCreate( + providerId: 'bing', + tileTypeId: 'satellite', + policy: const ServicePolicy(), + ); + + await ProviderTileCacheManager.resetAll(); + + // After reset, must set base dir again before creating stores + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + final storeAfter = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + // New instance should be created (not the old cached one) + expect(identical(storeBefore, storeAfter), isFalse); + }); + + test('unregister removes store from registry', () { + ProviderTileCacheManager.setBaseCacheDir(tempDir.path); + + final store1 = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + ProviderTileCacheManager.unregister('osm', 'street'); + + // Should create a new instance after unregistering + final store2 = ProviderTileCacheManager.getOrCreate( + providerId: 'osm', + tileTypeId: 'street', + policy: const ServicePolicy(), + ); + + expect(identical(store1, store2), isFalse); + }); + }); + + group('ProviderTileCacheStore eviction', () { + /// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes. + /// Sets deterministic modification times (1 second apart) so eviction + /// ordering is stable across platforms without relying on wall-clock delays. + Future fillCache( + ProviderTileCacheStore store, { + required int count, + required int bytesPerTile, + String prefix = '', + }) async { + final bytes = Uint8List.fromList(List.filled(bytesPerTile, 42)); + final metadata = CachedMapTileMetadata( + staleAt: DateTime.utc(2026, 3, 1), + lastModified: null, + etag: null, + ); + final baseTime = DateTime.utc(2026, 1, 1); + for (var i = 0; i < count; i++) { + await store.putTile( + url: 'https://tile.example.com/$prefix$i.png', + metadata: metadata, + bytes: bytes, + ); + // Set deterministic mtime so eviction order is stable across platforms. + final key = ProviderTileCacheStore.keyFor( + 'https://tile.example.com/$prefix$i.png', + ); + final tileFile = File(p.join(store.cacheDirectory, '$key.tile')); + final metaFile = File(p.join(store.cacheDirectory, '$key.meta')); + final mtime = baseTime.add(Duration(seconds: i)); + await tileFile.setLastModified(mtime); + await metaFile.setLastModified(mtime); + } + } + + test('eviction reduces cache when exceeding maxCacheBytes', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write tiles that exceed the limit + await fillCache(store, count: 10, bytesPerTile: 100); + + // Explicitly trigger eviction (bypasses throttle) + await store.forceEviction(); + + final sizeAfter = await store.estimatedSizeBytes; + expect(sizeAfter, lessThanOrEqualTo(500), + reason: 'Eviction should reduce cache to at or below limit'); + }); + + test('eviction targets 80% of maxCacheBytes', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 1000, + ); + + await fillCache(store, count: 10, bytesPerTile: 200); + await store.forceEviction(); + + final sizeAfter = await store.estimatedSizeBytes; + // Target is 80% of 1000 = 800 bytes + expect(sizeAfter, lessThanOrEqualTo(800), + reason: 'Eviction should target 80% of maxCacheBytes'); + }); + + test('oldest-modified tiles are evicted first', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write old tiles first (these should be evicted) + await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'old_'); + + // Write newer tiles (these should survive) + await fillCache(store, count: 5, bytesPerTile: 100, prefix: 'new_'); + + await store.forceEviction(); + + // Newest tile should still be present + final newestTile = await store.getTile('https://tile.example.com/new_4.png'); + expect(newestTile, isNotNull, + reason: 'Newest tiles should survive eviction'); + + // Oldest tile should have been evicted + final oldestTile = await store.getTile('https://tile.example.com/old_0.png'); + expect(oldestTile, isNull, + reason: 'Oldest tiles should be evicted first'); + }); + + test('orphan .meta files are cleaned up during eviction', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 500, + ); + + // Write a tile to create the directory + await fillCache(store, count: 1, bytesPerTile: 50); + + // Manually create an orphan .meta file (no matching .tile) + final orphanMetaFile = File(p.join(tempDir.path, 'orphan_key.meta')); + await orphanMetaFile.writeAsString('{"staleAt":0}'); + expect(await orphanMetaFile.exists(), isTrue); + + // Write enough tiles to exceed the limit, then force eviction + await fillCache(store, count: 10, bytesPerTile: 100, prefix: 'trigger_'); + await store.forceEviction(); + + // The orphan .meta file should have been cleaned up + expect(await orphanMetaFile.exists(), isFalse, + reason: 'Orphan .meta file should be cleaned up during eviction'); + }); + + test('evicted tiles have their .meta files removed too', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 300, + ); + + await fillCache(store, count: 10, bytesPerTile: 100); + await store.forceEviction(); + + // After eviction, count remaining .tile and .meta files + final dir = Directory(tempDir.path); + final files = await dir.list().toList(); + final tileFiles = files + .whereType() + .where((f) => f.path.endsWith('.tile')) + .length; + final metaFiles = files + .whereType() + .where((f) => f.path.endsWith('.meta')) + .length; + + // Every remaining .tile should have a matching .meta (1:1) + expect(metaFiles, equals(tileFiles), + reason: '.meta count should match .tile count after eviction'); + }); + + test('no eviction when cache is under limit', () async { + final store = ProviderTileCacheStore( + cacheDirectory: tempDir.path, + maxCacheBytes: 100000, // 100KB — way more than we'll write + ); + + await fillCache(store, count: 3, bytesPerTile: 50); + final sizeBefore = await store.estimatedSizeBytes; + + await store.forceEviction(); + final sizeAfter = await store.estimatedSizeBytes; + + expect(sizeAfter, equals(sizeBefore), + reason: 'No eviction needed when under limit'); + }); + }); +} diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart new file mode 100644 index 00000000..bfe31e41 --- /dev/null +++ b/test/services/service_policy_test.dart @@ -0,0 +1,426 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:deflockapp/services/service_policy.dart'; + +void main() { + group('ServicePolicyResolver', () { + setUp(() { + ServicePolicyResolver.clearCustomPolicies(); + }); + + group('resolveType', () { + test('resolves OSM editing API from production URL', () { + expect( + ServicePolicyResolver.resolveType('https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM editing API from sandbox URL', () { + expect( + ServicePolicyResolver.resolveType('https://api06.dev.openstreetmap.org/api/0.6/map?bbox=1,2,3,4'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM editing API from dev URL', () { + expect( + ServicePolicyResolver.resolveType('https://master.apis.dev.openstreetmap.org/api/0.6/user/details'), + ServiceType.osmEditingApi, + ); + }); + + test('resolves OSM tile server from tile URL', () { + expect( + ServicePolicyResolver.resolveType('https://tile.openstreetmap.org/12/1234/5678.png'), + ServiceType.osmTileServer, + ); + }); + + test('resolves Nominatim from geocoding URL', () { + expect( + ServicePolicyResolver.resolveType('https://nominatim.openstreetmap.org/search?q=London'), + ServiceType.nominatim, + ); + }); + + test('resolves Overpass API', () { + expect( + ServicePolicyResolver.resolveType('https://overpass-api.de/api/interpreter'), + ServiceType.overpass, + ); + }); + + test('resolves TagInfo', () { + expect( + ServicePolicyResolver.resolveType('https://taginfo.openstreetmap.org/api/4/key/values'), + ServiceType.tagInfo, + ); + }); + + test('resolves Bing tiles from virtualearth URL', () { + expect( + ServicePolicyResolver.resolveType('https://ecn.t0.tiles.virtualearth.net/tiles/a12345.jpeg'), + ServiceType.bingTiles, + ); + }); + + test('resolves Mapbox tiles', () { + expect( + ServicePolicyResolver.resolveType('https://api.mapbox.com/v4/mapbox.satellite/12/1234/5678@2x.jpg90'), + ServiceType.mapboxTiles, + ); + }); + + test('returns custom for unknown host', () { + expect( + ServicePolicyResolver.resolveType('https://tiles.myserver.com/12/1234/5678.png'), + ServiceType.custom, + ); + }); + + test('returns custom for empty string', () { + expect( + ServicePolicyResolver.resolveType(''), + ServiceType.custom, + ); + }); + + test('returns custom for malformed URL', () { + expect( + ServicePolicyResolver.resolveType('not-a-url'), + ServiceType.custom, + ); + }); + }); + + group('resolve', () { + test('OSM tile server policy allows offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('OSM tile server policy requires 7-day min cache TTL', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.minCacheTtl, const Duration(days: 7)); + }); + + test('OSM tile server has attribution URL', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('Nominatim policy enforces 1-second rate limit', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.minRequestInterval, const Duration(seconds: 1)); + }); + + test('Nominatim policy requires client caching', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.requiresClientCaching, true); + }); + + test('Nominatim has attribution URL', () { + final policy = ServicePolicyResolver.resolve( + 'https://nominatim.openstreetmap.org/search?q=test', + ); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('OSM editing API allows max 2 concurrent requests', () { + final policy = ServicePolicyResolver.resolve( + 'https://api.openstreetmap.org/api/0.6/map?bbox=1,2,3,4', + ); + expect(policy.maxConcurrentRequests, 2); + }); + + test('Bing tiles allow offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://ecn.t0.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('Mapbox tiles allow offline download', () { + final policy = ServicePolicyResolver.resolve( + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('custom/unknown host gets permissive defaults', () { + final policy = ServicePolicyResolver.resolve( + 'https://tiles.myserver.com/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + expect(policy.minRequestInterval, isNull); + expect(policy.requiresClientCaching, false); + expect(policy.attributionUrl, isNull); + }); + }); + + group('resolve with URL templates', () { + test('handles {z}/{x}/{y} template variables', () { + final policy = ServicePolicyResolver.resolve( + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('handles {quadkey} template variable', () { + final policy = ServicePolicyResolver.resolve( + 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1', + ); + expect(policy.allowsOfflineDownload, true); + }); + + test('handles {0_3} subdomain template', () { + final type = ServicePolicyResolver.resolveType( + 'https://ecn.t{0_3}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg', + ); + expect(type, ServiceType.bingTiles); + }); + + test('handles {api_key} template variable', () { + final type = ServicePolicyResolver.resolveType( + 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + ); + expect(type, ServiceType.mapboxTiles); + }); + }); + + group('custom policy overrides', () { + test('custom override takes precedence over built-in', () { + ServicePolicyResolver.registerCustomPolicy( + 'overpass-api.de', + const ServicePolicy.custom(maxConcurrent: 20, allowsOffline: true), + ); + + final policy = ServicePolicyResolver.resolve( + 'https://overpass-api.de/api/interpreter', + ); + expect(policy.maxConcurrentRequests, 20); + }); + + test('custom policy for self-hosted tiles allows offline', () { + ServicePolicyResolver.registerCustomPolicy( + 'tiles.myserver.com', + const ServicePolicy.custom(allowsOffline: true, maxConcurrent: 16), + ); + + final policy = ServicePolicyResolver.resolve( + 'https://tiles.myserver.com/{z}/{x}/{y}.png', + ); + expect(policy.allowsOfflineDownload, true); + expect(policy.maxConcurrentRequests, 16); + }); + + test('removing custom override restores built-in policy', () { + ServicePolicyResolver.registerCustomPolicy( + 'overpass-api.de', + const ServicePolicy.custom(maxConcurrent: 20), + ); + expect( + ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, + 20, + ); + + ServicePolicyResolver.removeCustomPolicy('overpass-api.de'); + // Should fall back to built-in Overpass policy (maxConcurrent: 0 = managed elsewhere) + expect( + ServicePolicyResolver.resolve('https://overpass-api.de/api/interpreter').maxConcurrentRequests, + 0, + ); + }); + + test('clearCustomPolicies removes all overrides', () { + ServicePolicyResolver.registerCustomPolicy('a.com', const ServicePolicy.custom(maxConcurrent: 1)); + ServicePolicyResolver.registerCustomPolicy('b.com', const ServicePolicy.custom(maxConcurrent: 2)); + + ServicePolicyResolver.clearCustomPolicies(); + + // Both should now return custom (default) policy + expect( + ServicePolicyResolver.resolve('https://a.com/test').maxConcurrentRequests, + 8, // default custom maxConcurrent + ); + }); + }); + }); + + group('ServiceRateLimiter', () { + setUp(() { + ServiceRateLimiter.reset(); + }); + + test('acquire and release work for editing API (2 concurrent)', () async { + // Should be able to acquire 2 slots without blocking + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + // Release both + ServiceRateLimiter.release(ServiceType.osmEditingApi); + ServiceRateLimiter.release(ServiceType.osmEditingApi); + }); + + test('third acquire blocks until a slot is released', () async { + // Fill both slots (osmEditingApi maxConcurrentRequests = 2) + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + await ServiceRateLimiter.acquire(ServiceType.osmEditingApi); + + // Third acquire should block + var thirdCompleted = false; + final thirdFuture = ServiceRateLimiter.acquire(ServiceType.osmEditingApi).then((_) { + thirdCompleted = true; + }); + + // Give microtasks a chance to run — third should still be blocked + await Future.delayed(Duration.zero); + expect(thirdCompleted, false); + + // Release one slot — third should now complete + ServiceRateLimiter.release(ServiceType.osmEditingApi); + await thirdFuture; + expect(thirdCompleted, true); + + // Clean up + ServiceRateLimiter.release(ServiceType.osmEditingApi); + ServiceRateLimiter.release(ServiceType.osmEditingApi); + }); + + test('Nominatim rate limiting delays rapid requests', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var acquireCount = 0; + + // First request should be immediate + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + async.flushMicrotasks(); + expect(acquireCount, 1); + + // Second request should be delayed by ~1 second + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + async.flushMicrotasks(); + expect(acquireCount, 1, reason: 'second acquire should be blocked'); + + // Advance past the 1-second rate limit + async.elapse(const Duration(seconds: 1)); + expect(acquireCount, 2, reason: 'second acquire should have completed'); + }); + }); + + test('services with no rate limit pass through immediately', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var acquireCount = 0; + + // Overpass has maxConcurrentRequests: 0, so acquire should not apply + // any artificial rate limiting delays. + ServiceRateLimiter.acquire(ServiceType.overpass).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.overpass); + }); + async.flushMicrotasks(); + expect(acquireCount, 1); + + ServiceRateLimiter.acquire(ServiceType.overpass).then((_) { + acquireCount++; + ServiceRateLimiter.release(ServiceType.overpass); + }); + async.flushMicrotasks(); + expect(acquireCount, 2); + }); + }); + + test('Nominatim enforces min interval under concurrent callers', () { + fakeAsync((async) { + ServiceRateLimiter.clock = () => async.getClock(DateTime(2026)).now(); + + var completedCount = 0; + + // Start two concurrent callers; only one should run at a time and + // the minRequestInterval of ~1s should still be enforced. + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + completedCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + ServiceRateLimiter.acquire(ServiceType.nominatim).then((_) { + completedCount++; + ServiceRateLimiter.release(ServiceType.nominatim); + }); + + async.flushMicrotasks(); + expect(completedCount, 1, reason: 'only first caller should complete immediately'); + + // Advance past the 1-second rate limit + async.elapse(const Duration(seconds: 1)); + expect(completedCount, 2, reason: 'second caller should complete after interval'); + }); + }); + }); + + group('ServicePolicy', () { + test('osmTileServer policy has correct values', () { + const policy = ServicePolicy.osmTileServer(); + expect(policy.allowsOfflineDownload, true); + expect(policy.minCacheTtl, const Duration(days: 7)); + expect(policy.requiresClientCaching, true); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + expect(policy.maxConcurrentRequests, 0); // managed by flutter_map + }); + + test('nominatim policy has correct values', () { + const policy = ServicePolicy.nominatim(); + expect(policy.minRequestInterval, const Duration(seconds: 1)); + expect(policy.maxConcurrentRequests, 1); + expect(policy.requiresClientCaching, true); + expect(policy.attributionUrl, 'https://www.openstreetmap.org/copyright'); + }); + + test('osmEditingApi policy has correct values', () { + const policy = ServicePolicy.osmEditingApi(); + expect(policy.maxConcurrentRequests, 2); + expect(policy.minRequestInterval, isNull); + }); + + test('custom policy uses permissive defaults', () { + const policy = ServicePolicy(); + expect(policy.maxConcurrentRequests, 8); + expect(policy.allowsOfflineDownload, true); + expect(policy.minRequestInterval, isNull); + expect(policy.requiresClientCaching, false); + expect(policy.minCacheTtl, isNull); + expect(policy.attributionUrl, isNull); + }); + + test('custom policy accepts overrides', () { + const policy = ServicePolicy.custom( + maxConcurrent: 20, + allowsOffline: false, + attribution: 'https://example.com/license', + ); + expect(policy.maxConcurrentRequests, 20); + expect(policy.allowsOfflineDownload, false); + expect(policy.attributionUrl, 'https://example.com/license'); + }); + }); +} diff --git a/test/services/tiles_from_local_test.dart b/test/services/tiles_from_local_test.dart new file mode 100644 index 00000000..767647f3 --- /dev/null +++ b/test/services/tiles_from_local_test.dart @@ -0,0 +1,227 @@ +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart' show LatLngBounds; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:deflockapp/services/map_data_submodules/tiles_from_local.dart'; +import 'package:deflockapp/services/offline_areas/offline_tile_utils.dart'; + +void main() { + group('normalizeBounds', () { + test('swapped corners are normalized', () { + // NE as first arg, SW as second (swapped) + final swapped = LatLngBounds( + const LatLng(52.0, 1.0), // NE corner passed as SW + const LatLng(51.0, -1.0), // SW corner passed as NE + ); + final normalized = normalizeBounds(swapped); + expect(normalized.south, closeTo(51.0, 1e-6)); + expect(normalized.north, closeTo(52.0, 1e-6)); + expect(normalized.west, closeTo(-1.0, 1e-6)); + expect(normalized.east, closeTo(1.0, 1e-6)); + }); + + test('degenerate (zero-width) bounds are expanded', () { + final point = LatLngBounds( + const LatLng(51.5, -0.1), + const LatLng(51.5, -0.1), + ); + final normalized = normalizeBounds(point); + expect(normalized.south, lessThan(51.5)); + expect(normalized.north, greaterThan(51.5)); + expect(normalized.west, lessThan(-0.1)); + expect(normalized.east, greaterThan(-0.1)); + }); + + test('already-normalized bounds are unchanged', () { + final normal = LatLngBounds( + const LatLng(40.0, -10.0), + const LatLng(60.0, 30.0), + ); + final normalized = normalizeBounds(normal); + expect(normalized.south, closeTo(40.0, 1e-6)); + expect(normalized.north, closeTo(60.0, 1e-6)); + expect(normalized.west, closeTo(-10.0, 1e-6)); + expect(normalized.east, closeTo(30.0, 1e-6)); + }); + }); + + group('tileInBounds', () { + /// Helper: compute expected tile range for [bounds] at [z] using the same + /// Mercator projection math and return whether (x, y) is within range. + bool referenceTileInBounds( + LatLngBounds bounds, int z, int x, int y) { + final n = pow(2.0, z); + final minX = ((bounds.west + 180.0) / 360.0 * n).floor(); + final maxX = ((bounds.east + 180.0) / 360.0 * n).floor(); + final minY = ((1.0 - + log(tan(bounds.north * pi / 180.0) + + 1.0 / cos(bounds.north * pi / 180.0)) / + pi) / + 2.0 * + n) + .floor(); + final maxY = ((1.0 - + log(tan(bounds.south * pi / 180.0) + + 1.0 / cos(bounds.south * pi / 180.0)) / + pi) / + 2.0 * + n) + .floor(); + return x >= minX && x <= maxX && y >= minY && y <= maxY; + } + + test('zoom 0: single tile covers the whole world', () { + final world = LatLngBounds( + const LatLng(-85, -180), + const LatLng(85, 180), + ); + expect(tileInBounds(world, 0, 0, 0), isTrue); + }); + + test('zoom 1: London area covers NW and NE quadrants', () { + // Bounds straddling the prime meridian in the northern hemisphere + final londonArea = LatLngBounds( + const LatLng(51.0, -1.0), + const LatLng(52.0, 1.0), + ); + + // NW quadrant (x=0, y=0) — should be in bounds + expect(tileInBounds(londonArea, 1, 0, 0), isTrue); + // NE quadrant (x=1, y=0) — should be in bounds + expect(tileInBounds(londonArea, 1, 1, 0), isTrue); + // SW quadrant (x=0, y=1) — southern hemisphere, out of bounds + expect(tileInBounds(londonArea, 1, 0, 1), isFalse); + // SE quadrant (x=1, y=1) — southern hemisphere, out of bounds + expect(tileInBounds(londonArea, 1, 1, 1), isFalse); + }); + + test('zoom 2: London area covers specific tiles', () { + final londonArea = LatLngBounds( + const LatLng(51.0, -1.0), + const LatLng(52.0, 1.0), + ); + + // Expected: X 1-2, Y 1 + expect(tileInBounds(londonArea, 2, 1, 1), isTrue); + expect(tileInBounds(londonArea, 2, 2, 1), isTrue); + // Outside X range + expect(tileInBounds(londonArea, 2, 0, 1), isFalse); + expect(tileInBounds(londonArea, 2, 3, 1), isFalse); + // Outside Y range + expect(tileInBounds(londonArea, 2, 1, 0), isFalse); + expect(tileInBounds(londonArea, 2, 1, 2), isFalse); + }); + + test('southern hemisphere: Sydney area', () { + final sydneyArea = LatLngBounds( + const LatLng(-34.0, 151.0), + const LatLng(-33.5, 151.5), + ); + + // At zoom 1, Sydney is in the SE quadrant (x=1, y=1) + expect(tileInBounds(sydneyArea, 1, 1, 1), isTrue); + expect(tileInBounds(sydneyArea, 1, 0, 0), isFalse); + expect(tileInBounds(sydneyArea, 1, 0, 1), isFalse); + expect(tileInBounds(sydneyArea, 1, 1, 0), isFalse); + }); + + test('western hemisphere: NYC area at zoom 4', () { + final nycArea = LatLngBounds( + const LatLng(40.5, -74.5), + const LatLng(41.0, -73.5), + ); + + // At zoom 4 (16x16), NYC should be around x=4-5, y=6 + // x = floor((-74.5+180)/360 * 16) = floor(105.5/360*16) = floor(4.69) = 4 + // x = floor((-73.5+180)/360 * 16) = floor(106.5/360*16) = floor(4.73) = 4 + // So x range is just 4 + expect(tileInBounds(nycArea, 4, 4, 6), isTrue); + expect(tileInBounds(nycArea, 4, 5, 6), isFalse); + expect(tileInBounds(nycArea, 4, 3, 6), isFalse); + }); + + test('higher zoom: smaller area at zoom 10', () { + // Small area around central London + final centralLondon = LatLngBounds( + const LatLng(51.49, -0.13), + const LatLng(51.52, -0.08), + ); + + // Compute expected tile range at zoom 10 using reference + const z = 10; + final n = pow(2.0, z); + final expectedMinX = + ((-0.13 + 180.0) / 360.0 * n).floor(); + final expectedMaxX = + ((-0.08 + 180.0) / 360.0 * n).floor(); + + // Tiles inside the computed range should be in bounds + for (var x = expectedMinX; x <= expectedMaxX; x++) { + expect( + referenceTileInBounds(centralLondon, z, x, 340), + equals(tileInBounds(centralLondon, z, x, 340)), + reason: 'Mismatch at tile ($x, 340, $z)', + ); + } + + // Tiles outside X range should not be in bounds + expect(tileInBounds(centralLondon, z, expectedMinX - 1, 340), isFalse); + expect(tileInBounds(centralLondon, z, expectedMaxX + 1, 340), isFalse); + }); + + test('tile exactly at boundary is included', () { + // Bounds whose edges align exactly with tile boundaries at zoom 1 + // At zoom 1: x=0 covers lon -180 to 0, x=1 covers lon 0 to 180 + final halfWorld = LatLngBounds( + const LatLng(0.0, 0.0), + const LatLng(60.0, 180.0), + ); + + // Tile (1, 0, 1) should be in bounds (NE quadrant) + expect(tileInBounds(halfWorld, 1, 1, 0), isTrue); + }); + + test('anti-meridian: bounds crossing 180° longitude', () { + // Bounds from eastern Russia (170°E) to Alaska (170°W = -170°) + // After normalization, west=170 east=-170 which is swapped — + // normalizeBounds will swap to west=-170 east=170, which covers + // nearly the whole world. This is the expected behavior since + // LatLngBounds doesn't support anti-meridian wrapping. + final antiMeridian = normalizeBounds(LatLngBounds( + const LatLng(50.0, 170.0), + const LatLng(70.0, -170.0), + )); + + // After normalization, west=-170 east=170 (covers most longitudes) + // At zoom 2, tiles 0-3 along X axis + // Since the normalized bounds cover lon -170 to 170 (340° of 360°), + // almost all tiles should be in bounds + expect(tileInBounds(antiMeridian, 2, 0, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 1, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 2, 0), isTrue); + expect(tileInBounds(antiMeridian, 2, 3, 0), isTrue); + }); + + test('exhaustive check at zoom 3 matches reference', () { + final bounds = LatLngBounds( + const LatLng(40.0, -10.0), + const LatLng(60.0, 30.0), + ); + + // Check all 64 tiles at zoom 3 against reference implementation + const z = 3; + final tilesPerSide = pow(2, z).toInt(); + for (var x = 0; x < tilesPerSide; x++) { + for (var y = 0; y < tilesPerSide; y++) { + expect( + tileInBounds(bounds, z, x, y), + equals(referenceTileInBounds(bounds, z, x, y)), + reason: 'Mismatch at tile ($x, $y, $z)', + ); + } + } + }); + }); +} diff --git a/test/state/settings_state_test.dart b/test/state/settings_state_test.dart new file mode 100644 index 00000000..8e56a242 --- /dev/null +++ b/test/state/settings_state_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/state/settings_state.dart'; +import 'package:deflockapp/keys.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('kHasOsmSecrets (no --dart-define)', () { + test('is false when built without secrets', () { + expect(kHasOsmSecrets, isFalse); + }); + + test('client ID getters return empty strings instead of throwing', () { + expect(kOsmProdClientId, isEmpty); + expect(kOsmSandboxClientId, isEmpty); + }); + }); + + group('SettingsState without secrets', () { + test('defaults to simulate mode', () { + final state = SettingsState(); + expect(state.uploadMode, UploadMode.simulate); + }); + + test('init() forces simulate even if prefs has production stored', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.production.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + + // Verify it persisted the override + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('upload_mode'), UploadMode.simulate.index); + }); + + test('init() forces simulate even if prefs has sandbox stored', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.sandbox.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + }); + + test('init() keeps simulate if already simulate', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.simulate.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + }); + + test('setUploadMode() allows simulate', () async { + final state = SettingsState(); + await state.setUploadMode(UploadMode.simulate); + + expect(state.uploadMode, UploadMode.simulate); + }); + }); +} diff --git a/test/widgets/map/tile_layer_manager_test.dart b/test/widgets/map/tile_layer_manager_test.dart new file mode 100644 index 00000000..3a9183ba --- /dev/null +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -0,0 +1,618 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:deflockapp/models/tile_provider.dart' as models; +import 'package:deflockapp/services/deflock_tile_provider.dart'; +import 'package:deflockapp/widgets/map/tile_layer_manager.dart'; + +class MockTileImage extends Mock implements TileImage {} + +void main() { + group('TileLayerManager exponential backoff', () { + test('initial retry delay is 2 seconds', () { + final manager = TileLayerManager(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + manager.dispose(); + }); + + test('scheduleRetry fires reset stream after delay', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.scheduleRetry(); + + expect(resets, isEmpty); + async.elapse(const Duration(seconds: 1)); + expect(resets, isEmpty); + async.elapse(const Duration(seconds: 1)); + expect(resets, hasLength(1)); + + manager.dispose(); + }); + }); + + test('delay doubles after each retry fires', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // First retry: 2s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + expect(manager.retryDelay, equals(const Duration(seconds: 4))); + + // Second retry: 4s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Third retry: 8s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 8)); + expect(manager.retryDelay, equals(const Duration(seconds: 16))); + + manager.dispose(); + }); + }); + + test('delay caps at 60 seconds', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // Drive through cycles: 2 → 4 → 8 → 16 → 32 → 60 → 60 + var currentDelay = manager.retryDelay; + while (currentDelay < const Duration(seconds: 60)) { + manager.scheduleRetry(); + async.elapse(currentDelay); + currentDelay = manager.retryDelay; + } + + // Should be capped at 60s + expect(manager.retryDelay, equals(const Duration(seconds: 60))); + + // Another cycle stays at 60s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 60)); + expect(manager.retryDelay, equals(const Duration(seconds: 60))); + + manager.dispose(); + }); + }); + + test('onTileLoadSuccess resets delay to minimum', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + // Drive up the delay + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + expect(manager.retryDelay, equals(const Duration(seconds: 4))); + + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Reset on success + manager.onTileLoadSuccess(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + + manager.dispose(); + }); + }); + + test('rapid errors debounce: only last timer fires', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + // Fire 3 errors in quick succession (each cancels the previous timer) + manager.scheduleRetry(); + async.elapse(const Duration(milliseconds: 500)); + manager.scheduleRetry(); + async.elapse(const Duration(milliseconds: 500)); + manager.scheduleRetry(); + + // 1s elapsed total since first error, but last timer started 0ms ago + // Need to wait 2s from *last* scheduleRetry call + async.elapse(const Duration(seconds: 1)); + expect(resets, isEmpty, reason: 'Timer should not fire yet'); + async.elapse(const Duration(seconds: 1)); + expect(resets, hasLength(1), reason: 'Only one reset should fire'); + + manager.dispose(); + }); + }); + + test('delay stays at minimum if no retries have fired', () { + final manager = TileLayerManager(); + // Just calling onTileLoadSuccess without any errors + manager.onTileLoadSuccess(); + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + manager.dispose(); + }); + + test('backoff progression: 2 → 4 → 8 → 16 → 32 → 60 → 60', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + manager.resetStream.listen((_) {}); + + final expectedDelays = [ + const Duration(seconds: 2), + const Duration(seconds: 4), + const Duration(seconds: 8), + const Duration(seconds: 16), + const Duration(seconds: 32), + const Duration(seconds: 60), + const Duration(seconds: 60), // capped + ]; + + for (var i = 0; i < expectedDelays.length; i++) { + expect(manager.retryDelay, equals(expectedDelays[i]), + reason: 'Step $i'); + manager.scheduleRetry(); + async.elapse(expectedDelays[i]); + } + + manager.dispose(); + }); + }); + + test('dispose cancels pending retry timer', () { + FakeAsync().run((async) { + final manager = TileLayerManager(); + final resets = []; + late StreamSubscription sub; + sub = manager.resetStream.listen((_) => resets.add(null)); + + manager.scheduleRetry(); + // Dispose before timer fires + sub.cancel(); + manager.dispose(); + + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty, reason: 'Timer should be cancelled by dispose'); + }); + }); + }); + + group('TileLayerManager checkAndClearCacheIfNeeded', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + test('first call triggers clear (initial null differs from provided values)', () { + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // First call: internal state is (null, null, false) → (osm, street, false) + // provider null→osm triggers clear. Harmless: no tiles to clear yet. + expect(result, isTrue); + }); + + test('same values on second call returns false', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(result, isFalse); + }); + + test('different provider triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('different tile type triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('different offline mode triggers cache clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: true, + ); + expect(result, isTrue); + }); + + test('cache clear increments mapRebuildKey', () { + final initialKey = manager.mapRebuildKey; + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // First call increments (null → osm) + expect(manager.mapRebuildKey, equals(initialKey + 1)); + + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ); + // Type change should increment again + expect(manager.mapRebuildKey, equals(initialKey + 2)); + }); + + test('no cache clear does not increment mapRebuildKey', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + final keyAfterFirst = manager.mapRebuildKey; + + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + expect(manager.mapRebuildKey, equals(keyAfterFirst)); + }); + + test('null to non-null transition triggers clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: null, + currentTileTypeId: null, + currentOfflineMode: false, + ); + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + // null → osm is a change — triggers clear so stale tiles are flushed + expect(result, isTrue); + }); + + test('non-null to null to non-null triggers clear both times', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + // Provider goes null (e.g., during reload) + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: null, + currentTileTypeId: null, + currentOfflineMode: false, + ), + isTrue, + ); + + // Provider returns — should still trigger clear + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ), + isTrue, + ); + }); + + test('switching back and forth triggers clear each time', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'satellite', + currentOfflineMode: false, + ), + isTrue, + ); + + expect( + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ), + isTrue, + ); + }); + + test('switching providers with same tile type triggers clear', () { + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'standard', + currentOfflineMode: false, + ); + + final result = manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'standard', + currentOfflineMode: false, + ); + expect(result, isTrue); + }); + + test('provider switch resets retry delay and cancels pending timer', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + // Escalate backoff: 2s → 4s → 8s + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 2)); + manager.scheduleRetry(); + async.elapse(const Duration(seconds: 4)); + expect(manager.retryDelay, equals(const Duration(seconds: 8))); + + // Start another retry timer (hasn't fired yet) + manager.scheduleRetry(); + + // Switch provider — should reset delay and cancel pending timer + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'osm', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + manager.checkAndClearCacheIfNeeded( + currentProviderId: 'bing', + currentTileTypeId: 'street', + currentOfflineMode: false, + ); + + expect(manager.retryDelay, equals(const Duration(seconds: 2))); + + // The pending 8s timer should have been cancelled + final resetsBefore = resets.length; + async.elapse(const Duration(seconds: 10)); + expect(resets.length, equals(resetsBefore), + reason: 'Old retry timer should be cancelled on provider switch'); + }); + }); + }); + + group('TileLayerManager config drift detection', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + models.TileProvider makeProvider({String? apiKey}) => models.TileProvider( + id: 'test_provider', + name: 'Test', + apiKey: apiKey, + tileTypes: [], + ); + + models.TileType makeTileType({ + String urlTemplate = 'https://example.com/{z}/{x}/{y}.png', + int maxZoom = 18, + }) => + models.TileType( + id: 'test_tile', + name: 'Test', + urlTemplate: urlTemplate, + attribution: 'Test', + maxZoom: maxZoom, + ); + + test('returns same provider for identical config', () { + final provider = makeProvider(); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isTrue, + reason: 'Same config should return the cached provider instance', + ); + }); + + test('replaces provider when urlTemplate changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType( + urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png', + ); + final tileTypeV2 = makeTileType( + urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png', + ); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed urlTemplate should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate, + 'https://new.example.com/{z}/{x}/{y}.png', + ); + }); + + test('replaces provider when apiKey changes', () { + final providerV1 = makeProvider(apiKey: 'old_key'); + final providerV2 = makeProvider(apiKey: 'new_key'); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: providerV1, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: providerV2, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed apiKey should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).apiKey, + 'new_key', + ); + }); + + test('replaces provider when maxZoom changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType(maxZoom: 18); + final tileTypeV2 = makeTileType(maxZoom: 20); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed maxZoom should create a new provider', + ); + }); + }); + + group('TileLayerManager error-type filtering', () { + late TileLayerManager manager; + late MockTileImage mockTile; + + setUp(() { + manager = TileLayerManager(); + mockTile = MockTileImage(); + when(() => mockTile.coordinates) + .thenReturn(const TileCoordinates(1, 2, 3)); + }); + + tearDown(() { + manager.dispose(); + }); + + test('skips retry for TileLoadCancelledException', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const TileLoadCancelledException(), + null, + ); + + // Even after waiting well past the retry delay, no reset should fire. + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty); + }); + }); + + test('skips retry for TileNotAvailableOfflineException', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const TileNotAvailableOfflineException(), + null, + ); + + async.elapse(const Duration(seconds: 10)); + expect(resets, isEmpty); + }); + }); + + test('schedules retry for other errors (e.g. HttpException)', () { + FakeAsync().run((async) { + final resets = []; + manager.resetStream.listen((_) => resets.add(null)); + + manager.onTileLoadError( + mockTile, + const HttpException('tile fetch failed'), + null, + ); + + // Should fire after the initial 2s retry delay. + async.elapse(const Duration(seconds: 2)); + expect(resets, hasLength(1)); + }); + }); + }); +}