Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/models/tile_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';

import '../services/service_policy.dart';

/// A specific tile type within a provider
class TileType {
final String id;
Expand Down Expand Up @@ -76,6 +78,14 @@ class TileType {
/// Check if this tile type needs an API key
bool get requiresApiKey => urlTemplate.contains('{api_key}');

/// Whether this tile server's usage policy permits offline/bulk downloading.
/// Resolved via [ServicePolicyResolver] from the URL template.
bool get allowsOfflineDownload =>
ServicePolicyResolver.resolve(urlTemplate).allowsOfflineDownload;

/// The service policy that applies to this tile type's server.
ServicePolicy get servicePolicy => ServicePolicyResolver.resolve(urlTemplate);

Map<String, dynamic> toJson() => {
'id': id,
'name': name,
Expand Down
25 changes: 17 additions & 8 deletions lib/services/map_data_submodules/nodes_from_osm_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:xml/xml.dart';
import '../../models/node_profile.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../service_policy.dart';

/// Fetches surveillance nodes from the direct OSM API using bbox query.
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
Expand Down Expand Up @@ -56,28 +57,36 @@ Future<List<OsmNode>> _fetchFromOsmApi({
try {
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
debugPrint('[fetchOsmApiNodes] URL: $url');

final response = await http.get(Uri.parse(url));


// Enforce max 2 concurrent download threads per OSM API usage policy
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);

final http.Response response;
try {
response = await http.get(Uri.parse(url));
} finally {
ServiceRateLimiter.release(ServiceType.osmEditingApi);
}

if (response.statusCode != 200) {
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
}

// Parse XML response
final document = XmlDocument.parse(response.body);
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);

if (nodes.isNotEmpty) {
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
}

// Don't report success here - let the top level handle it
return nodes;

} catch (e) {
debugPrint('[fetchOsmApiNodes] Exception: $e');

// Don't report status here - let the top level handle it
rethrow; // Re-throw to let caller handle
}
Expand Down
95 changes: 79 additions & 16 deletions lib/services/search_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,29 @@ import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';

import '../models/search_result.dart';
import 'service_policy.dart';

/// Cached search result with expiry.
class _CachedResult {
final List<SearchResult> results;
final DateTime cachedAt;

_CachedResult(this.results) : cachedAt = DateTime.now();

bool get isExpired =>
DateTime.now().difference(cachedAt) > const Duration(minutes: 5);
}

class SearchService {
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)';
static const int _maxResults = 5;
static const Duration _timeout = Duration(seconds: 10);


/// Client-side result cache, keyed by normalized query + viewbox.
/// Required by Nominatim usage policy.
final Map<String, _CachedResult> _resultCache = {};

/// Search for places using Nominatim geocoding service
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
if (query.trim().isEmpty) {
Expand All @@ -27,33 +43,47 @@ class SearchService {
// Otherwise, use Nominatim API
return await _searchNominatim(query.trim(), viewbox: viewbox);
}

/// Try to parse various coordinate formats
SearchResult? _tryParseCoordinates(String query) {
// Remove common separators and normalize
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
final parts = normalized.split(RegExp(r'\s+'));

if (parts.length != 2) return null;

final lat = double.tryParse(parts[0]);
final lon = double.tryParse(parts[1]);

if (lat == null || lon == null) return null;

// Basic validation for Earth coordinates
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;

return SearchResult(
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
coordinates: LatLng(lat, lon),
category: 'coordinates',
type: 'point',
);
}

/// Search using Nominatim API

/// Search using Nominatim API with rate limiting and result caching.
///
/// Nominatim usage policy requires:
/// - Max 1 request per second
/// - Client-side result caching
/// - No auto-complete / typeahead
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
final cacheKey = _buildCacheKey(query, viewbox);

// Check cache first (Nominatim policy requires client-side caching)
final cached = _resultCache[cacheKey];
if (cached != null && !cached.isExpired) {
debugPrint('[SearchService] Cache hit for "$query"');
return cached.results;
}

final params = {
'q': query,
'format': 'json',
Expand Down Expand Up @@ -84,32 +114,65 @@ class SearchService {
}

final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);

debugPrint('[SearchService] Searching Nominatim: $uri');

try {
// Rate limit: max 1 request/sec per Nominatim policy
await ServiceRateLimiter.acquire(ServiceType.nominatim);

final response = await http.get(
uri,
headers: {
'User-Agent': _userAgent,
},
).timeout(_timeout);


ServiceRateLimiter.release(ServiceType.nominatim);

if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
}
Comment on lines +131 to 135

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ServiceRateLimiter.release(ServiceType.nominatim) is called immediately after the HTTP request completes, before status-code handling and JSON parsing. If parsing/processing throws, the limiter slot has already been released and another request may start concurrently despite maxConcurrentRequests: 1. Consider holding the slot until all response processing is complete (e.g., move the release into a finally that wraps the whole request+parse section after a successful acquire).

Copilot uses AI. Check for mistakes.

final List<dynamic> jsonResults = json.decode(response.body);
final results = jsonResults
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
.toList();


// Cache the results
_resultCache[cacheKey] = _CachedResult(results);
_pruneCache();

debugPrint('[SearchService] Found ${results.length} results');
return results;

} catch (e) {
// Release the semaphore on error too
ServiceRateLimiter.release(ServiceType.nominatim);
debugPrint('[SearchService] Search failed: $e');
throw Exception('Search failed: $e');
Comment on lines 149 to 153

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block always calls ServiceRateLimiter.release(ServiceType.nominatim), but the try block also releases on the success path. If an exception occurs after the first release, this becomes a double-release and can break semaphore accounting when there are waiters. Use a single try/finally to ensure release is invoked exactly once after a successful acquire.

Copilot uses AI. Check for mistakes.
}
}
}

/// Build a cache key from the query and viewbox.
String _buildCacheKey(String query, LatLngBounds? viewbox) {
final normalizedQuery = query.trim().toLowerCase();
if (viewbox == null) return normalizedQuery;
// Round viewbox to 1 decimal place to group nearby viewboxes
double round1(double v) => (v * 10).round() / 10;
return '$normalizedQuery|${round1(viewbox.west)},${round1(viewbox.south)},${round1(viewbox.east)},${round1(viewbox.north)}';
}

/// Remove expired entries and limit cache size.
void _pruneCache() {
_resultCache.removeWhere((_, cached) => cached.isExpired);
// Limit cache to 50 entries to prevent unbounded growth
if (_resultCache.length > 50) {
final sortedKeys = _resultCache.keys.toList()
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
for (final key in sortedKeys.take(_resultCache.length - 50)) {
_resultCache.remove(key);
}
}
}
}
Loading
Loading