Get rid of double cache layer, remove tiles from network status indicator, fix status callbacks from split fetches, use tileprovider instead of http catching.

This commit is contained in:
stopflock
2025-11-24 18:28:36 -06:00
parent 2b2349dd16
commit 45f1635e10
20 changed files with 445 additions and 194 deletions
+49 -12
View File
@@ -309,7 +309,32 @@ Local cache contains production data. Showing production nodes in sandbox mode w
**Why separate from follow mode:**
Users often want to follow their location while keeping the map oriented north. Previous "north up" follow mode was confusing because it didn't actually keep north up. This separation provides clear, predictable behavior.
### 9. Suspected Locations
### 9. Network Status Indicator (Simplified in v1.5.2+)
**Purpose**: Show loading and error states for surveillance data fetching only
**Simplified approach (v1.5.2+):**
- **Surveillance data focus**: Only tracks node/camera data loading, not tile loading
- **Visual feedback**: Tiles show their own loading progress naturally
- **Reduced complexity**: Eliminated tile completion tracking and multiple issue types
**Status types:**
- **Loading**: Shows when fetching surveillance data from APIs
- **Success**: Brief confirmation when data loads successfully
- **Timeout**: Network request timeouts
- **Limit reached**: When node display limit is hit
- **API issues**: Overpass/OSM API problems only
**What was removed:**
- Tile server issue tracking (tiles handle their own progress)
- "Both" network issue type (only surveillance data matters)
- Complex semaphore-based completion detection
- Tile-related status messages and localizations
**Why the change:**
The previous approach tracked both tile loading and surveillance data, creating redundancy since tiles already show loading progress visually on the map. Users don't need to be notified about tile loading issues when they can see tiles loading/failing directly. Focusing only on surveillance data makes the indicator more purposeful and less noisy.
### 11. Suspected Locations
**Data pipeline:**
- **CSV ingestion**: Downloads utility permit data from alprwatch.org
@@ -327,7 +352,7 @@ Users often want to follow their location while keeping the map oriented north.
**Why utility permits:**
Utility companies often must file permits when installing surveillance infrastructure. This creates a paper trail that can indicate potential surveillance sites before devices are confirmed through direct observation.
### 10. Upload Mode Simplification
### 12. Upload Mode Simplification
**Release vs Debug builds:**
- **Release builds**: Production OSM only (simplified UX)
@@ -340,11 +365,22 @@ Most users should contribute to production; testing modes add complexity
bool get showUploadModeSelector => kDebugMode;
```
### 11. Tile Provider System & URL Templates
### 13. Tile Provider System & Clean Architecture (v1.5.2+)
**Design approach:**
**Architecture (post-v1.5.2):**
- **Custom TileProvider**: Clean Flutter Map integration using `DeflockTileProvider`
- **Direct MapDataProvider integration**: Tiles go through existing offline/online routing
- **No HTTP interception**: Eliminated fake URLs and complex HTTP clients
- **Simplified caching**: Single cache layer (FlutterMap's internal cache)
**Key components:**
- `DeflockTileProvider`: Custom Flutter Map TileProvider implementation
- `DeflockTileImageProvider`: Handles tile fetching through MapDataProvider
- Automatic offline/online routing: Uses `MapSource.auto` for each tile
**Tile provider configuration:**
- **Flexible URL templates**: Support multiple coordinate systems and load-balancing patterns
- **Built-in providers**: Curated set of high-quality, reliable tile sources
- **Built-in providers**: Curated set of high-quality, reliable tile sources
- **Custom providers**: Users can add any tile service with full validation
- **API key management**: Secure storage with per-provider API keys
@@ -352,7 +388,7 @@ bool get showUploadModeSelector => kDebugMode;
```
{x}, {y}, {z} - Standard TMS tile coordinates
{quadkey} - Bing Maps quadkey format (alternative to x/y/z)
{0_3} - Subdomain 0-3 for load balancing
{0_3} - Subdomain 0-3 for load balancing
{1_4} - Subdomain 1-4 for providers using 1-based indexing
{api_key} - API key insertion point (optional)
```
@@ -363,13 +399,14 @@ bool get showUploadModeSelector => kDebugMode;
- **Mapbox**: Satellite and street tiles, requires API key
- **OpenTopoMap**: Topographic maps, no API key required
**Validation logic:**
URL templates must contain either `{quadkey}` OR all of `{x}`, `{y}`, and `{z}`. This allows for both standard tile services and specialized formats like Bing Maps.
**Why the architectural change:**
The previous HTTP interception approach (`SimpleTileHttpClient` with fake URLs) fought against Flutter Map's architecture and created unnecessary complexity. The new `TileProvider` approach:
- **Cleaner integration**: Works with Flutter Map's design instead of against it
- **Simpler caching**: One cache layer instead of multiple conflicting systems
- **Better error handling**: Graceful fallbacks for missing tiles
- **Reduced complexity**: Eliminated semaphores, queue management, and tile completion tracking
**Why this approach:**
Provides maximum flexibility while maintaining simplicity. Users can add any tile service without code changes, while built-in providers offer immediate functionality. The quadkey system enables access to high-quality satellite imagery without API key requirements.
### 12. Navigation & Routing (Implemented, Awaiting Integration)
### 14. Navigation & Routing (Implemented, Awaiting Integration)
**Current state:**
- **Search functionality**: Fully implemented and active
-1
View File
@@ -101,7 +101,6 @@ cp lib/keys.dart.example lib/keys.dart
- Max nodes not working
- Update node cache to reflect cleared queue entries
- Are offline areas preferred for fast loading even when online? Check working.
- Fix network indicator - only done when fetch queue is empty!
### Current Development
- Decide what to do for extracting nodes attached to a way/relation:
+10
View File
@@ -1,4 +1,14 @@
{
"1.5.2": {
"content": [
"• IMPROVED: Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation",
"• IMPROVED: Network status indicator now focuses only on surveillance data loading, not tile loading (tiles show their own progress)",
"• IMPROVED: Reduced complexity in cache management and state tracking",
"• FIXED: Tile cache properly clears when switching between tile providers/types - no more mixed tiles",
"• FIXED: Network status indicator no longer shows false timeouts during surveillance data splitting operations",
"• FIXED: Eliminated potential conflicts between multiple cache layers"
]
},
"1.5.1": {
"content": [
"• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required",
+6 -8
View File
@@ -377,15 +377,13 @@
},
"networkStatus": {
"showIndicator": "Netzwerkstatus-Anzeige anzeigen",
"showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen",
"loading": "Lädt...",
"timedOut": "Zeitüberschreitung",
"noData": "Keine Kacheln hier",
"success": "Fertig",
"showIndicatorSubtitle": "Ladestatus und Fehler für Überwachungsdaten anzeigen",
"loading": "Lade Überwachungsdaten...",
"timedOut": "Anfrage Zeitüberschreitung",
"noData": "Keine Offline-Daten",
"success": "Überwachungsdaten geladen",
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
"tileProviderSlow": "Kartenanbieter langsam",
"nodeDataSlow": "Knotendaten langsam",
"networkIssues": "Netzwerkprobleme"
"nodeDataSlow": "Überwachungsdaten langsam"
},
"about": {
"title": "DeFlock - Überwachungs-Transparenz",
+6 -8
View File
@@ -409,15 +409,13 @@
},
"networkStatus": {
"showIndicator": "Show network status indicator",
"showIndicatorSubtitle": "Display network loading and error status on the map",
"loading": "Loading...",
"timedOut": "Timed out",
"noData": "No tiles here",
"success": "Done",
"showIndicatorSubtitle": "Display surveillance data loading and error status",
"loading": "Loading surveillance data...",
"timedOut": "Request timed out",
"noData": "No offline data",
"success": "Surveillance data loaded",
"nodeLimitReached": "Showing limit - increase in settings",
"tileProviderSlow": "Tile provider slow",
"nodeDataSlow": "Node data slow",
"networkIssues": "Network issues"
"nodeDataSlow": "Surveillance data slow"
},
"navigation": {
"searchLocation": "Search Location",
+6 -8
View File
@@ -409,15 +409,13 @@
},
"networkStatus": {
"showIndicator": "Mostrar indicador de estado de red",
"showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa",
"loading": "Cargando...",
"timedOut": "Tiempo agotado",
"noData": "Sin mosaicos aquí",
"success": "Hecho",
"showIndicatorSubtitle": "Mostrar estado de carga y errores de datos de vigilancia",
"loading": "Cargando datos de vigilancia...",
"timedOut": "Solicitud agotada",
"noData": "Sin datos sin conexión",
"success": "Datos de vigilancia cargados",
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
"tileProviderSlow": "Proveedor de mosaicos lento",
"nodeDataSlow": "Datos de nodo lentos",
"networkIssues": "Problemas de red"
"nodeDataSlow": "Datos de vigilancia lentos"
},
"navigation": {
"searchLocation": "Buscar ubicación",
+6 -8
View File
@@ -409,15 +409,13 @@
},
"networkStatus": {
"showIndicator": "Afficher l'indicateur de statut réseau",
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte",
"loading": "Chargement...",
"timedOut": "Temps dépassé",
"noData": "Aucune tuile ici",
"success": "Terminé",
"showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur des données de surveillance",
"loading": "Chargement des données de surveillance...",
"timedOut": "Demande expirée",
"noData": "Aucune donnée hors ligne",
"success": "Données de surveillance chargées",
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
"tileProviderSlow": "Fournisseur de tuiles lent",
"nodeDataSlow": "Données de nœud lentes",
"networkIssues": "Problèmes réseau"
"nodeDataSlow": "Données de surveillance lentes"
},
"navigation": {
"searchLocation": "Rechercher lieu",
+6 -8
View File
@@ -409,15 +409,13 @@
},
"networkStatus": {
"showIndicator": "Mostra indicatore di stato di rete",
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa",
"loading": "Caricamento...",
"timedOut": "Tempo scaduto",
"noData": "Nessuna tessera qui",
"success": "Fatto",
"showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori dei dati di sorveglianza",
"loading": "Caricamento dati di sorveglianza...",
"timedOut": "Richiesta scaduta",
"noData": "Nessun dato offline",
"success": "Dati di sorveglianza caricati",
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
"tileProviderSlow": "Provider di tessere lento",
"nodeDataSlow": "Dati del nodo lenti",
"networkIssues": "Problemi di rete"
"nodeDataSlow": "Dati di sorveglianza lenti"
},
"navigation": {
"searchLocation": "Cerca posizione",
+6 -8
View File
@@ -409,15 +409,13 @@
},
"networkStatus": {
"showIndicator": "Exibir indicador de status de rede",
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa",
"loading": "Carregando...",
"timedOut": "Tempo esgotado",
"noData": "Nenhum tile aqui",
"success": "Concluído",
"showIndicatorSubtitle": "Mostrar status de carregamento e erro de dados de vigilância",
"loading": "Carregando dados de vigilância...",
"timedOut": "Solicitação expirada",
"noData": "Nenhum dado offline",
"success": "Dados de vigilância carregados",
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
"tileProviderSlow": "Provedor de tiles lento",
"nodeDataSlow": "Dados do nó lentos",
"networkIssues": "Problemas de rede"
"nodeDataSlow": "Dados de vigilância lentos"
},
"navigation": {
"searchLocation": "Buscar localização",
+6 -8
View File
@@ -409,15 +409,13 @@
},
"networkStatus": {
"showIndicator": "显示网络状态指示器",
"showIndicatorSubtitle": "在地图上显示网络加载和错误状态",
"loading": "加载...",
"timedOut": "超时",
"noData": "这里没有瓦片",
"success": "完成",
"showIndicatorSubtitle": "显示监控数据加载和错误状态",
"loading": "加载监控数据...",
"timedOut": "请求超时",
"noData": "无离线数据",
"success": "监控数据已加载",
"nodeLimitReached": "显示限制 - 在设置中增加",
"tileProviderSlow": "瓦片提供商缓慢",
"nodeDataSlow": "节点数据缓慢",
"networkIssues": "网络问题"
"nodeDataSlow": "监控数据缓慢"
},
"navigation": {
"searchLocation": "搜索位置",
+127
View File
@@ -0,0 +1,127 @@
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui';
import '../app_state.dart';
import '../models/tile_provider.dart' as models;
import 'map_data_provider.dart';
/// Custom tile provider that integrates with DeFlock's offline/online architecture.
///
/// This replaces the complex HTTP interception approach with a clean TileProvider
/// implementation that directly interfaces with our MapDataProvider system.
class DeflockTileProvider extends TileProvider {
final MapDataProvider _mapDataProvider = MapDataProvider();
@override
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
// Get current provider info to include in cache key
final appState = AppState.instance;
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
return DeflockTileImageProvider(
coordinates: coordinates,
options: options,
mapDataProvider: _mapDataProvider,
providerId: providerId,
tileTypeId: tileTypeId,
);
}
}
/// Image provider that fetches tiles through our MapDataProvider.
///
/// This handles the actual tile fetching using our existing offline/online
/// routing logic without any HTTP interception complexity.
class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
final TileCoordinates coordinates;
final TileLayer options;
final MapDataProvider mapDataProvider;
final String providerId;
final String tileTypeId;
const DeflockTileImageProvider({
required this.coordinates,
required this.options,
required this.mapDataProvider,
required this.providerId,
required this.tileTypeId,
});
@override
Future<DeflockTileImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<DeflockTileImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(DeflockTileImageProvider key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode, chunkEvents),
chunkEvents: chunkEvents.stream,
scale: 1.0,
);
}
Future<Codec> _loadAsync(
DeflockTileImageProvider key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
try {
// Get current tile provider and type from app state
final appState = AppState.instance;
final selectedProvider = appState.selectedTileProvider;
final selectedTileType = appState.selectedTileType;
if (selectedProvider == null || selectedTileType == null) {
throw Exception('No tile provider configured');
}
// Fetch tile through our existing MapDataProvider system
// This automatically handles offline/online routing, caching, etc.
final tileBytes = await mapDataProvider.getTile(
z: coordinates.z,
x: coordinates.x,
y: coordinates.y,
source: MapSource.auto, // Use auto routing (offline first, then online)
);
// Decode the image bytes
final buffer = await ImmutableBuffer.fromUint8List(Uint8List.fromList(tileBytes));
return await decode(buffer);
} catch (e) {
// Log error for debugging but don't spam network status
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e');
// Return a transparent 1x1 pixel tile for missing tiles
// This is more graceful than throwing and prevents cascade failures
final transparentPixel = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]);
final buffer = await ImmutableBuffer.fromUint8List(transparentPixel);
return await decode(buffer);
}
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is DeflockTileImageProvider &&
other.coordinates == coordinates &&
other.providerId == providerId &&
other.tileTypeId == tileTypeId;
}
@override
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
}
@@ -20,6 +20,45 @@ Future<List<OsmNode>> fetchOsmApiNodes({
}) async {
if (profiles.isEmpty) return [];
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
try {
final nodes = await _fetchFromOsmApi(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
);
// Only report success at the top level if this was user-initiated
if (wasUserInitiated) {
NetworkStatus.instance.setSuccess();
}
return nodes;
} catch (e) {
// Only report errors at the top level if this was user-initiated
if (wasUserInitiated) {
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
}
debugPrint('[fetchOsmApiNodes] OSM API operation failed: $e');
return [];
}
}
/// Internal method that performs the actual OSM API fetch.
Future<List<OsmNode>> _fetchFromOsmApi({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
required int maxResults,
}) async {
// Choose API endpoint based on upload mode
final String apiHost = uploadMode == UploadMode.sandbox
? 'api06.dev.openstreetmap.org'
@@ -41,8 +80,7 @@ Future<List<OsmNode>> fetchOsmApiNodes({
if (response.statusCode != 200) {
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
NetworkStatus.instance.reportOverpassIssue(); // Reuse same status tracking
return [];
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
}
// Parse XML response
@@ -53,20 +91,14 @@ Future<List<OsmNode>> fetchOsmApiNodes({
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
}
NetworkStatus.instance.reportOverpassSuccess(); // Reuse same status tracking
// Don't report success here - let the top level handle it
return nodes;
} catch (e) {
debugPrint('[fetchOsmApiNodes] Exception: $e');
// Report network issues for connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
// Don't report status here - let the top level handle it
throw e; // Re-throw to let caller handle
}
}
@@ -23,14 +23,35 @@ Future<List<OsmNode>> fetchOverpassNodes({
// Check if this is a user-initiated fetch (indicated by loading state)
final wasUserInitiated = NetworkStatus.instance.currentStatus == NetworkStatusType.waiting;
return _fetchOverpassNodesWithSplitting(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
splitDepth: 0,
wasUserInitiated: wasUserInitiated,
);
try {
final nodes = await _fetchOverpassNodesWithSplitting(
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: maxResults,
splitDepth: 0,
reportStatus: wasUserInitiated, // Only top level reports status
);
// Only report success at the top level if this was user-initiated
if (wasUserInitiated) {
NetworkStatus.instance.setSuccess();
}
return nodes;
} catch (e) {
// Only report errors at the top level if this was user-initiated
if (wasUserInitiated) {
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
}
debugPrint('[fetchOverpassNodes] Top-level operation failed: $e');
return [];
}
}
/// Internal method that handles splitting when node limit is exceeded.
@@ -40,7 +61,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
UploadMode uploadMode = UploadMode.production,
required int maxResults,
required int splitDepth,
required bool wasUserInitiated,
required bool reportStatus, // Only true for top level
}) async {
if (profiles.isEmpty) return [];
@@ -51,28 +72,20 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
bounds: bounds,
profiles: profiles,
maxResults: maxResults,
reportStatus: reportStatus,
);
} on OverpassRateLimitException catch (e) {
// Rate limits should NOT be split - just fail with extended backoff
debugPrint('[fetchOverpassNodes] Rate limited - using extended backoff, not splitting');
// Report error if user was waiting
if (wasUserInitiated) {
NetworkStatus.instance.setNetworkError();
}
// Wait longer for rate limits before giving up entirely
await Future.delayed(const Duration(seconds: 30));
return []; // Return empty rather than rethrowing
return []; // Return empty rather than rethrowing - let caller handle error reporting
} on OverpassNodeLimitException {
// If we've hit max split depth, give up to avoid infinite recursion
if (splitDepth >= maxSplitDepth) {
debugPrint('[fetchOverpassNodes] Max split depth reached, giving up on area: $bounds');
// Report timeout if this was user-initiated (can't split further)
if (wasUserInitiated) {
NetworkStatus.instance.setTimeoutError();
}
return [];
return []; // Return empty - let caller handle error reporting
}
// Split the bounds into 4 quadrants and try each separately
@@ -87,7 +100,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
uploadMode: uploadMode,
maxResults: 0, // No limit on individual quadrants to avoid double-limiting
splitDepth: splitDepth + 1,
wasUserInitiated: wasUserInitiated,
reportStatus: false, // Sub-requests don't report status
);
allNodes.addAll(nodes);
}
@@ -102,6 +115,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
required int maxResults,
required bool reportStatus,
}) async {
const String overpassEndpoint = 'https://overpass-api.de/api/interpreter';
@@ -146,8 +160,8 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
throw OverpassRateLimitException('Rate limited by server', serverResponse: errorBody);
}
NetworkStatus.instance.reportOverpassIssue();
return [];
// Don't report status here - let the top level handle it
throw Exception('Overpass API error: $errorBody');
}
final data = await compute(jsonDecode, response.body) as Map<String, dynamic>;
@@ -157,7 +171,7 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
debugPrint('[fetchOverpassNodes] Retrieved ${elements.length} elements (nodes + ways/relations)');
}
NetworkStatus.instance.reportOverpassSuccess();
// Don't report success here - let the top level handle it
// Parse response to determine which nodes are constrained
final nodes = _parseOverpassResponseWithConstraints(elements);
@@ -173,14 +187,8 @@ Future<List<OsmNode>> _fetchSingleOverpassQuery({
debugPrint('[fetchOverpassNodes] Exception: $e');
// Report network issues for connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOverpassIssue();
}
return [];
// Don't report status here - let the top level handle it
throw e; // Re-throw to let caller handle
}
}
@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:deflockapp/dev_config.dart';
import '../network_status.dart';
/// Global semaphore to limit simultaneous tile fetches
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
@@ -121,21 +120,12 @@ Future<List<int>> fetchRemoteTile({
if (attempt > 1) {
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
}
NetworkStatus.instance.reportOsmTileSuccess();
return resp.bodyBytes;
} else {
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
NetworkStatus.instance.reportOsmTileIssue();
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
}
} catch (e) {
// Report network issues on connection errors
if (e.toString().contains('Connection refused') ||
e.toString().contains('Connection timed out') ||
e.toString().contains('Connection reset')) {
NetworkStatus.instance.reportOsmTileIssue();
}
// Calculate delay and retry (no attempt limit - keep trying forever)
final delay = _calculateRetryDelay(attempt, random);
if (attempt == 1) {
+2 -35
View File
@@ -3,7 +3,7 @@ import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { osmTiles, overpassApi, both }
enum NetworkIssueType { overpassApi }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
@@ -12,14 +12,12 @@ class NetworkStatus extends ChangeNotifier {
static final NetworkStatus instance = NetworkStatus._();
NetworkStatus._();
bool _osmTilesHaveIssues = false;
bool _overpassHasIssues = false;
bool _isWaitingForData = false;
bool _isTimedOut = false;
bool _hasNoData = false;
bool _hasSuccess = false;
int _recentOfflineMisses = 0;
Timer? _osmRecoveryTimer;
Timer? _overpassRecoveryTimer;
Timer? _waitingTimer;
Timer? _noDataResetTimer;
@@ -28,8 +26,7 @@ class NetworkStatus extends ChangeNotifier {
Timer? _nodeLimitResetTimer;
// Getters
bool get hasAnyIssues => _osmTilesHaveIssues || _overpassHasIssues;
bool get osmTilesHaveIssues => _osmTilesHaveIssues;
bool get hasAnyIssues => _overpassHasIssues;
bool get overpassHasIssues => _overpassHasIssues;
bool get isWaitingForData => _isWaitingForData;
bool get isTimedOut => _isTimedOut;
@@ -49,29 +46,10 @@ class NetworkStatus extends ChangeNotifier {
}
NetworkIssueType? get currentIssueType {
if (_osmTilesHaveIssues && _overpassHasIssues) return NetworkIssueType.both;
if (_osmTilesHaveIssues) return NetworkIssueType.osmTiles;
if (_overpassHasIssues) return NetworkIssueType.overpassApi;
return null;
}
/// Report tile server issues (for any provider)
void reportOsmTileIssue() {
if (!_osmTilesHaveIssues) {
_osmTilesHaveIssues = true;
notifyListeners();
debugPrint('[NetworkStatus] Tile server issues detected');
}
// Reset recovery timer - if we keep getting errors, keep showing indicator
_osmRecoveryTimer?.cancel();
_osmRecoveryTimer = Timer(const Duration(minutes: 2), () {
_osmTilesHaveIssues = false;
notifyListeners();
debugPrint('[NetworkStatus] Tile server issues cleared');
});
}
/// Report Overpass API issues
void reportOverpassIssue() {
if (!_overpassHasIssues) {
@@ -90,16 +68,6 @@ class NetworkStatus extends ChangeNotifier {
}
/// Report successful operations to potentially clear issues faster
void reportOsmTileSuccess() {
// Clear issues immediately on success (they were likely temporary)
if (_osmTilesHaveIssues) {
// Quietly clear - don't log routine success
_osmTilesHaveIssues = false;
_osmRecoveryTimer?.cancel();
notifyListeners();
}
}
void reportOverpassSuccess() {
if (_overpassHasIssues) {
// Quietly clear - don't log routine success
@@ -271,7 +239,6 @@ class NetworkStatus extends ChangeNotifier {
@override
void dispose() {
_osmRecoveryTimer?.cancel();
_overpassRecoveryTimer?.cancel();
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
+3 -8
View File
@@ -139,20 +139,15 @@ class PrefetchAreaService {
_preFetchedUploadMode = uploadMode;
_lastFetchTime = DateTime.now();
// Report completion to network status (only if user was waiting)
NetworkStatus.instance.setSuccess();
// The overpass module already reported success/failure during fetching
// We just need to handle the successful result here
// Notify UI that cache has been updated with fresh data
CameraProviderWithCache.instance.refreshDisplay();
} catch (e) {
debugPrint('[PrefetchAreaService] Pre-fetch failed: $e');
// Report failure to network status (only if user was waiting)
if (e.toString().contains('timeout') || e.toString().contains('timed out')) {
NetworkStatus.instance.setTimeoutError();
} else {
NetworkStatus.instance.setNetworkError();
}
// The overpass module already reported the error status
// Don't update pre-fetched area info on failure
} finally {
_preFetchInProgress = false;
+24 -18
View File
@@ -3,12 +3,12 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/tile_provider.dart' as models;
import '../../services/simple_tile_service.dart';
import '../../services/deflock_tile_provider.dart';
/// Manages tile layer creation, caching, and provider switching.
/// Handles tile HTTP client lifecycle and cache invalidation.
/// Uses DeFlock's custom tile provider for clean integration.
class TileLayerManager {
late final SimpleTileHttpClient _tileHttpClient;
DeflockTileProvider? _tileProvider;
int _mapRebuildKey = 0;
String? _lastTileTypeId;
bool? _lastOfflineMode;
@@ -18,12 +18,12 @@ class TileLayerManager {
/// Initialize the tile layer manager
void initialize() {
_tileHttpClient = SimpleTileHttpClient();
// Don't create tile provider here - create it fresh for each build
}
/// Dispose of resources
void dispose() {
_tileHttpClient.close();
// No resources to dispose with the new tile provider
}
/// Check if cache should be cleared and increment rebuild key if needed.
@@ -46,6 +46,8 @@ class TileLayerManager {
if (shouldClear) {
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
// Also force new tile provider instance to ensure fresh cache
_tileProvider = null;
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
}
@@ -57,38 +59,42 @@ class TileLayerManager {
/// Clear the tile request queue (call after cache clear)
void clearTileQueue() {
debugPrint('[TileLayerManager] Post-frame: Clearing tile request queue');
_tileHttpClient.clearTileQueue();
// With the new tile provider, 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');
}
/// Clear tile queue immediately (for zoom changes, etc.)
void clearTileQueueImmediate() {
_tileHttpClient.clearTileQueue();
// No immediate clearing needed with the new architecture
// FlutterMap handles this naturally
}
/// Clear only tiles that are no longer visible in the current bounds
void clearStaleRequests({required LatLngBounds currentBounds}) {
_tileHttpClient.clearStaleRequests(currentBounds);
// No selective clearing needed with the new architecture
// FlutterMap's internal caching is efficient enough
}
/// Build tile layer widget with current provider and type.
/// Uses fake domain that SimpleTileHttpClient can parse for cache separation.
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
Widget buildTileLayer({
required models.TileProvider? selectedProvider,
required models.TileType? selectedTileType,
}) {
// Use fake domain with standard HTTPS scheme: https://tiles.local/provider/type/z/x/y
// This naturally separates cache entries by provider and type while being HTTP-compatible
final urlTemplate = 'https://tiles.local/${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
// Create a fresh tile provider instance if we don't have one or cache was cleared
_tileProvider ??= DeflockTileProvider();
// Use provider/type info in URL template for FlutterMap's cache key generation
// This ensures different providers/types get different cache keys
final urlTemplate = '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
return TileLayer(
urlTemplate: urlTemplate,
urlTemplate: urlTemplate, // Critical for cache key generation
userAgentPackageName: 'me.deflock.deflockapp',
maxZoom: selectedTileType?.maxZoom?.toDouble() ?? 18.0,
tileProvider: NetworkTileProvider(
httpClient: _tileHttpClient,
// Enable flutter_map caching - cache busting handled by URL changes and FlutterMap key
),
tileProvider: _tileProvider!,
);
}
}
-10
View File
@@ -52,21 +52,11 @@ class NetworkStatusIndicator extends StatelessWidget {
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.osmTiles:
message = locService.t('networkStatus.tileProviderSlow');
icon = Icons.map_outlined;
color = Colors.orange;
break;
case NetworkIssueType.overpassApi:
message = locService.t('networkStatus.nodeDataSlow');
icon = Icons.camera_alt_outlined;
color = Colors.orange;
break;
case NetworkIssueType.both:
message = locService.t('networkStatus.networkIssues');
icon = Icons.cloud_off_outlined;
color = Colors.red;
break;
default:
return const SizedBox.shrink();
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.5.1+19 # The thing after the + is the version code, incremented with each release
version: 1.5.2+20 # The thing after the + is the version code, incremented with each release
environment:
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../lib/services/deflock_tile_provider.dart';
import '../../lib/services/map_data_provider.dart';
void main() {
group('DeflockTileProvider', () {
late DeflockTileProvider provider;
setUp(() {
provider = DeflockTileProvider();
});
test('creates image provider for tile coordinates', () {
const coordinates = TileCoordinates(0, 0, 0);
const options = TileLayer(
urlTemplate: 'test/{z}/{x}/{y}',
);
final imageProvider = provider.getImage(coordinates, options);
expect(imageProvider, isA<DeflockTileImageProvider>());
expect((imageProvider as DeflockTileImageProvider).coordinates, equals(coordinates));
});
});
group('DeflockTileImageProvider', () {
test('generates consistent keys for same coordinates', () {
const coordinates1 = TileCoordinates(1, 2, 3);
const coordinates2 = TileCoordinates(1, 2, 3);
const coordinates3 = TileCoordinates(1, 2, 4);
const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final mapDataProvider = MapDataProvider();
final provider1 = DeflockTileImageProvider(
coordinates: coordinates1,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'test_provider',
tileTypeId: 'test_type',
);
final provider2 = DeflockTileImageProvider(
coordinates: coordinates2,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'test_provider',
tileTypeId: 'test_type',
);
final provider3 = DeflockTileImageProvider(
coordinates: coordinates3,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'test_provider',
tileTypeId: 'test_type',
);
// Same coordinates should be equal
expect(provider1, equals(provider2));
expect(provider1.hashCode, equals(provider2.hashCode));
// Different coordinates should not be equal
expect(provider1, isNot(equals(provider3)));
});
test('generates different keys for different providers/types', () {
const coordinates = TileCoordinates(1, 2, 3);
const options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final mapDataProvider = MapDataProvider();
final provider1 = DeflockTileImageProvider(
coordinates: coordinates,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'provider_a',
tileTypeId: 'type_1',
);
final provider2 = DeflockTileImageProvider(
coordinates: coordinates,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'provider_b',
tileTypeId: 'type_1',
);
final provider3 = DeflockTileImageProvider(
coordinates: coordinates,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'provider_a',
tileTypeId: 'type_2',
);
// Different providers should not be equal (even with same coordinates)
expect(provider1, isNot(equals(provider2)));
expect(provider1.hashCode, isNot(equals(provider2.hashCode)));
// Different tile types should not be equal (even with same coordinates and provider)
expect(provider1, isNot(equals(provider3)));
expect(provider1.hashCode, isNot(equals(provider3.hashCode)));
});
});
}