Add offline-first tile system with per-provider caching and error retry

- Add ServicePolicy framework with OSM-specific rate limiting and TTL
- Add per-provider disk tile cache (ProviderTileCacheStore) with O(1)
  lookup, oldest-modified eviction, and ETag/304 revalidation
- Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider)
  and offline-first (disk cache -> local tiles -> network with caching)
- Add zoom-aware offline routing so tiles outside offline area zoom ranges
  use the efficient common path instead of the overhead-heavy offline path
- Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map
  widget recycling; shutdown() handles permanent teardown
- Add TileLayerManager with exponential backoff retry (2s->60s cap),
  provider switch detection, and backoff reset
- Guard null provider/tileType in download dialog with localized error
- Fix Nominatim cache key to use normalized viewbox values
- Comprehensive test coverage (1800+ lines across 6 test files)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Doug Borg
2026-03-03 14:33:26 -07:00
parent be446fbcbc
commit 2d92214bed
36 changed files with 3940 additions and 351 deletions
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:xml/xml.dart';
@@ -7,6 +8,7 @@ import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../http_client.dart';
import '../service_policy.dart';
/// Fetches surveillance nodes from the direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
@@ -58,28 +60,36 @@ Future<List<OsmNode>> _fetchFromOsmApi({
try {
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
debugPrint('[fetchOsmApiNodes] URL: $url');
final response = await _client.get(Uri.parse(url));
// Enforce max 2 concurrent download threads per OSM API usage policy
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
final http.Response response;
try {
response = await _client.get(Uri.parse(url));
} finally {
ServiceRateLimiter.release(ServiceType.osmEditingApi);
}
if (response.statusCode != 200) {
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
}
// Parse XML response
final document = XmlDocument.parse(response.body);
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
if (nodes.isNotEmpty) {
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
}
// Don't report success here - let the top level handle it
return nodes;
} catch (e) {
debugPrint('[fetchOsmApiNodes] Exception: $e');
// Don't report status here - let the top level handle it
rethrow; // Re-throw to let caller handle
}
@@ -1,7 +1,11 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:flutter/foundation.dart' show visibleForTesting;
import '../offline_area_service.dart';
import '../offline_areas/offline_area_models.dart';
import '../offline_areas/offline_tile_utils.dart';
import '../../app_state.dart';
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
@@ -19,7 +23,7 @@ Future<List<int>> fetchLocalTile({
final appState = AppState.instance;
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
final areas = offlineService.offlineAreas;
@@ -28,20 +32,21 @@ Future<List<int>> fetchLocalTile({
for (final area in areas) {
if (area.status != OfflineAreaStatus.complete) continue;
if (z < area.minZoom || z > area.maxZoom) continue;
// Only consider areas that match the current provider/type
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
// Get tile coverage for area at this zoom only
final coveredTiles = computeTileList(area.bounds, z, z);
final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y);
if (hasTile) {
final tilePath = _tilePath(area.directory, z, x, y);
final file = File(tilePath);
if (await file.exists()) {
final stat = await file.stat();
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
}
// O(1) bounds check instead of enumerating all tiles at this zoom level
if (!tileInBounds(area.bounds, z, x, y)) continue;
final tilePath = _tilePath(area.directory, z, x, y);
final file = File(tilePath);
try {
final stat = await file.stat();
if (stat.type == FileSystemEntityType.notFound) continue;
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
} on FileSystemException {
continue;
}
}
if (candidates.isEmpty) {
@@ -51,6 +56,34 @@ Future<List<int>> fetchLocalTile({
return await candidates.first.file.readAsBytes();
}
/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds.
///
/// Uses the same Mercator projection math as [latLonToTile] in
/// offline_tile_utils.dart, but only computes the bounding tile range
/// instead of enumerating every tile at that zoom level.
///
/// Note: Y axis is inverted in tile coordinates — north = lower Y.
@visibleForTesting
bool tileInBounds(LatLngBounds bounds, int z, int x, int y) {
final n = pow(2.0, z);
final west = bounds.west;
final east = bounds.east;
final north = bounds.north;
final south = bounds.south;
final minX = ((west + 180.0) / 360.0 * n).floor();
final maxX = ((east + 180.0) / 360.0 * n).floor();
// North → lower Y (Mercator projection inverts latitude)
final minY = ((1.0 - log(tan(north * pi / 180.0) +
1.0 / cos(north * pi / 180.0)) /
pi) / 2.0 * n).floor();
final maxY = ((1.0 - log(tan(south * pi / 180.0) +
1.0 / cos(south * pi / 180.0)) /
pi) / 2.0 * n).floor();
return x >= minX && x <= maxX && y >= minY && y <= maxY;
}
String _tilePath(String areaDir, int z, int x, int y) =>
'$areaDir/tiles/$z/$x/$y.png';