diff --git a/README.md b/README.md index 5760328..f04eb31 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with - **Map surveillance infrastructure** including cameras, ALPRs, gunshot detectors, and more with precise location, direction, and manufacturer details - **Upload to OpenStreetMap** with OAuth2 integration (live or sandbox modes) - **Work completely offline** with downloadable map areas and device data, plus upload queue -- **Multiple map types** including satellite imagery from USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support +- **Multiple map types** including satellite imagery from Bing Maps, USGS, Esri, Mapbox, and topographic maps from OpenTopoMap, plus custom map tile provider support - **Editing Ability** to update existing device locations and properties - **Built-in device profiles** for Flock Safety, Motorola, Genetec, Leonardo, and other major manufacturers, plus custom profiles for more specific tag sets @@ -30,7 +30,7 @@ A comprehensive Flutter app for mapping public surveillance infrastructure with ## Key Features ### Map & Navigation -- **Multi-source tiles**: Switch between OpenStreetMap, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers +- **Multi-source tiles**: Switch between OpenStreetMap, Bing satellite imagery, USGS imagery, Esri imagery, Mapbox, OpenTopoMap, and any custom providers - **Offline-first design**: Download a region for complete offline operation - **Smooth UX**: Intuitive controls, follow-me mode with GPS rotation, compass indicator with north-lock, and gesture-friendly interactions - **Device visualization**: Color-coded markers showing real devices (blue), pending uploads (purple), pending edits (grey), devices being edited (orange), and pending deletions (red) @@ -110,7 +110,6 @@ cp lib/keys.dart.example lib/keys.dart - Delete the old one (also wrong answer unless user chooses intentionally) - Give multiple of these options?? - Nav start+end too close together error (warning + disable submit button?) -- Add some builtin satellite tile provider - Persistent cache for MY submissions: assume submissions worked, cache,clean up when we see that node appear in overpass/OSM results or when older than 24h - Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?) - Tutorial / info guide before submitting first node, info and links before creating first profile diff --git a/assets/changelog.json b/assets/changelog.json index 241ab58..452a39e 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -1,4 +1,10 @@ { + "1.5.1": { + "content": [ + "• NEW: Bing satellite imagery - high-quality satellite tiles used by the iD editor, no API key required", + "• IMPROVED: Added support for quadkey tile formats and multi-subdomain providers" + ] + }, "1.5.0": { "content": [ "• NEW: First-submission guide popup - provides essential guidance and links before your first device submission", diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index cdcb92d..6b6b764 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -21,7 +21,22 @@ class TileType { /// Create URL for a specific tile, replacing template variables String getTileUrl(int z, int x, int y, {String? apiKey}) { - String url = urlTemplate + String url = urlTemplate; + + // Handle Bing Maps quadkey conversion + if (url.contains('{quadkey}')) { + final quadkey = _convertToQuadkey(x, y, z); + url = url.replaceAll('{quadkey}', quadkey); + } + + // Handle subdomains (for Bing Maps and other multi-subdomain providers) + if (url.contains('{subdomain}')) { + final subdomain = (x + y) % 4; // Distribute across 0-3 subdomains + url = url.replaceAll('{subdomain}', subdomain.toString()); + } + + // Standard x/y/z replacement + url = url .replaceAll('{z}', z.toString()) .replaceAll('{x}', x.toString()) .replaceAll('{y}', y.toString()); @@ -33,6 +48,19 @@ class TileType { return url; } + /// Convert x, y, z to Bing Maps quadkey format + String _convertToQuadkey(int x, int y, int z) { + final quadkey = StringBuffer(); + for (int i = z; i > 0; i--) { + int digit = 0; + final mask = 1 << (i - 1); + if ((x & mask) != 0) digit++; + if ((y & mask) != 0) digit += 2; + quadkey.write(digit); + } + return quadkey.toString(); + } + /// Check if this tile type needs an API key bool get requiresApiKey => urlTemplate.contains('{api_key}'); @@ -161,6 +189,19 @@ class DefaultTileProviders { ), ], ), + TileProvider( + id: 'bing', + name: 'Bing Maps', + tileTypes: [ + TileType( + id: 'bing_satellite', + name: 'Satellite', + urlTemplate: 'https://ecn.t{subdomain}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z', + attribution: '© Microsoft Corporation', + maxZoom: 20, + ), + ], + ), TileProvider( id: 'mapbox', name: 'Mapbox', diff --git a/pubspec.yaml b/pubspec.yaml index 55713d8..1bc92ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 1.5.0+18 # The thing after the + is the version code, incremented with each release +version: 1.5.1+19 # The thing after the + is the version code, incremented with each release environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+ diff --git a/test/models/tile_provider_test.dart b/test/models/tile_provider_test.dart new file mode 100644 index 0000000..2ad8ea1 --- /dev/null +++ b/test/models/tile_provider_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flock_map_app/models/tile_provider.dart'; + +void main() { + group('TileType', () { + test('getTileUrl handles standard x/y/z replacement', () { + final tileType = TileType( + id: 'test', + name: 'Test', + urlTemplate: 'https://example.com/{z}/{x}/{y}.png', + attribution: 'Test', + ); + + final url = tileType.getTileUrl(3, 2, 1); + expect(url, 'https://example.com/3/2/1.png'); + }); + + test('getTileUrl handles subdomain replacement', () { + final tileType = TileType( + id: 'test', + name: 'Test', + urlTemplate: 'https://a{subdomain}.example.com/{z}/{x}/{y}.png', + attribution: 'Test', + ); + + // Test subdomain distribution (should be consistent) + final url1 = tileType.getTileUrl(1, 2, 3); + final url2 = tileType.getTileUrl(1, 2, 3); + expect(url1, url2); // Same input should give same output + + // Test that different tiles can get different subdomains + final url3 = tileType.getTileUrl(1, 0, 0); + final url4 = tileType.getTileUrl(1, 1, 1); + expect(url3, contains('a0.example.com')); + expect(url4, contains('a2.example.com')); + }); + + test('getTileUrl handles Bing Maps quadkey conversion', () { + final tileType = TileType( + id: 'bing_test', + name: 'Bing Test', + urlTemplate: 'https://ecn.t{subdomain}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=1&n=z', + attribution: 'Microsoft', + ); + + // Test some known quadkey conversions + // x=0, y=0, z=1 should give quadkey "0" + final url1 = tileType.getTileUrl(1, 0, 0); + expect(url1, contains('a0.jpeg')); + + // x=1, y=0, z=1 should give quadkey "1" + final url2 = tileType.getTileUrl(1, 1, 0); + expect(url2, contains('a1.jpeg')); + + // x=0, y=1, z=1 should give quadkey "2" + final url3 = tileType.getTileUrl(1, 0, 1); + expect(url3, contains('a2.jpeg')); + + // x=1, y=1, z=1 should give quadkey "3" + final url4 = tileType.getTileUrl(1, 1, 1); + expect(url4, contains('a3.jpeg')); + + // More complex example: x=3, y=5, z=3 should give quadkey "213" + final url5 = tileType.getTileUrl(3, 3, 5); + expect(url5, contains('a213.jpeg')); + }); + + test('getTileUrl handles API key replacement', () { + final tileType = TileType( + id: 'test', + name: 'Test', + urlTemplate: 'https://api.example.com/{z}/{x}/{y}?key={api_key}', + attribution: 'Test', + ); + + final url = tileType.getTileUrl(1, 2, 3, apiKey: 'mykey123'); + expect(url, 'https://api.example.com/1/2/3?key=mykey123'); + }); + + test('requiresApiKey detects API key requirement correctly', () { + final tileTypeWithKey = TileType( + id: 'test1', + name: 'Test 1', + urlTemplate: 'https://api.example.com/{z}/{x}/{y}?key={api_key}', + attribution: 'Test', + ); + + final tileTypeWithoutKey = TileType( + id: 'test2', + name: 'Test 2', + urlTemplate: 'https://example.com/{z}/{x}/{y}.png', + attribution: 'Test', + ); + + expect(tileTypeWithKey.requiresApiKey, isTrue); + expect(tileTypeWithoutKey.requiresApiKey, isFalse); + }); + }); + + group('DefaultTileProviders', () { + test('contains Bing satellite provider', () { + final providers = DefaultTileProviders.createDefaults(); + final bingProvider = providers.firstWhere((p) => p.id == 'bing'); + + expect(bingProvider.name, 'Bing Maps'); + expect(bingProvider.tileTypes, hasLength(1)); + + final satelliteType = bingProvider.tileTypes.first; + expect(satelliteType.id, 'bing_satellite'); + expect(satelliteType.name, 'Satellite'); + expect(satelliteType.urlTemplate, contains('quadkey')); + expect(satelliteType.urlTemplate, contains('subdomain')); + expect(satelliteType.requiresApiKey, isFalse); + expect(satelliteType.attribution, '© Microsoft Corporation'); + }); + + test('all default providers are usable', () { + final providers = DefaultTileProviders.createDefaults(); + for (final provider in providers) { + expect(provider.isUsable, isTrue, reason: '${provider.name} should be usable'); + } + }); + }); +} \ No newline at end of file