From dbdd8536faae3811d01c34a3cf39205aefc81427 Mon Sep 17 00:00:00 2001 From: David Popescu <80603759+DavidPopesc@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:00:20 -0400 Subject: [PATCH 1/6] Update iOS deployment target to 15.0, configure analyzer exclusions, and update package versions in pubspec.lock --- analysis_options.yaml | 9 ++ ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/Info.plist | 173 +++++++++++++-------------- pubspec.lock | 16 +-- 4 files changed, 106 insertions(+), 98 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d290213..bf8d4218 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3c09d15a..35c146ed 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -364,7 +364,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -494,7 +494,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -545,7 +545,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6918f87a..8f6aba99 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,8 +1,8 @@ - - - FlutterDeepLinkingEnabled - - CADisableMinimumFrameDurationOnPhone - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - DeFlock - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - deflockapp - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleTypeRole - None - CFBundleURLSchemes - - deflockapp - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSApplicationQueriesSchemes - - https - - LSRequiresIPhoneOS - - NSLocationAlwaysAndWhenInUseUsageDescription - This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional. - NSLocationWhenInUseUsageDescription - This app optionally uses your location to show nearby cameras by centering the map on your location. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes + FlutterDeepLinkingEnabled - UISceneConfigurations + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + DeFlock + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + deflockapp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + None + CFBundleURLSchemes + + deflockapp + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + https + + LSRequiresIPhoneOS + + NSLocationAlwaysAndWhenInUseUsageDescription + This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional. + NSLocationWhenInUseUsageDescription + This app optionally uses your location to show nearby cameras by centering the map on your location. + UIApplicationSceneManifest - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - FlutterSceneDelegate - UISceneStoryboardFile - Main - - + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - diff --git a/pubspec.lock b/pubspec.lock index e5cd22b2..c75e8e76 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -564,10 +564,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -580,10 +580,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -945,10 +945,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" timezone: dependency: transitive description: @@ -1081,10 +1081,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: From 72b3d99a445f3f040d9b7b0a4bc91ce7bc1cfe0c Mon Sep 17 00:00:00 2001 From: David Popescu <80603759+DavidPopesc@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:13:41 -0400 Subject: [PATCH 2/6] Implement migration for Flock Raven tags and update changelog for version 2.11.1 --- assets/changelog.json | 5 + lib/app_state.dart | 15 +++ lib/migrations.dart | 141 +++++++++++++++++++++++++++ lib/models/node_profile.dart | 6 +- lib/screens/about_screen.dart | 22 +++++ lib/screens/home_screen.dart | 8 +- lib/services/changelog_service.dart | 6 +- lib/services/overpass_service.dart | 38 ++++++++ lib/state/upload_queue_state.dart | 115 ++++++++++++++++++++++ pubspec.yaml | 2 +- test/migrations_test.dart | 144 ++++++++++++++++++++++++++++ 11 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 test/migrations_test.dart diff --git a/assets/changelog.json b/assets/changelog.json index 2e09f752..265871c0 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,9 @@ { + "2.11.1": { + "content": [ + "• Fix incorrect Flock Raven tags (surveillance/manufacturer) on existing saved entries" + ] + }, "2.11.0": { "content": [ "• Further improve node loading performance", diff --git a/lib/app_state.dart b/lib/app_state.dart index 0ba485c7..224a29ce 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -616,6 +616,16 @@ class AppState extends ChangeNotifier { } } + /// Queue corrective uploads for the user's own live OSM nodes still using + /// the old Flock Raven tag scheme (for the one-time live-data fix prompt). + List addFlockRavenCorrections(List staleNodes) { + final corrections = _uploadQueueState.addFlockRavenCorrections(staleNodes, uploadMode: uploadMode); + if (corrections.isNotEmpty) { + _startUploader(); + } + return corrections; + } + void deleteNode(OsmNode node, {String? changesetComment}) { _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment); _startUploader(); @@ -851,6 +861,11 @@ class AppState extends ChangeNotifier { await _uploadQueueState.reloadQueue(); } + /// Fix queued Flock Raven entries still using the old tag scheme (for migration purposes) + Future migrateFlockRavenQueueTags() async { + return _uploadQueueState.migrateFlockRavenQueueTags(); + } + // ---------- Suspected Location Methods ---------- Future setSuspectedLocationsEnabled(bool enabled) async { await _suspectedLocationState.setEnabled(enabled); diff --git a/lib/migrations.dart b/lib/migrations.dart index 171e4eb4..3cec690e 100644 --- a/lib/migrations.dart +++ b/lib/migrations.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app_state.dart'; import 'services/offline_area_service.dart'; +import 'services/overpass_service.dart'; import 'services/profile_service.dart'; import 'services/suspected_location_cache.dart'; import 'widgets/nuclear_reset_dialog.dart'; @@ -215,6 +216,50 @@ class OneTimeMigrations { } } + /// Fix Flock Raven tags that were using the wrong surveillance scheme (v2.11.1) + /// Old (incorrect): 'surveillance': 'public', 'brand': 'Flock Safety', 'brand:wikidata': 'Q108485435' + /// New (correct): 'surveillance': 'outdoor', 'manufacturer': 'Flock Safety', 'manufacturer:wikidata': 'Q108485435' + static Future migrate_2_11_1(AppState appState) async { + try { + // Fix the persisted profile catalog (used when creating new nodes going forward) + final profiles = await ProfileService().load(); + bool profilesChanged = false; + + final updatedProfiles = profiles.map((profile) { + final tags = profile.tags; + if (tags['surveillance'] == 'public' && + tags['brand'] == 'Flock Safety' && + tags['surveillance:type'] == 'gunshot_detector') { + debugPrint('[Migration] 2.11.1: Fixing Flock Raven tags on profile: ${profile.id}'); + final newTags = Map.from(tags); + final wikidata = newTags.remove('brand:wikidata'); + newTags.remove('brand'); + newTags['surveillance'] = 'outdoor'; + newTags['manufacturer'] = 'Flock Safety'; + newTags['manufacturer:wikidata'] = wikidata ?? 'Q108485435'; + profilesChanged = true; + return profile.copyWith(tags: newTags); + } + return profile; + }).toList(); + + if (profilesChanged) { + await ProfileService().save(updatedProfiles); + await appState.reloadProfiles(); + } + + // Fix any not-yet-uploaded queue entries that snapshotted the old tags + final queueChanged = await appState.migrateFlockRavenQueueTags(); + + debugPrint('[Migration] 2.11.1 completed: fixed Flock Raven tags ' + '(profiles changed=$profilesChanged, queue changed=$queueChanged)'); + } catch (e, stackTrace) { + debugPrint('[Migration] 2.11.1 ERROR: Failed to fix Flock Raven tags: $e'); + debugPrint('[Migration] 2.11.1 ERROR: Stack trace: $stackTrace'); + // Don't rethrow - non-critical, worst case stale tags persist until manually fixed + } + } + /// Get the migration function for a specific version static Future Function(AppState)? getMigrationForVersion(String version) { switch (version) { @@ -234,6 +279,8 @@ class OneTimeMigrations { return migrate_2_10_0; case '2.10.5': return migrate_2_10_5; + case '2.11.1': + return migrate_2_11_1; default: return null; } @@ -262,4 +309,98 @@ class OneTimeMigrations { } } } +} + +/// One-time (not version-gated) scan of the logged-in user's own live OSM +/// data for nodes still using the old Flock Raven tag scheme, offering to +/// correct them. Runs at most once ever: skipped permanently once the user +/// has answered the prompt, or once a scan finds nothing to fix. A failed +/// scan (e.g. offline) is *not* marked as done, so it's retried on a later +/// launch once conditions allow. +class FlockRavenLiveDataFix { + static const _promptedKey = 'flock_raven_live_fix_prompted'; + + /// The old (incorrect) Flock Raven tag scheme this scan looks for. + static const Map _staleTags = { + 'man_made': 'surveillance', + 'surveillance': 'public', + 'surveillance:type': 'gunshot_detector', + 'brand': 'Flock Safety', + }; + + static Future checkAndPrompt(AppState appState, BuildContext? context) async { + try { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_promptedKey) == true) return; + + if (!appState.isLoggedIn || appState.username.isEmpty) { + debugPrint('[FlockRavenLiveFix] Not logged in, will retry on a later launch'); + return; + } + + if (context == null || !context.mounted) { + debugPrint('[FlockRavenLiveFix] No context available, will retry on a later launch'); + return; + } + + debugPrint('[FlockRavenLiveFix] Scanning OSM for stale Flock Raven nodes owned by ${appState.username}'); + final staleNodes = await OverpassService().fetchNodesByUserAndTags( + username: appState.username, + tagFilter: _staleTags, + ); + + if (staleNodes.isEmpty) { + debugPrint('[FlockRavenLiveFix] No stale nodes found, marking as done'); + await prefs.setBool(_promptedKey, true); + return; + } + + if (!context.mounted) return; // Re-check after the async scan + + final count = staleNodes.length; + final shouldFix = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Update Flock Raven tags?'), + content: Text( + 'Found $count Flock Raven ${count == 1 ? 'node' : 'nodes'} on your OpenStreetMap ' + 'account using outdated tags. Update ${count == 1 ? 'it' : 'them'} to the correct tags now?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('No'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Yes'), + ), + ], + ), + ); + + if (shouldFix == true) { + final queued = appState.addFlockRavenCorrections(staleNodes); + debugPrint('[FlockRavenLiveFix] Queued ${queued.length} corrective uploads'); + } else { + debugPrint('[FlockRavenLiveFix] User declined the live data fix'); + } + + // Never ask again, regardless of the answer. + await prefs.setBool(_promptedKey, true); + } catch (e, stackTrace) { + debugPrint('[FlockRavenLiveFix] ERROR: Scan/prompt failed: $e'); + debugPrint('[FlockRavenLiveFix] Stack trace: $stackTrace'); + // Don't mark as prompted - retry on a later launch + } + } + + /// Dev-only: clear the "already prompted" flag so the scan/prompt fires + /// again on the next app launch, without needing a full app/data reset. + static Future resetPromptedFlagForTesting() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_promptedKey); + debugPrint('[FlockRavenLiveFix] Reset prompted flag for testing'); + } } \ No newline at end of file diff --git a/lib/models/node_profile.dart b/lib/models/node_profile.dart index cc8612ae..90b8a860 100644 --- a/lib/models/node_profile.dart +++ b/lib/models/node_profile.dart @@ -195,10 +195,10 @@ class NodeProfile { name: 'Flock Raven', tags: const { 'man_made': 'surveillance', - 'surveillance': 'public', + 'surveillance': 'outdoor', 'surveillance:type': 'gunshot_detector', - 'brand': 'Flock Safety', - 'brand:wikidata': 'Q108485435', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', }, builtin: true, requiresDirection: false, diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 485cc493..534ce982 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../dev_config.dart'; +import '../migrations.dart'; import '../services/localization_service.dart'; import '../services/nuclear_reset_service.dart'; import '../widgets/welcome_dialog.dart'; @@ -261,12 +262,33 @@ class AboutScreen extends StatelessWidget { ), ), ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _resetFlockRavenLiveFixPrompt(context), + icon: const Icon(Icons.replay), + label: const Text('Reset Flock Raven Live-Fix Prompt'), + ), + ), ], ), ), ); } + /// Dev-only: clears the one-time "already prompted" flag for the live OSM + /// Flock Raven tag scan, so it can be re-triggered on the next app launch + /// without a full data reset (which would also log the user out). + Future _resetFlockRavenLiveFixPrompt(BuildContext context) async { + await FlockRavenLiveDataFix.resetPromptedFlagForTesting(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Flag reset. Relaunch the app to retrigger the scan/prompt.')), + ); + } + } + /// Show confirmation dialog for nuclear reset Future _showNuclearResetConfirmation(BuildContext context) async { final confirmed = await showDialog( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 60d9ca86..bc64f42f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../dev_config.dart'; +import '../migrations.dart'; import '../widgets/map_view.dart'; import '../services/localization_service.dart'; import '../services/map_data_provider.dart'; @@ -233,7 +234,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Complete the version change workflow (updates last seen version) await ChangelogService().completeVersionChange(); - + + // One-time (not version-gated) scan/prompt for live OSM data still + // using the old Flock Raven tag scheme. + if (!mounted) return; + await FlockRavenLiveDataFix.checkAndPrompt(appState, context); + } catch (e) { // Silently handle errors to avoid breaking the app launch debugPrint('[HomeScreen] Error checking for popup: $e'); diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart index 0797daf0..f00db56d 100644 --- a/lib/services/changelog_service.dart +++ b/lib/services/changelog_service.dart @@ -244,7 +244,11 @@ class ChangelogService { if (needsMigration(lastSeenVersion, currentVersion, '2.10.5')) { versionsNeedingMigration.add('2.10.5'); } - + + if (needsMigration(lastSeenVersion, currentVersion, '2.11.1')) { + versionsNeedingMigration.add('2.11.1'); + } + // Future versions can be added here // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 90299d7e..92a4a6b5 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -192,6 +192,44 @@ out; } + /// Fetch nodes last edited by [username] that match a raw tag filter, with + /// no bounding box (global query). Used for one-time account-wide data-fix + /// scans (e.g. finding a user's own nodes still using a stale profile's + /// old tags after that profile's tags are corrected in a later release). + Future> fetchNodesByUserAndTags({ + required String username, + required Map tagFilter, + ResiliencePolicy? policy, + }) async { + final query = _buildUserTagQuery(username, tagFilter); + final endpoint = _primaryEndpoint; + final canFallback = _endpointOverride == null; + final effectivePolicy = policy ?? _policy; + + return executeWithFallback>( + primaryUrl: endpoint, + fallbackUrl: canFallback ? fallbackEndpoint : null, + execute: (url) => _attemptFetch(url, query, effectivePolicy), + classifyError: _classifyError, + policy: effectivePolicy, + ); + } + + /// Build an Overpass QL query for all nodes last touched by [username] + /// matching every entry in [tagFilter]. + String _buildUserTagQuery(String username, Map tagFilter) { + final escapedUser = username.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + final filters = tagFilter.entries + .map((entry) => '["${entry.key}"="${entry.value}"]') + .join(); + + return ''' +[out:json][timeout:60]; +node(user:"$escapedUser")$filters; +out body; +'''; + } + /// Deduplicate profiles for Overpass queries by removing profiles that are /// subsumed by others. A profile A subsumes profile B if all of A's /// non-empty tags exist in B with identical values — meaning every node diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index a7250b7c..332ec1e3 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -287,6 +287,61 @@ class UploadQueueState extends ChangeNotifier { return upload; } + /// Queue corrective "modify" uploads for live OSM nodes (found via an + /// account-wide scan, not necessarily in the local cache) that still carry + /// the old Flock Raven tag scheme. Unlike a normal edit session these nodes + /// may not be cached locally, so no pending-edit visual markers are added — + /// they'll simply refresh once each upload completes. + List addFlockRavenCorrections(List staleNodes, {required UploadMode uploadMode}) { + final correctedProfile = NodeProfile( + id: 'builtin-flock-raven', + name: 'Flock Raven', + tags: const { + 'man_made': 'surveillance', + 'surveillance': 'outdoor', + 'surveillance:type': 'gunshot_detector', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', + }, + builtin: true, + requiresDirection: false, + submittable: true, + editable: true, + ); + + final corrections = []; + + for (final node in staleNodes) { + final existingTags = Map.from(node.tags); + for (final key in correctedProfile.tags.keys) { + existingTags.remove(key); + } + existingTags.remove('brand'); + existingTags.remove('brand:wikidata'); + + final upload = PendingUpload( + coord: node.coord, + direction: 0, + profile: correctedProfile, + additionalExistingTags: existingTags, + changesetComment: 'Correct Flock Raven surveillance/manufacturer tags', + uploadMode: uploadMode, + operation: UploadOperation.modify, + originalNodeId: node.id, + ); + + _queue.add(upload); + corrections.add(upload); + } + + if (corrections.isNotEmpty) { + _saveQueue(); + notifyListeners(); + } + + return corrections; + } + void clearQueue() { // Clean up all pending nodes from cache before clearing queue for (final upload in _queue) { @@ -822,6 +877,66 @@ class UploadQueueState extends ChangeNotifier { notifyListeners(); } + /// Fix queued (not-yet-uploaded) Flock Raven entries that still carry the old + /// tag scheme ('surveillance': 'public', 'brand': 'Flock Safety') by + /// replacing them with the corrected scheme ('surveillance': 'outdoor', + /// 'manufacturer': 'Flock Safety'). Returns true if any entries were changed. + Future migrateFlockRavenQueueTags() async { + bool changed = false; + + for (var i = 0; i < _queue.length; i++) { + final upload = _queue[i]; + final profile = upload.profile; + if (profile == null) continue; + + final tags = profile.tags; + if (tags['surveillance'] == 'public' && + tags['brand'] == 'Flock Safety' && + tags['surveillance:type'] == 'gunshot_detector') { + final newTags = Map.from(tags); + final wikidata = newTags.remove('brand:wikidata'); + newTags.remove('brand'); + newTags['surveillance'] = 'outdoor'; + newTags['manufacturer'] = 'Flock Safety'; + newTags['manufacturer:wikidata'] = wikidata ?? 'Q108485435'; + + _queue[i] = PendingUpload( + coord: upload.coord, + direction: upload.direction, + profile: profile.copyWith(tags: newTags), + operatorProfile: upload.operatorProfile, + refinedTags: upload.refinedTags, + additionalExistingTags: upload.additionalExistingTags, + changesetComment: upload.changesetComment, + uploadMode: upload.uploadMode, + operation: upload.operation, + originalNodeId: upload.originalNodeId, + submittedNodeId: upload.submittedNodeId, + tempNodeId: upload.tempNodeId, + attempts: upload.attempts, + error: upload.error, + errorMessage: upload.errorMessage, + completing: upload.completing, + uploadState: upload.uploadState, + changesetId: upload.changesetId, + nodeOperationCompletedAt: upload.nodeOperationCompletedAt, + changesetCloseAttempts: upload.changesetCloseAttempts, + lastChangesetCloseAttemptAt: upload.lastChangesetCloseAttemptAt, + nodeSubmissionAttempts: upload.nodeSubmissionAttempts, + lastNodeSubmissionAttemptAt: upload.lastNodeSubmissionAttemptAt, + ); + changed = true; + } + } + + if (changed) { + await _saveQueue(); + notifyListeners(); + } + + return changed; + } + // Public method to manually trigger cache repopulation (useful for debugging or after cache clears) void repopulateCacheFromQueue() { _repopulateCacheFromQueue(); diff --git a/pubspec.yaml b/pubspec.yaml index d25567ff..cb32209e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 2.11.0+61 # The thing after the + is the version code, incremented with each release +version: 2.11.1+62 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.10.3 <4.0.0" # Resolved dependency floor (Dart 3.10.3 = Flutter 3.38+) diff --git a/test/migrations_test.dart b/test/migrations_test.dart new file mode 100644 index 00000000..ce4646d7 --- /dev/null +++ b/test/migrations_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/app_state.dart'; +import 'package:deflockapp/migrations.dart'; +import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/pending_upload.dart'; +import 'package:deflockapp/services/profile_service.dart'; +import 'package:deflockapp/state/settings_state.dart'; +import 'package:deflockapp/state/upload_queue_state.dart'; + +class MockAppState extends Mock implements AppState {} + +NodeProfile staleFlockRavenProfile() => NodeProfile( + id: 'builtin-flock-raven', + name: 'Flock Raven', + tags: const { + 'man_made': 'surveillance', + 'surveillance': 'public', + 'surveillance:type': 'gunshot_detector', + 'brand': 'Flock Safety', + 'brand:wikidata': 'Q108485435', + }, + builtin: true, + requiresDirection: false, + ); + +void main() { + late MockAppState mockAppState; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + mockAppState = MockAppState(); + AppState.instance = mockAppState; + when(() => mockAppState.offlineMode).thenReturn(false); + when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false); + when(() => mockAppState.reloadProfiles()).thenAnswer((_) async {}); + when(() => mockAppState.migrateFlockRavenQueueTags()).thenAnswer((_) async => false); + }); + + group('OneTimeMigrations.migrate_2_11_1 (profile catalog)', () { + test('fixes stale Flock Raven profile tags in storage', () async { + await ProfileService().save([staleFlockRavenProfile()]); + + await OneTimeMigrations.migrate_2_11_1(mockAppState); + + final saved = await ProfileService().load(); + final fixed = saved.firstWhere((p) => p.id == 'builtin-flock-raven'); + + expect(fixed.tags['surveillance'], 'outdoor'); + expect(fixed.tags['manufacturer'], 'Flock Safety'); + expect(fixed.tags['manufacturer:wikidata'], 'Q108485435'); + expect(fixed.tags.containsKey('brand'), isFalse); + expect(fixed.tags.containsKey('brand:wikidata'), isFalse); + + verify(() => mockAppState.reloadProfiles()).called(1); + verify(() => mockAppState.migrateFlockRavenQueueTags()).called(1); + }); + + test('leaves an already-correct profile untouched', () async { + final correctProfile = staleFlockRavenProfile().copyWith(tags: const { + 'man_made': 'surveillance', + 'surveillance': 'outdoor', + 'surveillance:type': 'gunshot_detector', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', + }); + await ProfileService().save([correctProfile]); + + await OneTimeMigrations.migrate_2_11_1(mockAppState); + + verifyNever(() => mockAppState.reloadProfiles()); + }); + + test('does not touch the unrelated ALPR "Flock" profile', () async { + final alprFlock = NodeProfile( + id: 'builtin-flock', + name: 'Flock', + tags: const { + 'man_made': 'surveillance', + 'surveillance': 'public', + 'surveillance:type': 'ALPR', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', + }, + builtin: true, + ); + await ProfileService().save([alprFlock]); + + await OneTimeMigrations.migrate_2_11_1(mockAppState); + + final saved = await ProfileService().load(); + expect(saved.single.tags, alprFlock.tags); + verifyNever(() => mockAppState.reloadProfiles()); + }); + }); + + group('UploadQueueState.migrateFlockRavenQueueTags', () { + test('fixes a queued (not-yet-uploaded) Flock Raven entry', () async { + final pending = PendingUpload( + coord: const LatLng(1, 2), + direction: 0, + profile: staleFlockRavenProfile(), + changesetComment: 'Add Flock Raven surveillance node', + uploadMode: UploadMode.simulate, + operation: UploadOperation.create, + ); + + SharedPreferences.setMockInitialValues({ + 'queue': jsonEncode([pending.toJson()]), + }); + + final state = UploadQueueState(); + await state.init(); + + final changed = await state.migrateFlockRavenQueueTags(); + expect(changed, isTrue); + + final fixedTags = state.pendingUploads.single.profile!.tags; + expect(fixedTags['surveillance'], 'outdoor'); + expect(fixedTags['manufacturer'], 'Flock Safety'); + expect(fixedTags.containsKey('brand'), isFalse); + + // Persisted change should survive a reload. + final reloaded = UploadQueueState(); + await reloaded.init(); + expect(reloaded.pendingUploads.single.profile!.tags['surveillance'], 'outdoor'); + }); + + test('leaves a queue with no Flock Raven entries unchanged', () async { + SharedPreferences.setMockInitialValues({'queue': jsonEncode([])}); + + final state = UploadQueueState(); + await state.init(); + + final changed = await state.migrateFlockRavenQueueTags(); + expect(changed, isFalse); + }); + }); +} From c27296d826214f835d4f879b1ec31bc40ef43546 Mon Sep 17 00:00:00 2001 From: David Popescu <80603759+DavidPopesc@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:09:37 -0400 Subject: [PATCH 3/6] Refactor: Remove Flock Raven live data fix logic and related methods. --- lib/app_state.dart | 10 ---- lib/migrations.dart | 95 ------------------------------ lib/screens/about_screen.dart | 22 ------- lib/screens/home_screen.dart | 16 +++-- lib/services/overpass_service.dart | 38 ------------ lib/state/upload_queue_state.dart | 55 ----------------- test/migrations_test.dart | 1 - 7 files changed, 10 insertions(+), 227 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 224a29ce..2507269a 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -616,16 +616,6 @@ class AppState extends ChangeNotifier { } } - /// Queue corrective uploads for the user's own live OSM nodes still using - /// the old Flock Raven tag scheme (for the one-time live-data fix prompt). - List addFlockRavenCorrections(List staleNodes) { - final corrections = _uploadQueueState.addFlockRavenCorrections(staleNodes, uploadMode: uploadMode); - if (corrections.isNotEmpty) { - _startUploader(); - } - return corrections; - } - void deleteNode(OsmNode node, {String? changesetComment}) { _uploadQueueState.addFromNodeDeletion(node, uploadMode: uploadMode, changesetComment: changesetComment); _startUploader(); diff --git a/lib/migrations.dart b/lib/migrations.dart index 3cec690e..c8ef1264 100644 --- a/lib/migrations.dart +++ b/lib/migrations.dart @@ -4,7 +4,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app_state.dart'; import 'services/offline_area_service.dart'; -import 'services/overpass_service.dart'; import 'services/profile_service.dart'; import 'services/suspected_location_cache.dart'; import 'widgets/nuclear_reset_dialog.dart'; @@ -309,98 +308,4 @@ class OneTimeMigrations { } } } -} - -/// One-time (not version-gated) scan of the logged-in user's own live OSM -/// data for nodes still using the old Flock Raven tag scheme, offering to -/// correct them. Runs at most once ever: skipped permanently once the user -/// has answered the prompt, or once a scan finds nothing to fix. A failed -/// scan (e.g. offline) is *not* marked as done, so it's retried on a later -/// launch once conditions allow. -class FlockRavenLiveDataFix { - static const _promptedKey = 'flock_raven_live_fix_prompted'; - - /// The old (incorrect) Flock Raven tag scheme this scan looks for. - static const Map _staleTags = { - 'man_made': 'surveillance', - 'surveillance': 'public', - 'surveillance:type': 'gunshot_detector', - 'brand': 'Flock Safety', - }; - - static Future checkAndPrompt(AppState appState, BuildContext? context) async { - try { - final prefs = await SharedPreferences.getInstance(); - if (prefs.getBool(_promptedKey) == true) return; - - if (!appState.isLoggedIn || appState.username.isEmpty) { - debugPrint('[FlockRavenLiveFix] Not logged in, will retry on a later launch'); - return; - } - - if (context == null || !context.mounted) { - debugPrint('[FlockRavenLiveFix] No context available, will retry on a later launch'); - return; - } - - debugPrint('[FlockRavenLiveFix] Scanning OSM for stale Flock Raven nodes owned by ${appState.username}'); - final staleNodes = await OverpassService().fetchNodesByUserAndTags( - username: appState.username, - tagFilter: _staleTags, - ); - - if (staleNodes.isEmpty) { - debugPrint('[FlockRavenLiveFix] No stale nodes found, marking as done'); - await prefs.setBool(_promptedKey, true); - return; - } - - if (!context.mounted) return; // Re-check after the async scan - - final count = staleNodes.length; - final shouldFix = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: const Text('Update Flock Raven tags?'), - content: Text( - 'Found $count Flock Raven ${count == 1 ? 'node' : 'nodes'} on your OpenStreetMap ' - 'account using outdated tags. Update ${count == 1 ? 'it' : 'them'} to the correct tags now?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('No'), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Yes'), - ), - ], - ), - ); - - if (shouldFix == true) { - final queued = appState.addFlockRavenCorrections(staleNodes); - debugPrint('[FlockRavenLiveFix] Queued ${queued.length} corrective uploads'); - } else { - debugPrint('[FlockRavenLiveFix] User declined the live data fix'); - } - - // Never ask again, regardless of the answer. - await prefs.setBool(_promptedKey, true); - } catch (e, stackTrace) { - debugPrint('[FlockRavenLiveFix] ERROR: Scan/prompt failed: $e'); - debugPrint('[FlockRavenLiveFix] Stack trace: $stackTrace'); - // Don't mark as prompted - retry on a later launch - } - } - - /// Dev-only: clear the "already prompted" flag so the scan/prompt fires - /// again on the next app launch, without needing a full app/data reset. - static Future resetPromptedFlagForTesting() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_promptedKey); - debugPrint('[FlockRavenLiveFix] Reset prompted flag for testing'); - } } \ No newline at end of file diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 534ce982..485cc493 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../dev_config.dart'; -import '../migrations.dart'; import '../services/localization_service.dart'; import '../services/nuclear_reset_service.dart'; import '../widgets/welcome_dialog.dart'; @@ -262,33 +261,12 @@ class AboutScreen extends StatelessWidget { ), ), ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _resetFlockRavenLiveFixPrompt(context), - icon: const Icon(Icons.replay), - label: const Text('Reset Flock Raven Live-Fix Prompt'), - ), - ), ], ), ), ); } - /// Dev-only: clears the one-time "already prompted" flag for the live OSM - /// Flock Raven tag scan, so it can be re-triggered on the next app launch - /// without a full data reset (which would also log the user out). - Future _resetFlockRavenLiveFixPrompt(BuildContext context) async { - await FlockRavenLiveDataFix.resetPromptedFlagForTesting(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Flag reset. Relaunch the app to retrigger the scan/prompt.')), - ); - } - } - /// Show confirmation dialog for nuclear reset Future _showNuclearResetConfirmation(BuildContext context) async { final confirmed = await showDialog( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index bc64f42f..fff1784e 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,7 +6,6 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../dev_config.dart'; -import '../migrations.dart'; import '../widgets/map_view.dart'; import '../services/localization_service.dart'; import '../services/map_data_provider.dart'; @@ -235,11 +234,6 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Complete the version change workflow (updates last seen version) await ChangelogService().completeVersionChange(); - // One-time (not version-gated) scan/prompt for live OSM data still - // using the old Flock Raven tag scheme. - if (!mounted) return; - await FlockRavenLiveDataFix.checkAndPrompt(appState, context); - } catch (e) { // Silently handle errors to avoid breaking the app launch debugPrint('[HomeScreen] Error checking for popup: $e'); @@ -341,6 +335,14 @@ class _HomeScreenState extends State with TickerProviderStateMixin { onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id), ); + // Deleting the node rebuilds this sheet mid-close (into a "deleted" + // placeholder of a different height) before the close animation settles. + // That late MeasuredSheet measurement can arrive after `closed` has + // already reset the height below, leaving it stuck non-zero forever + // (and the location button permanently greyed out). Ignore any height + // updates once the sheet has actually closed. + bool sheetClosed = false; + final controller = _scaffoldKey.currentState!.showBottomSheet( (ctx) => Padding( padding: EdgeInsets.only( @@ -348,6 +350,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), child: MeasuredSheet( onHeightChanged: (height) { + if (sheetClosed) return; _sheetCoordinator.updateTagSheetHeight( height + MediaQuery.of(context).padding.bottom, () => setState(() {}), @@ -382,6 +385,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Reset height and selection when sheet is dismissed (unless transitioning to edit) controller.closed.then((_) { + sheetClosed = true; if (!_sheetCoordinator.transitioningToEdit) { _sheetCoordinator.resetTagSheetHeight(() => setState(() {})); setState(() => _selectedNodeId = null); diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 92a4a6b5..90299d7e 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -192,44 +192,6 @@ out; } - /// Fetch nodes last edited by [username] that match a raw tag filter, with - /// no bounding box (global query). Used for one-time account-wide data-fix - /// scans (e.g. finding a user's own nodes still using a stale profile's - /// old tags after that profile's tags are corrected in a later release). - Future> fetchNodesByUserAndTags({ - required String username, - required Map tagFilter, - ResiliencePolicy? policy, - }) async { - final query = _buildUserTagQuery(username, tagFilter); - final endpoint = _primaryEndpoint; - final canFallback = _endpointOverride == null; - final effectivePolicy = policy ?? _policy; - - return executeWithFallback>( - primaryUrl: endpoint, - fallbackUrl: canFallback ? fallbackEndpoint : null, - execute: (url) => _attemptFetch(url, query, effectivePolicy), - classifyError: _classifyError, - policy: effectivePolicy, - ); - } - - /// Build an Overpass QL query for all nodes last touched by [username] - /// matching every entry in [tagFilter]. - String _buildUserTagQuery(String username, Map tagFilter) { - final escapedUser = username.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); - final filters = tagFilter.entries - .map((entry) => '["${entry.key}"="${entry.value}"]') - .join(); - - return ''' -[out:json][timeout:60]; -node(user:"$escapedUser")$filters; -out body; -'''; - } - /// Deduplicate profiles for Overpass queries by removing profiles that are /// subsumed by others. A profile A subsumes profile B if all of A's /// non-empty tags exist in B with identical values — meaning every node diff --git a/lib/state/upload_queue_state.dart b/lib/state/upload_queue_state.dart index 332ec1e3..565147e9 100644 --- a/lib/state/upload_queue_state.dart +++ b/lib/state/upload_queue_state.dart @@ -287,61 +287,6 @@ class UploadQueueState extends ChangeNotifier { return upload; } - /// Queue corrective "modify" uploads for live OSM nodes (found via an - /// account-wide scan, not necessarily in the local cache) that still carry - /// the old Flock Raven tag scheme. Unlike a normal edit session these nodes - /// may not be cached locally, so no pending-edit visual markers are added — - /// they'll simply refresh once each upload completes. - List addFlockRavenCorrections(List staleNodes, {required UploadMode uploadMode}) { - final correctedProfile = NodeProfile( - id: 'builtin-flock-raven', - name: 'Flock Raven', - tags: const { - 'man_made': 'surveillance', - 'surveillance': 'outdoor', - 'surveillance:type': 'gunshot_detector', - 'manufacturer': 'Flock Safety', - 'manufacturer:wikidata': 'Q108485435', - }, - builtin: true, - requiresDirection: false, - submittable: true, - editable: true, - ); - - final corrections = []; - - for (final node in staleNodes) { - final existingTags = Map.from(node.tags); - for (final key in correctedProfile.tags.keys) { - existingTags.remove(key); - } - existingTags.remove('brand'); - existingTags.remove('brand:wikidata'); - - final upload = PendingUpload( - coord: node.coord, - direction: 0, - profile: correctedProfile, - additionalExistingTags: existingTags, - changesetComment: 'Correct Flock Raven surveillance/manufacturer tags', - uploadMode: uploadMode, - operation: UploadOperation.modify, - originalNodeId: node.id, - ); - - _queue.add(upload); - corrections.add(upload); - } - - if (corrections.isNotEmpty) { - _saveQueue(); - notifyListeners(); - } - - return corrections; - } - void clearQueue() { // Clean up all pending nodes from cache before clearing queue for (final upload in _queue) { diff --git a/test/migrations_test.dart b/test/migrations_test.dart index ce4646d7..0127b2d7 100644 --- a/test/migrations_test.dart +++ b/test/migrations_test.dart @@ -10,7 +10,6 @@ import 'package:deflockapp/migrations.dart'; import 'package:deflockapp/models/node_profile.dart'; import 'package:deflockapp/models/pending_upload.dart'; import 'package:deflockapp/services/profile_service.dart'; -import 'package:deflockapp/state/settings_state.dart'; import 'package:deflockapp/state/upload_queue_state.dart'; class MockAppState extends Mock implements AppState {} From 1685a25a66a7e8cbaa337f92349a743be11abd94 Mon Sep 17 00:00:00 2001 From: David Popescu <80603759+DavidPopesc@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:17:19 -0400 Subject: [PATCH 4/6] Remove migrations_test.dart file as it is no longer needed --- test/migrations_test.dart | 143 -------------------------------------- 1 file changed, 143 deletions(-) delete mode 100644 test/migrations_test.dart diff --git a/test/migrations_test.dart b/test/migrations_test.dart deleted file mode 100644 index 0127b2d7..00000000 --- a/test/migrations_test.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:deflockapp/app_state.dart'; -import 'package:deflockapp/migrations.dart'; -import 'package:deflockapp/models/node_profile.dart'; -import 'package:deflockapp/models/pending_upload.dart'; -import 'package:deflockapp/services/profile_service.dart'; -import 'package:deflockapp/state/upload_queue_state.dart'; - -class MockAppState extends Mock implements AppState {} - -NodeProfile staleFlockRavenProfile() => NodeProfile( - id: 'builtin-flock-raven', - name: 'Flock Raven', - tags: const { - 'man_made': 'surveillance', - 'surveillance': 'public', - 'surveillance:type': 'gunshot_detector', - 'brand': 'Flock Safety', - 'brand:wikidata': 'Q108485435', - }, - builtin: true, - requiresDirection: false, - ); - -void main() { - late MockAppState mockAppState; - - setUp(() { - SharedPreferences.setMockInitialValues({}); - mockAppState = MockAppState(); - AppState.instance = mockAppState; - when(() => mockAppState.offlineMode).thenReturn(false); - when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false); - when(() => mockAppState.reloadProfiles()).thenAnswer((_) async {}); - when(() => mockAppState.migrateFlockRavenQueueTags()).thenAnswer((_) async => false); - }); - - group('OneTimeMigrations.migrate_2_11_1 (profile catalog)', () { - test('fixes stale Flock Raven profile tags in storage', () async { - await ProfileService().save([staleFlockRavenProfile()]); - - await OneTimeMigrations.migrate_2_11_1(mockAppState); - - final saved = await ProfileService().load(); - final fixed = saved.firstWhere((p) => p.id == 'builtin-flock-raven'); - - expect(fixed.tags['surveillance'], 'outdoor'); - expect(fixed.tags['manufacturer'], 'Flock Safety'); - expect(fixed.tags['manufacturer:wikidata'], 'Q108485435'); - expect(fixed.tags.containsKey('brand'), isFalse); - expect(fixed.tags.containsKey('brand:wikidata'), isFalse); - - verify(() => mockAppState.reloadProfiles()).called(1); - verify(() => mockAppState.migrateFlockRavenQueueTags()).called(1); - }); - - test('leaves an already-correct profile untouched', () async { - final correctProfile = staleFlockRavenProfile().copyWith(tags: const { - 'man_made': 'surveillance', - 'surveillance': 'outdoor', - 'surveillance:type': 'gunshot_detector', - 'manufacturer': 'Flock Safety', - 'manufacturer:wikidata': 'Q108485435', - }); - await ProfileService().save([correctProfile]); - - await OneTimeMigrations.migrate_2_11_1(mockAppState); - - verifyNever(() => mockAppState.reloadProfiles()); - }); - - test('does not touch the unrelated ALPR "Flock" profile', () async { - final alprFlock = NodeProfile( - id: 'builtin-flock', - name: 'Flock', - tags: const { - 'man_made': 'surveillance', - 'surveillance': 'public', - 'surveillance:type': 'ALPR', - 'manufacturer': 'Flock Safety', - 'manufacturer:wikidata': 'Q108485435', - }, - builtin: true, - ); - await ProfileService().save([alprFlock]); - - await OneTimeMigrations.migrate_2_11_1(mockAppState); - - final saved = await ProfileService().load(); - expect(saved.single.tags, alprFlock.tags); - verifyNever(() => mockAppState.reloadProfiles()); - }); - }); - - group('UploadQueueState.migrateFlockRavenQueueTags', () { - test('fixes a queued (not-yet-uploaded) Flock Raven entry', () async { - final pending = PendingUpload( - coord: const LatLng(1, 2), - direction: 0, - profile: staleFlockRavenProfile(), - changesetComment: 'Add Flock Raven surveillance node', - uploadMode: UploadMode.simulate, - operation: UploadOperation.create, - ); - - SharedPreferences.setMockInitialValues({ - 'queue': jsonEncode([pending.toJson()]), - }); - - final state = UploadQueueState(); - await state.init(); - - final changed = await state.migrateFlockRavenQueueTags(); - expect(changed, isTrue); - - final fixedTags = state.pendingUploads.single.profile!.tags; - expect(fixedTags['surveillance'], 'outdoor'); - expect(fixedTags['manufacturer'], 'Flock Safety'); - expect(fixedTags.containsKey('brand'), isFalse); - - // Persisted change should survive a reload. - final reloaded = UploadQueueState(); - await reloaded.init(); - expect(reloaded.pendingUploads.single.profile!.tags['surveillance'], 'outdoor'); - }); - - test('leaves a queue with no Flock Raven entries unchanged', () async { - SharedPreferences.setMockInitialValues({'queue': jsonEncode([])}); - - final state = UploadQueueState(); - await state.init(); - - final changed = await state.migrateFlockRavenQueueTags(); - expect(changed, isFalse); - }); - }); -} From 8284c77e59d065290c7f8fc1df439ddeec7bdf89 Mon Sep 17 00:00:00 2001 From: David Popescu <80603759+DavidPopesc@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:22:41 -0400 Subject: [PATCH 5/6] Revert "Remove migrations_test.dart file as it is no longer needed" This reverts commit 1685a25a66a7e8cbaa337f92349a743be11abd94. --- test/migrations_test.dart | 143 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 test/migrations_test.dart diff --git a/test/migrations_test.dart b/test/migrations_test.dart new file mode 100644 index 00000000..0127b2d7 --- /dev/null +++ b/test/migrations_test.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/app_state.dart'; +import 'package:deflockapp/migrations.dart'; +import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/pending_upload.dart'; +import 'package:deflockapp/services/profile_service.dart'; +import 'package:deflockapp/state/upload_queue_state.dart'; + +class MockAppState extends Mock implements AppState {} + +NodeProfile staleFlockRavenProfile() => NodeProfile( + id: 'builtin-flock-raven', + name: 'Flock Raven', + tags: const { + 'man_made': 'surveillance', + 'surveillance': 'public', + 'surveillance:type': 'gunshot_detector', + 'brand': 'Flock Safety', + 'brand:wikidata': 'Q108485435', + }, + builtin: true, + requiresDirection: false, + ); + +void main() { + late MockAppState mockAppState; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + mockAppState = MockAppState(); + AppState.instance = mockAppState; + when(() => mockAppState.offlineMode).thenReturn(false); + when(() => mockAppState.offlineFeaturesEnabled).thenReturn(false); + when(() => mockAppState.reloadProfiles()).thenAnswer((_) async {}); + when(() => mockAppState.migrateFlockRavenQueueTags()).thenAnswer((_) async => false); + }); + + group('OneTimeMigrations.migrate_2_11_1 (profile catalog)', () { + test('fixes stale Flock Raven profile tags in storage', () async { + await ProfileService().save([staleFlockRavenProfile()]); + + await OneTimeMigrations.migrate_2_11_1(mockAppState); + + final saved = await ProfileService().load(); + final fixed = saved.firstWhere((p) => p.id == 'builtin-flock-raven'); + + expect(fixed.tags['surveillance'], 'outdoor'); + expect(fixed.tags['manufacturer'], 'Flock Safety'); + expect(fixed.tags['manufacturer:wikidata'], 'Q108485435'); + expect(fixed.tags.containsKey('brand'), isFalse); + expect(fixed.tags.containsKey('brand:wikidata'), isFalse); + + verify(() => mockAppState.reloadProfiles()).called(1); + verify(() => mockAppState.migrateFlockRavenQueueTags()).called(1); + }); + + test('leaves an already-correct profile untouched', () async { + final correctProfile = staleFlockRavenProfile().copyWith(tags: const { + 'man_made': 'surveillance', + 'surveillance': 'outdoor', + 'surveillance:type': 'gunshot_detector', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', + }); + await ProfileService().save([correctProfile]); + + await OneTimeMigrations.migrate_2_11_1(mockAppState); + + verifyNever(() => mockAppState.reloadProfiles()); + }); + + test('does not touch the unrelated ALPR "Flock" profile', () async { + final alprFlock = NodeProfile( + id: 'builtin-flock', + name: 'Flock', + tags: const { + 'man_made': 'surveillance', + 'surveillance': 'public', + 'surveillance:type': 'ALPR', + 'manufacturer': 'Flock Safety', + 'manufacturer:wikidata': 'Q108485435', + }, + builtin: true, + ); + await ProfileService().save([alprFlock]); + + await OneTimeMigrations.migrate_2_11_1(mockAppState); + + final saved = await ProfileService().load(); + expect(saved.single.tags, alprFlock.tags); + verifyNever(() => mockAppState.reloadProfiles()); + }); + }); + + group('UploadQueueState.migrateFlockRavenQueueTags', () { + test('fixes a queued (not-yet-uploaded) Flock Raven entry', () async { + final pending = PendingUpload( + coord: const LatLng(1, 2), + direction: 0, + profile: staleFlockRavenProfile(), + changesetComment: 'Add Flock Raven surveillance node', + uploadMode: UploadMode.simulate, + operation: UploadOperation.create, + ); + + SharedPreferences.setMockInitialValues({ + 'queue': jsonEncode([pending.toJson()]), + }); + + final state = UploadQueueState(); + await state.init(); + + final changed = await state.migrateFlockRavenQueueTags(); + expect(changed, isTrue); + + final fixedTags = state.pendingUploads.single.profile!.tags; + expect(fixedTags['surveillance'], 'outdoor'); + expect(fixedTags['manufacturer'], 'Flock Safety'); + expect(fixedTags.containsKey('brand'), isFalse); + + // Persisted change should survive a reload. + final reloaded = UploadQueueState(); + await reloaded.init(); + expect(reloaded.pendingUploads.single.profile!.tags['surveillance'], 'outdoor'); + }); + + test('leaves a queue with no Flock Raven entries unchanged', () async { + SharedPreferences.setMockInitialValues({'queue': jsonEncode([])}); + + final state = UploadQueueState(); + await state.init(); + + final changed = await state.migrateFlockRavenQueueTags(); + expect(changed, isFalse); + }); + }); +} From 2afc68152a0900ec84ff4d55c895379b209c5881 Mon Sep 17 00:00:00 2001 From: David Popescu <80603759+DavidPopesc@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:33:37 -0400 Subject: [PATCH 6/6] Update iOS deployment target to be back on 13.0 in project configuration --- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 35c146ed..3c09d15a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -364,7 +364,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -494,7 +494,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -545,7 +545,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos;