diff --git a/CHANGELOG.md b/CHANGELOG.md index 433138db..8d4d04e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ Many thanks to my sponsors, no matter how much or how little they donated. Spons # Changelog +## [10.1.1] - 2025/02/03 + +* 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. +* Improved performance of import operation + ## [10.1.0] - 2025/02/02 * Added support for flutter_map v8 diff --git a/example/lib/src/screens/import/stages/progress.dart b/example/lib/src/screens/import/stages/progress.dart index 74fc24fc..b3ae1497 100644 --- a/example/lib/src/screens/import/stages/progress.dart +++ b/example/lib/src/screens/import/stages/progress.dart @@ -62,13 +62,9 @@ class _ImportProgressStageState extends State { style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center, ), - const SizedBox(height: 16), + const SizedBox(height: 8), const Text( - 'This could take a while.\n' - "We don't recommend leaving this screen. The import will " - 'continue, but performance could be affected.\n' - 'Closing the app will stop the import operation in an ' - 'indeterminate (but stable) state.', + "This could take a while. Don't leave this screen.", textAlign: TextAlign.center, ), ], diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e1facc1d..9d00b756 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,39 +1,37 @@ name: fmtc_demo description: The demo app for 'flutter_map_tile_caching', showcasing its functionality and use-cases. publish_to: "none" -version: 10.1.0 +version: 10.1.1 environment: sdk: ">=3.6.0 <4.0.0" flutter: ">=3.27.0" dependencies: - async: ^2.12.0 + async: ^2.13.0 auto_size_text: ^3.0.0 badges: ^3.1.2 - collection: ^1.18.0 - file_picker: 8.1.4 # Compatible with 3.27! + collection: ^1.19.1 + file_picker: ^9.0.2 flutter: sdk: flutter - flutter_map: - flutter_map_animations: ^0.8.0 + flutter_map: ^8.1.1 + flutter_map_animations: ^0.9.0 flutter_map_tile_caching: - flutter_slidable: ^3.1.2 + flutter_slidable: ^4.0.0 google_fonts: ^6.2.1 gpx: ^2.3.0 - http: ^1.2.2 - intl: ^0.19.0 + http: ^1.3.0 + intl: ^0.20.2 latlong2: ^0.9.1 path: ^1.9.1 path_provider: ^2.1.5 provider: ^6.1.2 - share_plus: ^10.1.3 - shared_preferences: ^2.3.3 - stream_transform: ^2.1.0 + share_plus: ^10.1.4 + shared_preferences: ^2.5.2 + stream_transform: ^2.1.1 dependency_overrides: - flutter_map: - git: https://github.com/fleaflet/flutter_map.git flutter_map_tile_caching: path: ../ diff --git a/lib/src/backend/impls/objectbox/backend/backend.dart b/lib/src/backend/impls/objectbox/backend/backend.dart index 5bb750cc..2851d559 100644 --- a/lib/src/backend/impls/objectbox/backend/backend.dart +++ b/lib/src/backend/impls/objectbox/backend/backend.dart @@ -9,9 +9,8 @@ import 'dart:isolate'; import 'dart:math'; import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; 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 bcbdc0db..3737946d 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 @@ -97,12 +97,16 @@ Future _worker( const tilesChunkSize = 200; final stores = root.box(); - final tiles = root.box(); + + final modifyStoreQuery = + stores.query(ObjectBoxStore_.name.equals('')).build(); bool hadTilesToUpdate = false; - int rootDeltaSize = 0; final tilesToRemove = []; - final storesToUpdate = {}; + + int rootDeltaSize = 0; + final storeDeltaLength = {}; + final storeDeltaSize = {}; root.runInTransaction( TxMode.write, @@ -110,25 +114,21 @@ Future _worker( final queriedStores = storesQuery.property(ObjectBoxStore_.name).find(); if (queriedStores.isEmpty) return 0; - for (int offset = 0;; offset += tilesChunkSize) { - final limit = limitTiles == null - ? tilesChunkSize - : min(tilesChunkSize, limitTiles - offset); - - final tilesChunk = (tilesQuery - ..offset = offset - ..limit = limit) - .find(); + tilesQuery.chunkedMultiTransaction( + chunkSize: tilesChunkSize, + limitTiles: limitTiles, + root: root, // For each store, remove it from the tile if requested // For each store & if removed, update that store's stats - for (final tile in tilesChunk) { + runInTransaction: (tile) { tile.stores.removeWhere((store) { if (!queriedStores.contains(store.name)) return false; - storesToUpdate[store.name] = (storesToUpdate[store.name] ?? store) - ..length -= 1 - ..size -= tile.bytes.lengthInBytes; + storeDeltaLength[store.name] = + (storeDeltaLength[store.name] ?? 0) - 1; + storeDeltaSize[store.name] = + (storeDeltaSize[store.name] ?? 0) - tile.bytes.lengthInBytes; return true; }); @@ -136,19 +136,17 @@ Future _worker( if (tile.stores.isNotEmpty) { tile.stores.applyToDb(mode: PutMode.update); hadTilesToUpdate = true; - continue; + return; } rootDeltaSize -= tile.bytes.lengthInBytes; tilesToRemove.add(tile.id); - } - - if (tilesChunk.length < tilesChunkSize) break; - } + }, + ); if (!hadTilesToUpdate && tilesToRemove.isEmpty) return 0; - tilesToRemove.forEach(tiles.remove); + root.box().removeMany(tilesToRemove); updateRootStatistics( deltaLength: -tilesToRemove.length, @@ -156,7 +154,18 @@ Future _worker( ); stores.putMany( - storesToUpdate.values.toList(), + storeDeltaSize.entries.map( + (entry) { + final storeName = entry.key; + final deltaSize = entry.value; + final deltaLength = storeDeltaLength[storeName]!; + + modifyStoreQuery.param(ObjectBoxStore_.name).value = storeName; + return modifyStoreQuery.findUnique()! + ..size += deltaSize + ..length += deltaLength; + }, + ).toList(growable: false), mode: PutMode.update, ); }, @@ -891,142 +900,117 @@ Future _worker( rethrow; } - final storesQuery = root - .box() - .query(ObjectBoxStore_.name.oneOf(storeNames)) - .build(); + try { + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.oneOf(storeNames)) + .build(); + final tilesQuery = (root.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.oneOf(storeNames), + )) + .build(); + + // Copy all stores to external root + // Then, to make sure relations work 100%, we go through the stores + // just copied to the external root and add them to the map below + final storesToExport = storesQuery.find(); + if (!listEquals( + storesToExport.map((s) => s.name).toList(growable: false), + storeNames, + )) { + throw ArgumentError( + 'Specified stores did not match the resolved existing stores', + 'storeNames', + ); + } + final storesObjectsForRelations = + Map.fromEntries( + (exportingRoot.box() + ..putMany( + storesQuery + .find() + .map( + (store) => ObjectBoxStore( + name: store.name, + maxLength: store.maxLength, + length: store.length, + size: store.size, + hits: store.hits, + misses: store.misses, + metadataJson: store.metadataJson, + ), + ) + .toList(growable: false), + mode: PutMode.insert, + )) + .getAll() + .map((s) => MapEntry(s.name, s)), + ); - final tilesQuery = (root.box().query() - ..linkMany( - ObjectBoxTile_.stores, - ObjectBoxStore_.name.oneOf(storeNames), - )) - .build(); + // Copy all tiles to external root + int numExportedTiles = 0; + tilesQuery.chunkedMultiTransaction( + chunkSize: 300, + root: root, + runInTransaction: (tile) { + exportingRoot.box().put( + ObjectBoxTile( + url: tile.url, + bytes: tile.bytes, + lastModified: tile.lastModified, + )..stores.addAll( + tile.stores + .map((s) => storesObjectsForRelations[s.name]) + .nonNulls, + ), + mode: PutMode.insert, + ); + numExportedTiles++; + }, + ); - final storesObjectsForRelations = {}; + storesQuery.close(); + tilesQuery.close(); + exportingRoot.close(); - final exportingStores = root.runInTransaction( - TxMode.read, - storesQuery.stream, - ); + final dbFile = File(path.join(workingDir.absolute.path, 'data.mdb')); - exportingRoot - .runInTransaction( - TxMode.write, - () => exportingStores.map( - (exportingStore) { - exportingRoot.box().put( - storesObjectsForRelations[exportingStore.name] = - ObjectBoxStore( - name: exportingStore.name, - maxLength: exportingStore.maxLength, - length: exportingStore.length, - size: exportingStore.size, - hits: exportingStore.hits, - misses: exportingStore.misses, - metadataJson: exportingStore.metadataJson, - ), - mode: PutMode.insert, - ); - }, - ), - ) - .length - .then( - (numExportedStores) { - if (numExportedStores == 0) throw StateError('Unpossible'); - - final exportingTiles = root.runInTransaction( - TxMode.read, - tilesQuery.stream, - ); - - exportingRoot - .runInTransaction( - TxMode.write, - () => exportingTiles.map( - (exportingTile) { - exportingRoot.box().put( - ObjectBoxTile( - url: exportingTile.url, - bytes: exportingTile.bytes, - lastModified: exportingTile.lastModified, - )..stores.addAll( - exportingTile.stores - .map( - (s) => storesObjectsForRelations[s.name], - ) - .nonNulls, - ), - mode: PutMode.insert, - ); - }, - ), - ) - .length - .then( - (numExportedTiles) { - if (numExportedTiles == 0) { - throw ArgumentError( - 'Specified stores must include at least one tile total', - 'storeNames', - ); - } + final ram = dbFile.openSync(mode: FileMode.writeOnlyAppend); + try { + ram + ..writeFromSync(List.filled(4, 255)) + ..writeStringSync('ObjectBox') // Backend identifier + ..writeByteSync(255) + ..writeByteSync(255) + ..writeStringSync('FMTC'); // Signature + } finally { + ram.closeSync(); + } - storesQuery.close(); - tilesQuery.close(); - exportingRoot.close(); - - final dbFile = - File(path.join(workingDir.absolute.path, 'data.mdb')); - - final ram = dbFile.openSync(mode: FileMode.writeOnlyAppend); - try { - ram - ..writeFromSync(List.filled(4, 255)) - ..writeStringSync('ObjectBox') // Backend identifier - ..writeByteSync(255) - ..writeByteSync(255) - ..writeStringSync('FMTC'); // Signature - } finally { - ram.closeSync(); - } + try { + dbFile.renameSync(outputPath); + } on FileSystemException { + dbFile.copySync(outputPath); + } finally { + workingDir.deleteSync(recursive: true); + } - try { - dbFile.renameSync(outputPath); - } on FileSystemException { - dbFile.copySync(outputPath); - } finally { - workingDir.deleteSync(recursive: true); - } + sendRes( + id: cmd.id, + data: {'numExportedTiles': numExportedTiles}, + ); - sendRes( - id: cmd.id, - data: {'numExportedTiles': numExportedTiles}, - ); - }, - ).catchError((error, stackTrace) { - exportingRoot.close(); - try { - workingDir.deleteSync(recursive: true); - // If the working dir didn't exist, that's fine - // We don't want to spend time checking if exists, as it likely - // does - // ignore: empty_catches - } on FileSystemException {} - Error.throwWithStackTrace(error, stackTrace); - }); - }, - ).catchError((error, stackTrace) { + // We don't care what type, we always need to clean up and rethrow + // ignore: avoid_catches_without_on_clauses + } catch (e) { exportingRoot.close(); - try { + if (workingDir.existsSync()) { workingDir.deleteSync(recursive: true); - // If the working dir didn't exist, that's fine - // We don't want to spend time checking if exists, as it likely does - // ignore: empty_catches - } on FileSystemException {} - Error.throwWithStackTrace(error, stackTrace); - }); + } + rethrow; + } case _CmdType.importStores: final importPath = cmd.args['path']! as String; final strategy = cmd.args['strategy'] as ImportConflictStrategy; @@ -1075,9 +1059,10 @@ Future _worker( } final StoresToStates storesToStates = {}; - (switch (strategy) { + + final compiledImportStores = switch (strategy) { ImportConflictStrategy.skip => importingStoresQuery - .stream() + .find() .where( (importingStore) { final name = importingStore.name; @@ -1110,7 +1095,7 @@ Future _worker( .map((s) => s.name) .toList(), ImportConflictStrategy.rename => - importingStoresQuery.stream().map((importingStore) { + importingStoresQuery.find().map((importingStore) { final name = importingStore.name; if ((specificStoresQuery @@ -1155,7 +1140,7 @@ Future _worker( }).toList(), ImportConflictStrategy.replace || ImportConflictStrategy.merge => - importingStoresQuery.stream().map( + importingStoresQuery.find().map( (importingStore) { final name = importingStore.name; @@ -1198,242 +1183,249 @@ Future _worker( return name; }, ).toList(), - }) - .then( - (storesToImport) async { - sendRes( - id: cmd.id, - data: { - 'expectStream': true, - 'storesToStates': storesToStates, - if (storesToImport.isEmpty) 'complete': 0, - }, - ); - if (storesToImport.isEmpty) { - cleanup(); - return; - } + }; - // At this point: - // * storesToImport should contain only the required IMPORT stores - // * root's stores should be set so that every import store has an - // equivalent with the same name - // It is important never to 'copy' from the import root to the - // in-use root - - final importingTilesQuery = - (importingRoot.box().query() - ..linkMany( - ObjectBoxTile_.stores, - ObjectBoxStore_.name.oneOf(storesToImport), - )) - .build(); - final importingTiles = importingTilesQuery.stream(); - - final existingStoresQuery = root - .box() - .query(ObjectBoxStore_.name.equals('')) - .build(); - final existingTilesQuery = root - .box() - .query(ObjectBoxTile_.url.equals('')) - .build(); + sendRes( + id: cmd.id, + data: { + 'expectStream': true, + 'storesToStates': storesToStates, + if (compiledImportStores.isEmpty) 'complete': 0, + }, + ); + if (compiledImportStores.isEmpty) { + cleanup(); + break; + } - final storesToUpdate = {}; + // At this point: + // * storesToImport should contain only the required IMPORT stores + // * root's stores should be set so that every import store has an + // equivalent with the same name + // It is important never to 'copy' from the import root to the + // in-use root - int rootDeltaLength = 0; - int rootDeltaSize = 0; + final importingTilesQuery = (importingRoot.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.oneOf(compiledImportStores), + )) + .build(); - Iterable convertToExistingStores( - Iterable importingStores, - ) => - importingStores - .where((s) => storesToImport.contains(s.name)) - .map( - (s) => storesToUpdate[s.name] ??= (existingStoresQuery - ..param(ObjectBoxStore_.name).value = s.name) - .findUnique()!, - ); + final existingStoresQuery = root + .box() + .query(ObjectBoxStore_.name.equals('')) + .build(); + final existingTilesQuery = root + .box() + .query(ObjectBoxTile_.url.equals('')) + .build(); - if (strategy == ImportConflictStrategy.replace) { - final storesQuery = root - .box() - .query(ObjectBoxStore_.name.oneOf(storesToImport)) - .build(); - final tilesQuery = (root.box().query() - ..linkMany( - ObjectBoxTile_.stores, - ObjectBoxStore_.name.oneOf(storesToImport), - )) - .build(); - - deleteTiles( - storesQuery: storesQuery, - tilesQuery: tilesQuery, + final storeDeltaLength = {}; + final storeDeltaSize = {}; + + int rootDeltaLength = 0; + int rootDeltaSize = 0; + + Iterable convertToExistingStores( + Iterable importingStores, + ) => + importingStores + .where((s) => compiledImportStores.contains(s.name)) + .map( + (s) { + final e = (existingStoresQuery + ..param(ObjectBoxStore_.name).value = s.name) + .findUnique()!; + storeDeltaLength[s.name] = 0; + storeDeltaSize[s.name] = 0; + return e; + }, + ); + + if (strategy == ImportConflictStrategy.replace) { + final storesQuery = root + .box() + .query(ObjectBoxStore_.name.oneOf(compiledImportStores)) + .build(); + final tilesQuery = (root.box().query() + ..linkMany( + ObjectBoxTile_.stores, + ObjectBoxStore_.name.oneOf(compiledImportStores), + )) + .build(); + + deleteTiles( + storesQuery: storesQuery, + tilesQuery: tilesQuery, + ); + + final importingStoresQuery = importingRoot + .box() + .query(ObjectBoxStore_.name.oneOf(compiledImportStores)) + .build(); + + final importingStores = importingStoresQuery.find(); + + storesQuery.remove(); + + root.box().putMany( + List.generate( + importingStores.length, + (i) => ObjectBoxStore( + name: importingStores[i].name, + maxLength: importingStores[i].maxLength, + length: importingStores[i].length, + size: importingStores[i].size, + hits: importingStores[i].hits, + misses: importingStores[i].misses, + metadataJson: importingStores[i].metadataJson, + ), + growable: false, + ), + mode: PutMode.insert, ); - final importingStoresQuery = importingRoot - .box() - .query(ObjectBoxStore_.name.oneOf(storesToImport)) - .build(); - - final importingStores = importingStoresQuery.find(); - - storesQuery.remove(); - - root.box().putMany( - List.generate( - importingStores.length, - (i) => ObjectBoxStore( - name: importingStores[i].name, - maxLength: importingStores[i].maxLength, - length: importingStores[i].length, - size: importingStores[i].size, - hits: importingStores[i].hits, - misses: importingStores[i].misses, - metadataJson: importingStores[i].metadataJson, - ), - growable: false, - ), + storesQuery.close(); + tilesQuery.close(); + importingStoresQuery.close(); + } + + int numImportedTiles = 0; + importingTilesQuery.chunkedMultiTransaction( + chunkSize: 300, + root: root, + runInTransaction: (tile) { + final convertedRelatedStores = convertToExistingStores(tile.stores); + + final existingTile = (existingTilesQuery + ..param(ObjectBoxTile_.url).value = tile.url) + .findUnique(); + + if (existingTile == null) { + root.box().put( + ObjectBoxTile( + url: tile.url, + bytes: tile.bytes, + lastModified: tile.lastModified, + )..stores.addAll(convertedRelatedStores), mode: PutMode.insert, ); - storesQuery.close(); - tilesQuery.close(); - importingStoresQuery.close(); - } + // No need to modify store stats, because if tile didn't already + // exist, then was not present in an existing store that needs + // changing, and all importing stores are brand new and already + // contain accurate stats. EXCEPT in merge mode - importing + // stores may not be new. + if (strategy == ImportConflictStrategy.merge) { + // No need to worry if it was brand new, we use the same + // logic, treating it as an existing related store, because + // when we created it, we made it empty. + for (final convertedRelatedStore in convertedRelatedStores) { + storeDeltaLength[convertedRelatedStore.name] = + (storeDeltaLength[convertedRelatedStore.name] ?? 0) + 1; + storeDeltaSize[convertedRelatedStore.name] = + (storeDeltaSize[convertedRelatedStore.name] ?? 0) + + tile.bytes.lengthInBytes; + } + } - final numImportedTiles = await root - .runInTransaction( - TxMode.write, - () => importingTiles.map((importingTile) { - final convertedRelatedStores = - convertToExistingStores(importingTile.stores); - - final existingTile = (existingTilesQuery - ..param(ObjectBoxTile_.url).value = importingTile.url) - .findUnique(); - - if (existingTile == null) { - root.box().put( - ObjectBoxTile( - url: importingTile.url, - bytes: importingTile.bytes, - lastModified: importingTile.lastModified, - )..stores.addAll(convertedRelatedStores), - mode: PutMode.insert, - ); - - // No need to modify store stats, because if tile didn't - // already exist, then was not present in an existing - // store that needs changing, and all importing stores - // are brand new and already contain accurate stats. - // EXCEPT in merge mode - importing stores may not be - // new. - if (strategy == ImportConflictStrategy.merge) { - // No need to worry if it was brand new, we use the - // same logic, treating it as an existing related - // store, because when we created it, we made it - // empty. - for (final convertedRelatedStore - in convertedRelatedStores) { - storesToUpdate[convertedRelatedStore.name] = - (storesToUpdate[convertedRelatedStore.name] ?? - convertedRelatedStore) - ..length += 1 - ..size += importingTile.bytes.lengthInBytes; - } - } - - rootDeltaLength++; - rootDeltaSize += importingTile.bytes.lengthInBytes; - - return 1; - } - - final existingTileIsNewer = existingTile.lastModified - .isAfter(importingTile.lastModified) || - existingTile.lastModified == importingTile.lastModified; - - final relations = { - ...existingTile.stores, - ...convertedRelatedStores, - }; - - root.box().put( - ObjectBoxTile( - url: importingTile.url, - bytes: existingTileIsNewer - ? existingTile.bytes - : importingTile.bytes, - lastModified: existingTileIsNewer - ? existingTile.lastModified - : importingTile.lastModified, - )..stores.addAll(relations), - ); + rootDeltaLength++; + rootDeltaSize += tile.bytes.lengthInBytes; - if (strategy == ImportConflictStrategy.merge) { - for (final newConvertedRelatedStore - in convertedRelatedStores) { - if (existingTile.stores - .map((e) => e.name) - .contains(newConvertedRelatedStore.name)) { - continue; - } - - storesToUpdate[newConvertedRelatedStore.name] = - (storesToUpdate[newConvertedRelatedStore.name] ?? - newConvertedRelatedStore) - ..length += 1 - ..size += (existingTileIsNewer - ? existingTile - : importingTile) - .bytes - .lengthInBytes; - } - } - - if (existingTileIsNewer) return null; - - for (final existingTileStore in existingTile.stores) { - storesToUpdate[existingTileStore.name] = - (storesToUpdate[existingTileStore.name] ?? - existingTileStore) - ..size += -existingTile.bytes.lengthInBytes + - importingTile.bytes.lengthInBytes; - } - - rootDeltaSize += -existingTile.bytes.lengthInBytes + - importingTile.bytes.lengthInBytes; - - return 1; - }), - ) - .where((e) => e != null) - .length; - - root.box().putMany( - storesToUpdate.values.toList(), + numImportedTiles++; + return; + } + + final existingTileIsNewer = + existingTile.lastModified.isAfter(tile.lastModified) || + existingTile.lastModified == tile.lastModified; + + final relations = { + ...existingTile.stores, + ...convertedRelatedStores, + }; + + root.box().put( + ObjectBoxTile( + url: tile.url, + bytes: + existingTileIsNewer ? existingTile.bytes : tile.bytes, + lastModified: existingTileIsNewer + ? existingTile.lastModified + : tile.lastModified, + ) + ..stores.addAll(relations) + ..id = existingTile.id, mode: PutMode.update, ); - updateRootStatistics( - deltaLength: rootDeltaLength, - deltaSize: rootDeltaSize, - ); + if (strategy == ImportConflictStrategy.merge) { + for (final newConvertedRelatedStore in convertedRelatedStores) { + if (existingTile.stores + .map((e) => e.name) + .contains(newConvertedRelatedStore.name)) { + continue; + } - importingTilesQuery.close(); - existingStoresQuery.close(); - existingTilesQuery.close(); - cleanup(); + storeDeltaLength[newConvertedRelatedStore.name] = + (storeDeltaLength[newConvertedRelatedStore.name] ?? 0) + 1; + storeDeltaSize[newConvertedRelatedStore.name] = + (storeDeltaSize[newConvertedRelatedStore.name] ?? 0) + + (existingTileIsNewer ? existingTile : tile) + .bytes + .lengthInBytes; + } + } - sendRes( - id: cmd.id, - data: { - 'expectStream': true, - 'complete': numImportedTiles, - }, + if (existingTileIsNewer) return; + + for (final existingTileStore in existingTile.stores) { + storeDeltaSize[existingTileStore.name] = + (storeDeltaSize[existingTileStore.name] ?? 0) - + existingTile.bytes.lengthInBytes + + tile.bytes.lengthInBytes; + } + + rootDeltaSize += + -existingTile.bytes.lengthInBytes + tile.bytes.lengthInBytes; + + numImportedTiles++; + }, + ); + + root.box().putMany( + storeDeltaSize.entries.map( + (entry) { + final storeName = entry.key; + final deltaSize = entry.value; + final deltaLength = storeDeltaLength[storeName] ?? 0; + + specificStoresQuery.param(ObjectBoxStore_.name).value = + storeName; + return specificStoresQuery.findUnique()! + ..size += deltaSize + ..length += deltaLength; + }, + ).toList(growable: false), + mode: PutMode.update, ); + + updateRootStatistics( + deltaLength: rootDeltaLength, + deltaSize: rootDeltaSize, + ); + + importingTilesQuery.close(); + existingStoresQuery.close(); + existingTilesQuery.close(); + cleanup(); + + sendRes( + id: cmd.id, + data: { + 'expectStream': true, + 'complete': numImportedTiles, }, ); case _CmdType.listImportableStores: @@ -1501,3 +1493,30 @@ Future _worker( } }).asFuture(); } + +extension _ChunkedFind on Query { + void chunkedMultiTransaction({ + required Store root, + required void Function(T e) runInTransaction, + required int chunkSize, + int? limitTiles, + TxMode transactionMode = TxMode.write, + }) { + for (int offset = 0;; offset += chunkSize) { + final exit = root.runInTransaction( + transactionMode, + () { + final chunk = (this + ..offset = offset + ..limit = (limitTiles == null + ? chunkSize + : min(chunkSize, limitTiles - offset))) + .find() + ..forEach(runInTransaction); + return chunk.length < chunkSize; + }, + ); + if (exit) return; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8b2d8ec4..657345f0 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.0 +version: 10.1.1 repository: https://github.com/JaffaKetchup/flutter_map_tile_caching issue_tracker: https://github.com/JaffaKetchup/flutter_map_tile_caching/issues @@ -33,19 +33,19 @@ dependencies: flat_buffers: ^23.5.26 flutter: sdk: flutter - flutter_map: ^8.0.0 + flutter_map: ^8.1.1 http: ^1.2.2 latlong2: ^0.9.1 meta: ^1.15.0 - objectbox: ^4.0.3 - objectbox_flutter_libs: ^4.0.3 - path: ^1.9.0 - path_provider: ^2.1.4 + objectbox: ^4.1.0 + objectbox_flutter_libs: ^4.1.0 + path: ^1.9.1 + path_provider: ^2.1.5 dev_dependencies: - build_runner: ^2.4.14 - objectbox_generator: ^4.0.3 - test: ^1.25.14 + build_runner: ^2.4.15 + objectbox_generator: ^4.1.0 + test: ^1.25.15 flutter: null diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 53b7ccb7..3f7c58ad 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.0" +#define MyAppVersion "for 10.1.1" #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"