Get rid of double cache, filesystem checking for every tile fetch, swap out http interception for a fluttermap tileprovider that calls map_data, fix node rendering limit

This commit is contained in:
stopflock
2025-11-28 21:48:17 -06:00
parent 153377e9e6
commit df0377b41f
19 changed files with 228 additions and 45 deletions

View File

@@ -402,9 +402,22 @@ bool get showUploadModeSelector => kDebugMode;
**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
- **Smart cache routing**: Only checks offline cache when needed, eliminating expensive filesystem searches
- **Better error handling**: Graceful fallbacks for missing tiles
- **Reduced complexity**: Eliminated semaphores, queue management, and tile completion tracking
- **Cross-platform performance**: Optimizations that work well on both iOS and Android
**Tile Loading Performance Fix (v1.5.2):**
The major performance issue was discovered to be double caching with expensive operations:
1. **Problem**: Every tile request checked offline areas via filesystem I/O, even when no offline data existed
2. **Solution**: Smart cache detection - only check offline cache when in offline mode OR when offline areas actually exist for the current provider
3. **Result**: Dramatically improved tile loading from 0.5-5 tiles/sec back to ~70 tiles/sec for normal browsing
**Cross-Platform Optimizations:**
- **Request deduplication**: Prevents multiple simultaneous requests for identical tile coordinates
- **Optimized retry timing**: Faster initial retry (150ms vs 200ms) with shorter backoff for quicker recovery
- **Queue size limits**: Maximum 100 queued requests to prevent memory bloat
- **Smart queue management**: Drops oldest requests when queue fills up
- **Reduced concurrent connections**: 8 threads instead of 10 for better stability across platforms
### 14. Navigation & Routing (Implemented, Awaiting Integration)

View File

