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
@@ -0,0 +1,487 @@
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/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 = <void>[];
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 = <void>[];
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 = <void>[];
late StreamSubscription<void> 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 = <void>[];
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 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 = <void>[];
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 = <void>[];
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 = <void>[];
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));
});
});
});
}