diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4d04e2..921eeec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,13 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog -## [10.1.1] - 2025/02/03 +## [11.0.0-dev.2] - 2025/03/09 + +* Change `FMTCTileProvider.provideTile` arguments + Require a tile's URL & optional coordinates; instead of required coordinates and required `TileLayer` +* Fixed overly-aggressive Flutter-side tile image caching which prevented changes to `TileLayer.urlTemplate` from updating the displayed tiles + +## [10.1.1] - 2025/03/09 * Fixed bug where import operation fatally crashed on some iOS devices This appears to be an [ObjectBox issue](https://github.com/objectbox/objectbox-dart/issues/654) where streaming the results of a database query caused the crash. Instead, FMTC now uses a custom chunking system to avoid streaming and also avoid loading potentially many tiles into memory. diff --git a/analysis_options.yaml b/analysis_options.yaml index 97dbfab7..59d8a4a8 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,4 +6,4 @@ analyzer: linter: rules: - avoid_slow_async_io: false \ No newline at end of file + avoid_slow_async_io: false diff --git a/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart b/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart index 597cc019..baad5862 100644 --- a/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart +++ b/example/lib/src/screens/main/map_view/components/download_progress/download_progress_masker.dart @@ -28,7 +28,7 @@ class DownloadProgressMasker extends StatefulWidget { final int minZoom; final int maxZoom; final int tileSize; - final TileLayer child; + final Widget child; // To reset after a download, the `key` must be changed diff --git a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart index a4821bfa..40d64fb2 100644 --- a/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart +++ b/example/lib/src/screens/main/secondary_view/contents/region_selection/components/shape_selector/components/animated_visibility_icon_button.dart @@ -6,8 +6,6 @@ class _AnimatedVisibilityIconButton extends StatelessWidget { this.onPressed, this.tooltip, required this.isVisible, - // This is exactly what we want to do - // ignore: avoid_field_initializers_in_const_classes }) : _mode = 0; const _AnimatedVisibilityIconButton.filledTonal({ @@ -15,8 +13,6 @@ class _AnimatedVisibilityIconButton extends StatelessWidget { this.onPressed, this.tooltip, required this.isVisible, - // This is exactly what we want to do - // ignore: avoid_field_initializers_in_const_classes }) : _mode = 1; const _AnimatedVisibilityIconButton.filled({ @@ -24,8 +20,6 @@ class _AnimatedVisibilityIconButton extends StatelessWidget { this.onPressed, this.tooltip, required this.isVisible, - // This is exactly what we want to do - // ignore: avoid_field_initializers_in_const_classes }) : _mode = 2; final Icon icon; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9d00b756..85079493 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: fmtc_demo description: The demo app for 'flutter_map_tile_caching', showcasing its functionality and use-cases. publish_to: "none" -version: 10.1.1 +version: 11.0.0 environment: sdk: ">=3.6.0 <4.0.0" diff --git a/jaffa_lints.yaml b/jaffa_lints.yaml index 81dfd034..10204f2f 100644 --- a/jaffa_lints.yaml +++ b/jaffa_lints.yaml @@ -102,7 +102,6 @@ linter: - one_member_abstracts - only_throw_errors - overridden_fields - - package_api_docs - package_names - package_prefixed_library_names - parameter_assignments @@ -155,6 +154,7 @@ linter: - sort_constructors_first - sort_pub_dependencies - sort_unnamed_constructors_first + - strict_top_level_inference - test_types_in_equals - throw_in_finally - tighten_type_of_initializing_formals @@ -163,12 +163,14 @@ linter: - type_literal_in_constant_pattern - unawaited_futures - unintended_html_in_doc_comment + - unnecessary_async - unnecessary_await_in_return - unnecessary_brace_in_string_interps - unnecessary_breaks - unnecessary_const - unnecessary_constructor_name - unnecessary_getters_setters + - unnecessary_ignore - unnecessary_lambdas - unnecessary_late - unnecessary_library_directive @@ -187,8 +189,10 @@ linter: - unnecessary_string_interpolations - unnecessary_this - unnecessary_to_list_in_spreads + - unnecessary_underscores - unreachable_from_main - unrelated_type_equality_checks + - unsafe_variance - use_build_context_synchronously - use_colored_box - use_decorated_box @@ -200,6 +204,7 @@ linter: - use_key_in_widget_constructors - use_late_for_private_fields_and_variables - use_named_constants + - use_null_aware_elements - use_raw_strings - use_rethrow_when_possible - use_setters_to_change_properties @@ -210,4 +215,4 @@ linter: - use_to_and_as_if_applicable - use_truncating_division - valid_regexps - - void_checks \ No newline at end of file + - void_checks diff --git a/lib/src/backend/impls/objectbox/backend/internal.dart b/lib/src/backend/impls/objectbox/backend/internal.dart index b6e63090..aa5be6aa 100644 --- a/lib/src/backend/impls/objectbox/backend/internal.dart +++ b/lib/src/backend/impls/objectbox/backend/internal.dart @@ -461,7 +461,7 @@ class _ObjectBoxBackendImpl implements FMTCObjectBoxBackendInternal { @override Future> removeOldestTilesAboveLimit({ required List storeNames, - }) async { + }) { // By sharing a single completer, all invocations of this method during the // debounce period will return the same result at the same time if (_rotalResultCompleter?.isCompleted ?? true) { diff --git a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart index 3737946d..a660d32f 100644 --- a/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart +++ b/lib/src/backend/impls/objectbox/backend/internal_workers/standard/worker.dart @@ -1001,9 +1001,6 @@ Future _worker( id: cmd.id, data: {'numExportedTiles': numExportedTiles}, ); - - // We don't care what type, we always need to clean up and rethrow - // ignore: avoid_catches_without_on_clauses } catch (e) { exportingRoot.close(); if (workingDir.existsSync()) { diff --git a/lib/src/providers/image_provider/image_provider.dart b/lib/src/providers/image_provider/image_provider.dart index bdad0f15..b11f48dd 100644 --- a/lib/src/providers/image_provider/image_provider.dart +++ b/lib/src/providers/image_provider/image_provider.dart @@ -10,22 +10,28 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { /// Create a specialised [ImageProvider] that uses FMTC internals to enable /// browse caching const _FMTCImageProvider({ - required this.provider, - required this.options, + required this.networkUrl, required this.coords, + required this.provider, required this.startedLoading, required this.finishedLoadingBytes, }); - /// An instance of the [FMTCTileProvider] in use - final FMTCTileProvider provider; - - /// An instance of the [TileLayer] in use - final TileLayer options; + /// The network URL of the tile at [coords], determined by + /// [FMTCTileProvider.getTileUrl] + final String networkUrl; /// The coordinates of the tile to be fetched + /// + /// Must be set when using the image provider - acts as a key for + /// [FMTCTileProvider.tileLoadingInterceptor], and is used for some debug + /// info. Optional when [provideTile] is used directly, if + /// `tileLoadingInterceptor` functionality is not used. final TileCoordinates coords; + /// An instance of the [FMTCTileProvider] in use + final FMTCTileProvider provider; + /// Function invoked when the image starts loading (not from cache) /// /// Used with [finishedLoadingBytes] to safely dispose of the `httpClient` @@ -46,7 +52,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { MultiFrameImageStreamCompleter( codec: provideTile( coords: coords, - options: options, + networkUrl: networkUrl, provider: provider, key: key, finishedLoadingBytes: finishedLoadingBytes, @@ -55,26 +61,22 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { ).then(ImmutableBuffer.fromUint8List).then((v) => decode(v)), scale: 1, debugLabel: coords.toString(), - informationCollector: () { - final tileUrl = provider.getTileUrl(coords, options); - - return [ - DiagnosticsProperty('Stores', provider.stores), - DiagnosticsProperty('Tile coordinates', coords), - DiagnosticsProperty('Tile URL', tileUrl), - DiagnosticsProperty( - 'Tile storage-suitable UID', - provider.urlTransformer?.call(tileUrl) ?? tileUrl, - ), - ]; - }, + informationCollector: () => [ + DiagnosticsProperty('Stores', provider.stores), + DiagnosticsProperty('Tile coordinates', coords), + DiagnosticsProperty('Tile URL', networkUrl), + DiagnosticsProperty( + 'Tile storage-suitable UID', + provider.urlTransformer?.call(networkUrl) ?? networkUrl, + ), + ], ); /// {@macro fmtc.tileProvider.provideTile} static Future provideTile({ - required TileCoordinates coords, - required TileLayer options, required FMTCTileProvider provider, + required String networkUrl, + TileCoordinates? coords, Object? key, void Function()? startedLoading, void Function()? finishedLoadingBytes, @@ -92,7 +94,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key)); } - if (currentTLIR != null) { + if (currentTLIR != null && coords != null) { currentTLIR.error = error; provider.tileLoadingInterceptor! @@ -121,8 +123,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { final Uint8List bytes; try { bytes = await _internalTileBrowser( - coords: coords, - options: options, + networkUrl: networkUrl, provider: provider, requireValidImage: requireValidImage, currentTLIR: currentTLIR, @@ -150,6 +151,7 @@ class _FMTCImageProvider extends ImageProvider<_FMTCImageProvider> { bool operator ==(Object other) => identical(this, other) || (other is _FMTCImageProvider && + other.networkUrl == networkUrl && other.coords == coords && other.provider == provider); diff --git a/lib/src/providers/image_provider/internal_tile_browser.dart b/lib/src/providers/image_provider/internal_tile_browser.dart index 393242d2..8ff49be2 100644 --- a/lib/src/providers/image_provider/internal_tile_browser.dart +++ b/lib/src/providers/image_provider/internal_tile_browser.dart @@ -4,8 +4,7 @@ part of '../../../flutter_map_tile_caching.dart'; Future _internalTileBrowser({ - required TileCoordinates coords, - required TileLayer options, + required String networkUrl, required FMTCTileProvider provider, required bool requireValidImage, required _TLIRConstructor? currentTLIR, @@ -27,7 +26,6 @@ Future _internalTileBrowser({ } } - final networkUrl = provider.getTileUrl(coords, options); final matcherUrl = provider.urlTransformer?.call(networkUrl) ?? networkUrl; currentTLIR?.networkUrl = networkUrl; diff --git a/lib/src/providers/map_caching_provider/provider.dart b/lib/src/providers/map_caching_provider/provider.dart new file mode 100644 index 00000000..d11fbefd --- /dev/null +++ b/lib/src/providers/map_caching_provider/provider.dart @@ -0,0 +1,201 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../../../flutter_map_tile_caching.dart'; +import '../../backend/export_internal.dart'; + +@immutable +class FMTCCachingProvider implements MapCachingProvider, PutTileCapability { + /// Create an [FMTCTileProvider] that interacts with a subset of all available + /// stores + /// + /// See [stores] & [otherStoresStrategy] for information. + /// + /// {@macro fmtc.fmtcTileProvider.constructionTip} + const FMTCCachingProvider({ + required this.stores, + this.otherStoresStrategy, + this.loadingStrategy = BrowseLoadingStrategy.cacheFirst, + this.useOtherStoresAsFallbackOnly = false, + this.cachedValidDuration = Duration.zero, + this.tileKeyGenerator = BuiltInMapCachingProvider.uuidTileKeyGenerator, + }); + + /// Create an [FMTCTileProvider] that interacts with all available stores, + /// using one [BrowseStoreStrategy] efficiently + /// + /// {@macro fmtc.fmtcTileProvider.constructionTip} + const FMTCCachingProvider.allStores({ + required BrowseStoreStrategy allStoresStrategy, + this.loadingStrategy = BrowseLoadingStrategy.cacheFirst, + this.cachedValidDuration = Duration.zero, + this.tileKeyGenerator = BuiltInMapCachingProvider.uuidTileKeyGenerator, + }) : stores = const {}, + otherStoresStrategy = allStoresStrategy, + useOtherStoresAsFallbackOnly = false; + + /// The store names from which to (possibly) read/update/create tiles from/in + /// + /// Keys represent store names, and the associated [BrowseStoreStrategy] + /// represents how that store should be used. + /// + /// Stores not included will not be used by default. However, + /// [otherStoresStrategy] determines whether & how all other unspecified + /// stores should be used. Stores included in this mapping but with a `null` + /// value will be exempted from [otherStoresStrategy] (ie. unused). + /// + /// All specified store names should correspond to existing stores. + /// Non-existant stores may cause unexpected read behaviour and will throw a + /// [StoreNotExists] error if a tile is attempted to be written to it. + final Map stores; + + /// The behaviour of all other stores not specified in [stores] + /// + /// `null` means that all other stores will not be used. + /// + /// Setting a non-`null` value may negatively impact performance, because + /// internal tile cache lookups will have less constraints. + /// + /// Also see [useOtherStoresAsFallbackOnly] for whether these unspecified + /// stores should only be used as a last resort or in addition to the + /// specified stores as normal. + /// + /// Stores specified in [stores] but associated with a `null` value will not + /// gain this behaviour. + final BrowseStoreStrategy? otherStoresStrategy; + + /// Determines whether the network or cache is preferred during browse + /// caching, and how to fallback + /// + /// Defaults to [BrowseLoadingStrategy.cacheFirst]. + final BrowseLoadingStrategy loadingStrategy; + + /// Whether to only use tiles retrieved by + /// [FMTCTileProvider.otherStoresStrategy] after all specified stores have + /// been exhausted (where the tile was not present) + /// + /// When tiles are retrieved from other stores, it is counted as a miss for + /// the specified store(s). + /// + /// Note that an attempt is *always* made to read the tile from the cache, + /// regardless of whether the tile is then actually retrieved from the cache + /// or the network is then used (successfully). + /// + /// For example, if a specified store does not contain the tile, and an + /// unspecified store does contain the tile: + /// * if this is `false`, then the tile will be retrieved and used from the + /// unspecified store + /// * if this is `true`, then the tile will be retrieved (see note above), + /// but not used unless the network request fails + /// + /// Defaults to `false`. + final bool useOtherStoresAsFallbackOnly; + + /// The duration for which a tile does not require updating when cached, after + /// which it is marked as expired and updated at the next possible + /// opportunity + /// + /// Set to [Duration.zero] to never expire a tile (default). + final Duration cachedValidDuration; + + /// Function to convert a tile's URL to a key used to uniquely identify the + /// tile + /// + /// Where parts of the URL are volatile or do not represent the tile's + /// contents/image - for example, API keys contained with the query + /// parameters - this should be modified to remove the volatile portions. + /// + /// Keys must be usable as filenames on all intended platform filesystems. + /// The callback should not throw. + /// + /// Defaults to using [BuiltInMapCachingProvider.uuidTileKeyGenerator]. + final String Function(String url) tileKeyGenerator; + + /// Compile the [FMTCTileProvider.stores] & + /// [FMTCTileProvider.otherStoresStrategy] into a format which can be resolved + /// by the backend once all available stores are known + ({List storeNames, bool includeOrExclude}) _compileReadableStores() { + final excludeOrInclude = otherStoresStrategy != null; + final storeNames = (excludeOrInclude + ? stores.entries.where((e) => e.value == null) + : stores.entries.where((e) => e.value != null)) + .map((e) => e.key) + .toList(growable: false); + return (storeNames: storeNames, includeOrExclude: !excludeOrInclude); + } + + @override + Future?> getTile(String url) async { + final storageSuitableUid = tileKeyGenerator(url); + + final ( + tile: existingTile, + intersectedStoreNames: intersectedExistingStores, //! TODO + allStoreNames: allExistingStores, + ) = await FMTCBackendAccess.internal.readTile( + url: storageSuitableUid, + storeNames: _compileReadableStores(), + ); + + if (existingTile != null) { + final bytes = existingTile.bytes; + + if (loadingStrategy == BrowseLoadingStrategy.cacheOnly) { + // We can't use the network, so always use the tile regardless of its + // status + return (bytes: bytes, metadata: CachedTileMetadata.fresh); + } + + if (loadingStrategy == BrowseLoadingStrategy.onlineFirst) { + // Prefer using the network, but use the cached tile as a fallback if + // necessary + return (bytes: bytes, metadata: CachedTileMetadata.stale); + } + + // Loading strategy is cache first + // Here, we want to try to use the network (falling back to the cached + // tile), only if either: + // * the tile was retrieved from an "other" store, and + // `useOtherStoresAsFallbackOnly` was set + // * the tile has expired and needs updating + // Otherwise, we use the cached tile + + if (useOtherStoresAsFallbackOnly && + stores.keys.toSet().intersection(allExistingStores.toSet()).isEmpty) { + return (bytes: bytes, metadata: CachedTileMetadata.stale); + } + if (cachedValidDuration != Duration.zero && + DateTime.timestamp().millisecondsSinceEpoch - + existingTile.lastModified.millisecondsSinceEpoch > + cachedValidDuration.inMilliseconds) { + return (bytes: bytes, metadata: CachedTileMetadata.stale); + } + return (bytes: bytes, metadata: CachedTileMetadata.fresh); + } + + if (loadingStrategy == BrowseLoadingStrategy.cacheOnly) { + throw FMTCBrowsingError( + type: FMTCBrowsingErrorType.missingInCacheOnlyMode, + networkUrl: url, + storageSuitableUID: storageSuitableUid, + ); + } + + return null; // Depend on network + } + + @override + // TODO: implement isSupported + bool get isSupported => throw UnimplementedError(); + + @override + Future putTile({ + required String url, + Uint8List? bytes, + }) { + // TODO: implement putTile + throw UnimplementedError(); + } +} diff --git a/lib/src/providers/tile_provider/tile_provider.dart b/lib/src/providers/tile_provider/tile_provider.dart index a183fffb..4117aa5f 100644 --- a/lib/src/providers/tile_provider/tile_provider.dart +++ b/lib/src/providers/tile_provider/tile_provider.dart @@ -278,9 +278,9 @@ class FMTCTileProvider extends TileProvider { @override ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => _FMTCImageProvider( - provider: this, - options: options, + networkUrl: getTileUrl(coordinates, options), coords: coordinates, + provider: this, startedLoading: () => _tilesInProgress[coordinates] = Completer(), finishedLoadingBytes: () { _tilesInProgress[coordinates]?.complete(); @@ -300,9 +300,7 @@ class FMTCTileProvider extends TileProvider { } /// {@template fmtc.tileProvider.provideTile} - /// Use FMTC's caching logic to get the bytes of the specific tile (at - /// [coords]) with the specified [TileLayer] options and [FMTCTileProvider] - /// provider + /// Use FMTC's caching logic to get the bytes of the tile at [networkUrl] /// /// > [!IMPORTANT] /// > Note that this will actuate the cache writing mechanism as if a normal @@ -321,6 +319,10 @@ class FMTCTileProvider extends TileProvider { /// /// --- /// + /// [coords] is required to enable functioning of + /// [FMTCTileProvider.tileLoadingInterceptor]. If the tile loading interceptor + /// is not in use, providing coordinates is not necessary. + /// /// [key] is used to control the [ImageCache], and should be set when in a /// context where [ImageProvider.obtainKey] is available. /// @@ -342,16 +344,16 @@ class FMTCTileProvider extends TileProvider { /// to be decoded (now or at a later time). /// {@endtemplate} Future provideTile({ - required TileCoordinates coords, - required TileLayer options, + required String networkUrl, + TileCoordinates? coords, Object? key, void Function()? startedLoading, void Function()? finishedLoadingBytes, bool requireValidImage = false, }) => _FMTCImageProvider.provideTile( + networkUrl: networkUrl, coords: coords, - options: options, provider: this, key: key, startedLoading: startedLoading, diff --git a/lib/src/root/recovery.dart b/lib/src/root/recovery.dart index 04f5a8ee..cfc8faa7 100644 --- a/lib/src/root/recovery.dart +++ b/lib/src/root/recovery.dart @@ -58,7 +58,7 @@ class RootRecovery { /// /// {@macro fmtc.rootRecovery.failedDefinition} Future> - get recoverableRegions async => + get recoverableRegions => FMTCBackendAccess.internal.listRecoverableRegions().then( (rs) => rs.map( (r) => @@ -79,7 +79,7 @@ class RootRecovery { } /// {@macro fmtc.backend.cancelRecovery} - Future cancel(int id) async => + Future cancel(int id) => FMTCBackendAccess.internal.cancelRecovery(id: id); } diff --git a/pubspec.yaml b/pubspec.yaml index 657345f0..cb0ac61f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_map_tile_caching description: Plugin for 'flutter_map' providing advanced caching functionality, with ability to download map regions for offline use. -version: 10.1.1 +version: 11.0.0-dev.2 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -47,6 +47,12 @@ dev_dependencies: objectbox_generator: ^4.1.0 test: ^1.25.15 +dependency_overrides: + flutter_map: + git: + url: https://github.com/fleaflet/flutter_map + ref: modern-tile-layer + flutter: null objectbox: diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 3f7c58ad..a131ac3d 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -1,7 +1,7 @@ ; Script generated by the Inno Setup Script Wizard #define MyAppName "FMTC Demo" -#define MyAppVersion "for 10.1.1" +#define MyAppVersion "for 11.0.0" #define MyAppPublisher "JaffaKetchup Development" #define MyAppURL "https://github.com/JaffaKetchup/flutter_map_tile_caching" #define MyAppSupportURL "https://github.com/JaffaKetchup/flutter_map_tile_caching/issues"