@@ -1,14 +1,18 @@
{
"1.5.2": {
"content": [
"• MAJOR: Fixed severe tile loading performance issue - eliminated expensive filesystem searches on every tile request",
"• IMPROVED: Smart cache routing - only checks offline cache when actually needed, dramatically improving browsing speed",
"• IMPROVED: Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation",
"• IMPROVED: Cross-platform tile performance optimizations - better retry timing, request deduplication, queue management",
"• IMPROVED: Network status indicator now focuses only on surveillance data loading, not tile loading (tiles show their own progress)",
"• IMPROVED: Node limit behavior - buttons now show helpful messages instead of being disabled, encouraging users to zoom in for safe editing",
"• IMPROVED: Indicator positioning - network status and node limit indicators automatically move down when search bar is visible",
"• IMPROVED: Reduced complexity in cache management and state tracking",
"• FIXED: Max nodes setting now correctly limits rendering only (not data fetching) to prevent UI lag",
"• FIXED: New node limit indicator shows when not all devices are displayed due to rendering limit",
"• 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"
"• FIXED: Network status indicator no longer shows false timeouts during surveillance data splitting operations"
]
},
"1.5.1": {

View File

@@ -125,11 +125,12 @@ const double kPinchMoveThreshold = 30.0; // How much drag required for two-finge
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
const int kTileFetchConcurrentThreads = 10; // Number of simultaneous tile downloads
const int kTileFetchInitialDelayMs = 200; // Base delay for first retry (500ms)
const double kTileFetchBackoffMultiplier = 1.5; // Multiply delay by this each attempt
const int kTileFetchMaxDelayMs = 5000; // Cap delays at this value (10 seconds max)
const int kTileFetchRandomJitterMs = 100; // Random fuzz to add (0 to 250ms)
const int kTileFetchConcurrentThreads = 8; // Reduced from 10 to 8 for better cross-platform performance
const int kTileFetchInitialDelayMs = 150; // Reduced from 200ms for faster retries
const double kTileFetchBackoffMultiplier = 1.4; // Slightly reduced for faster recovery
const int kTileFetchMaxDelayMs = 4000; // Reduced from 5000ms for faster max retry
const int kTileFetchRandomJitterMs = 50; // Reduced jitter for more predictable timing
const int kTileFetchMaxQueueSize = 100; // Reasonable queue size to prevent memory bloat
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
@@ -165,3 +166,4 @@ double getNodeRingThickness(BuildContext context) {
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
return _kNodeRingThicknessBase;
}

View File

@@ -385,7 +385,8 @@
"nodeDataSlow": "Überwachungsdaten langsam"
},
"nodeLimitIndicator": {
"message": "Zeige {rendered} von {total} Geräten"
"message": "Zeige {rendered} von {total} Geräten",
"editingDisabledMessage": "Zu viele Geräte sichtbar für sicheres Bearbeiten. Vergrößern Sie die Ansicht, um die Anzahl sichtbarer Geräte zu reduzieren, und versuchen Sie es erneut."
},
"about": {
"title": "DeFlock - Überwachungs-Transparenz",

View File

@@ -417,7 +417,8 @@
"nodeDataSlow": "Surveillance data slow"
},
"nodeLimitIndicator": {
"message": "Showing {rendered} of {total} devices"
"message": "Showing {rendered} of {total} devices",
"editingDisabledMessage": "Too many devices shown to safely edit. Zoom in further to reduce the number of visible devices, then try again."
},
"navigation": {
"searchLocation": "Search Location",

View File

@@ -417,7 +417,8 @@
"nodeDataSlow": "Datos de vigilancia lentos"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos"
"message": "Mostrando {rendered} de {total} dispositivos",
"editingDisabledMessage": "Demasiados dispositivos visibles para editar con seguridad. Acerque más para reducir el número de dispositivos visibles, luego inténtelo de nuevo."
},
"navigation": {
"searchLocation": "Buscar ubicación",

View File

@@ -417,7 +417,8 @@
"nodeDataSlow": "Données de surveillance lentes"
},
"nodeLimitIndicator": {
"message": "Affichage de {rendered} sur {total} appareils"
"message": "Affichage de {rendered} sur {total} appareils",
"editingDisabledMessage": "Trop d'appareils visibles pour éditer en toute sécurité. Zoomez davantage pour réduire le nombre d'appareils visibles, puis réessayez."
},
"navigation": {
"searchLocation": "Rechercher lieu",

View File

@@ -417,7 +417,8 @@
"nodeDataSlow": "Dati di sorveglianza lenti"
},
"nodeLimitIndicator": {
"message": "Mostra {rendered} di {total} dispositivi"
"message": "Mostra {rendered} di {total} dispositivi",
"editingDisabledMessage": "Troppi dispositivi visibili per modificare in sicurezza. Ingrandisci per ridurre il numero di dispositivi visibili, poi riprova."
},
"navigation": {
"searchLocation": "Cerca posizione",

View File

@@ -417,7 +417,8 @@
"nodeDataSlow": "Dados de vigilância lentos"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos"
"message": "Mostrando {rendered} de {total} dispositivos",
"editingDisabledMessage": "Muitos dispositivos visíveis para editar com segurança. Aproxime mais para reduzir o número de dispositivos visíveis, e tente novamente."
},
"navigation": {
"searchLocation": "Buscar localização",

View File

@@ -417,7 +417,8 @@
"nodeDataSlow": "监控数据缓慢"
},
"nodeLimitIndicator": {
"message": "显示 {rendered} / {total} 设备"
"message": "显示 {rendered} / {total} 设备",
"editingDisabledMessage": "可见设备过多,无法安全编辑。请放大地图以减少可见设备数量,然后重试。"
},
"navigation": {
"searchLocation": "搜索位置",

View File

@@ -115,6 +115,22 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
LocalizationService.instance.t('editNode.zoomInRequiredMessage',
params: [kMinZoomForNodeEditingSheets.toString()])
),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
return;
}
// Check if node limit is active and warn user
if (_isNodeLimitActive) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocalizationService.instance.t('nodeLimitIndicator.editingDisabledMessage')
),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
return;
@@ -775,7 +791,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _isNodeLimitActive ? null : _openAddNodeSheet,
onPressed: _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),

View File

@@ -8,6 +8,7 @@ import 'dart:ui';
import '../app_state.dart';
import '../models/tile_provider.dart' as models;
import 'map_data_provider.dart';
import 'offline_area_service.dart';
/// Custom tile provider that integrates with DeFlock's offline/online architecture.
///
@@ -83,13 +84,16 @@ class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
throw Exception('No tile provider configured');
}
// Fetch tile through our existing MapDataProvider system
// This automatically handles offline/online routing, caching, etc.
// Smart cache routing: only check offline cache when needed
final MapSource source = _shouldCheckOfflineCache(appState)
? MapSource.auto // Check offline first, then network
: MapSource.remote; // Skip offline cache, go directly to network
final tileBytes = await mapDataProvider.getTile(
z: coordinates.z,
x: coordinates.x,
y: coordinates.y,
source: MapSource.auto, // Use auto routing (offline first, then online)
source: source,
);
// Decode the image bytes
@@ -119,4 +123,36 @@ class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
@override
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
/// Determine if we should check offline cache for this tile request.
/// Only check offline cache if:
/// 1. We're in offline mode (forced), OR
/// 2. We have offline areas for the current provider/type
///
/// This avoids expensive filesystem searches when browsing online
/// with providers that have no offline areas.
bool _shouldCheckOfflineCache(AppState appState) {
// Always check offline cache in offline mode
if (appState.offlineMode) {
return true;
}
// For online mode, only check if we might actually have relevant offline data
final currentProvider = appState.selectedTileProvider;
final currentTileType = appState.selectedTileType;
if (currentProvider == null || currentTileType == null) {
return false;
}
// Quick check: do we have any offline areas for this provider/type?
// This avoids the expensive per-tile filesystem search in fetchLocalTile
final offlineService = OfflineAreaService();
final hasRelevantAreas = offlineService.hasOfflineAreasForProvider(
currentProvider.id,
currentTileType.id,
);
return hasRelevantAreas;
}
}

View File

@@ -34,7 +34,7 @@ void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
/// Calculate retry delay using configurable backoff strategy.
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
int _calculateRetryDelay(int attempt, Random random) {
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
// Calculate exponential backoff
final baseDelay = (kTileFetchInitialDelayMs *
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
@@ -136,7 +136,7 @@ Future<List<int>> fetchRemoteTile({
}
await Future.delayed(Duration(milliseconds: delay));
} finally {
_tileFetchSemaphore.release();
_tileFetchSemaphore.release(z: z, x: x, y: y);
}
}
}
@@ -164,18 +164,40 @@ class _TileRequest {
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
}
/// Spatially-aware counting semaphore for tile requests
/// Spatially-aware counting semaphore for tile requests with deduplication
class _SimpleSemaphore {
final int _max;
int _current = 0;
final List<_TileRequest> _queue = [];
final Set<String> _inFlightTiles = {}; // Track in-flight requests for deduplication
_SimpleSemaphore(this._max);
Future<void> acquire({int? z, int? x, int? y}) async {
// Create tile key for deduplication
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
// If this tile is already in flight, skip the request
if (_inFlightTiles.contains(tileKey)) {
debugPrint('[SimpleSemaphore] Skipping duplicate request for $tileKey');
return;
}
// Add to in-flight tracking
_inFlightTiles.add(tileKey);
if (_current < _max) {
_current++;
return;
} else {
// Check queue size limit to prevent memory bloat
if (_queue.length >= kTileFetchMaxQueueSize) {
// Remove oldest request to make room
final oldRequest = _queue.removeAt(0);
final oldKey = '${oldRequest.z}/${oldRequest.x}/${oldRequest.y}';
_inFlightTiles.remove(oldKey);
debugPrint('[SimpleSemaphore] Queue full, dropped oldest request: $oldKey');
}
final c = Completer<void>();
final request = _TileRequest(
z: z ?? -1,
@@ -188,7 +210,11 @@ class _SimpleSemaphore {
}
}
void release() {
void release({int? z, int? x, int? y}) {
// Remove from in-flight tracking
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
_inFlightTiles.remove(tileKey);
if (_queue.isNotEmpty) {
final request = _queue.removeAt(0);
request.callback();
@@ -201,19 +227,37 @@ class _SimpleSemaphore {
int clearQueue() {
final clearedCount = _queue.length;
_queue.clear();
_inFlightTiles.clear(); // Also clear deduplication tracking
return clearedCount;
}
/// Clear only tiles that don't pass the visibility filter
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
final initialCount = _queue.length;
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
final clearedCount = initialCount - _queue.length;
final initialInFlightCount = _inFlightTiles.length;
if (clearedCount > 0) {
debugPrint('[SimpleSemaphore] Cleared $clearedCount stale tile requests, kept ${_queue.length}');
// Remove stale requests from queue
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
// Remove stale tiles from in-flight tracking
_inFlightTiles.removeWhere((tileKey) {
final parts = tileKey.split('/');
if (parts.length == 3) {
final z = int.tryParse(parts[0]) ?? -1;
final x = int.tryParse(parts[1]) ?? -1;
final y = int.tryParse(parts[2]) ?? -1;
return isStale(z, x, y);
}
return false;
});
final queueClearedCount = initialCount - _queue.length;
final inFlightClearedCount = initialInFlightCount - _inFlightTiles.length;
if (queueClearedCount > 0 || inFlightClearedCount > 0) {
debugPrint('[SimpleSemaphore] Cleared $queueClearedCount stale queue + $inFlightClearedCount stale in-flight, kept ${_queue.length}');
}
return clearedCount;
return queueClearedCount + inFlightClearedCount;
}
}

View File

@@ -29,6 +29,21 @@ class OfflineAreaService {
/// Check if any areas are currently downloading
bool get hasActiveDownloads => _areas.any((area) => area.status == OfflineAreaStatus.downloading);
/// Fast check: do we have any completed offline areas for a specific provider/type?
/// This allows smart cache routing without expensive filesystem searches.
/// Safe to call before initialization - returns false if not yet initialized.
bool hasOfflineAreasForProvider(String providerId, String tileTypeId) {
if (!_initialized) {
return false; // No offline areas loaded yet
}
return _areas.any((area) =>
area.status == OfflineAreaStatus.complete &&
area.tileProviderId == providerId &&
area.tileTypeId == tileTypeId
);
}
/// Cancel all active downloads (used when enabling offline mode)
Future<void> cancelActiveDownloads() async {
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();

View File

@@ -610,14 +610,24 @@ class MapViewState extends State<MapView> {
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
// Node limit indicator (top-left) - shown when limit is active
NodeLimitIndicator(
isActive: isLimitActive,
renderedCount: nodesToRender.length,
totalCount: isLimitActive ? allNodes.where((node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}).length : 0,
Builder(
builder: (context) {
final appState = context.read<AppState>();
// Add search bar offset when search bar is visible
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
return NodeLimitIndicator(
isActive: isLimitActive,
renderedCount: nodesToRender.length,
totalCount: isLimitActive ? allNodes.where((node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}).length : 0,
top: 8.0 + searchBarOffset,
left: 8.0,
);
},
),
],
);
@@ -774,7 +784,18 @@ class MapViewState extends State<MapView> {
// Network status indicator (top-left) - conditionally shown
if (appState.networkStatusIndicatorEnabled)
const NetworkStatusIndicator(),
Builder(
builder: (context) {
// Calculate position based on node limit indicator presence and search bar
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
final nodeLimitOffset = isLimitActive ? 48.0 : 0.0; // Height of node limit indicator + spacing
return NetworkStatusIndicator(
top: 8.0 + searchBarOffset + nodeLimitOffset,
left: 8.0,
);
},
),
// Proximity alert banner (top)
ProximityAlertBanner(

View File

@@ -4,7 +4,14 @@ import '../services/network_status.dart';
import '../services/localization_service.dart';
class NetworkStatusIndicator extends StatelessWidget {
const NetworkStatusIndicator({super.key});
final double top;
final double left;
const NetworkStatusIndicator({
super.key,
this.top = 56.0,
this.left = 8.0,
});
@override
Widget build(BuildContext context) {
@@ -61,8 +68,8 @@ class NetworkStatusIndicator extends StatelessWidget {
}
return Positioned(
top: 56, // Position below node limit indicator when present
left: 8,
top: top, // Position dynamically based on other indicators
left: left,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(

View File

@@ -5,12 +5,16 @@ class NodeLimitIndicator extends StatelessWidget {
final bool isActive;
final int renderedCount;
final int totalCount;
final double top;
final double left;
const NodeLimitIndicator({
super.key,
required this.isActive,
required this.renderedCount,
required this.totalCount,
this.top = 8.0,
this.left = 8.0,
});
@override
@@ -25,8 +29,8 @@ class NodeLimitIndicator extends StatelessWidget {
.replaceAll('{total}', totalCount.toString());
return Positioned(
top: 8, // Position at top-left of map area
left: 8,
top: top, // Position at top-left of map area
left: left,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(

View File

@@ -37,6 +37,20 @@ class NodeTagSheet extends StatelessWidget {
node.tags['_pending_deletion'] != 'true');
void _openEditSheet() {
// Check if node limit is active and warn user
if (isNodeLimitActive) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
locService.t('nodeLimitIndicator.editingDisabledMessage')
),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
return;
}
if (onEditPressed != null) {
onEditPressed!(); // Use callback if provided
} else {
@@ -206,7 +220,7 @@ class NodeTagSheet extends StatelessWidget {
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: isNodeLimitActive ? null : _openEditSheet,
onPressed: _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(

View File

@@ -1,7 +1,7 @@
name: deflockapp
description: Map public surveillance infrastructure with OpenStreetMap
publish_to: "none"
version: 1.5.2+20 # The thing after the + is the version code, incremented with each release
version: 1.5.2+23 # 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+