diff --git a/README.md b/README.md index e5bdd15..8bdcb9c 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,32 @@ A minimal Flutter scaffold for mapping and tagging Flock‑style ALPR cameras in OpenStreetMap. -# NOTE: -Forks should register for their own oauth2 client id from OSM: https://www.openstreetmap.org/oauth2/applications -These are hardcoded in lib/services/auth_service.dart for each app. -If you discover a bug that causes bad behavior w/rt OSM API, you might want to register a new one for the patched version to distinguish them. You can also then delete the old version from OSM to prevent new people from using the old version. +# OAuth Setup + +Before you can upload to OpenStreetMap (production **or sandbox**), you must register your own OAuth2 application on each OSM API you wish to support: +- [Production OSM register page](https://www.openstreetmap.org/oauth2/applications) +- [Sandbox OSM register page](https://master.apis.dev.openstreetmap.org/oauth2/applications) + +Copy your generated client IDs into a new file: + +```dart +// lib/keys.dart +const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE'; +const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE'; +``` + +For open source: use `lib/keys.dart.example` as a template and do **not** commit your real secrets. + +If you discover a bug that causes bad behavior w/rt OSM API, register a new OAuth client to distinguish patched versions and, if needed, delete the old app to prevent misuse. + +# Upload Modes + +In Settings, you can now choose your "Upload Destination": +- **Production**: Live OSM database (visible to all users). +- **Sandbox**: OSM's dedicated test database; safe for development/testing. [More info](https://wiki.openstreetmap.org/wiki/Sandbox). +- **Simulate**: Does not contact any server. Actions are fully offline for testing UI/flows. + +--- ## TODO for Beta/RC Release diff --git a/lib/app_state.dart b/lib/app_state.dart index d3ae4f0..6e52227 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -10,6 +10,9 @@ import 'services/auth_service.dart'; import 'services/uploader.dart'; import 'services/profile_service.dart'; +// Enum for upload mode (Production, OSM Sandbox, Simulate) +enum UploadMode { production, sandbox, simulate } + // ------------------ AddCameraSession ------------------ class AddCameraSession { AddCameraSession({required this.profile, this.directionDegrees = 0}); @@ -18,6 +21,7 @@ class AddCameraSession { LatLng? target; } + // ------------------ AppState ------------------ class AppState extends ChangeNotifier { AppState() { @@ -30,22 +34,26 @@ class AppState extends ChangeNotifier { final List _profiles = []; final Set _enabled = {}; static const String _enabledPrefsKey = 'enabled_profiles'; - - // Test mode - prevents actual uploads to OSM - bool _testMode = false; - static const String _testModePrefsKey = 'test_mode'; - bool get testMode => _testMode; - Future setTestMode(bool enabled) async { - _testMode = enabled; + + // Upload mode: production, sandbox, or simulate (in-memory, no uploads) + UploadMode _uploadMode = UploadMode.production; + static const String _uploadModePrefsKey = 'upload_mode'; + UploadMode get uploadMode => _uploadMode; + Future setUploadMode(UploadMode mode) async { + _uploadMode = mode; + // Update AuthService to match new mode + _auth.setUploadMode(mode); final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_testModePrefsKey, enabled); - print('AppState: Test mode ${enabled ? 'enabled' : 'disabled'}'); + await prefs.setInt(_uploadModePrefsKey, mode.index); + print('AppState: Upload mode set to $mode'); notifyListeners(); } + // For legacy bool test mode + static const String _legacyTestModePrefsKey = 'test_mode'; + AddCameraSession? _session; AddCameraSession? get session => _session; - final List _queue = []; Timer? _uploadTimer; @@ -58,7 +66,7 @@ class AppState extends ChangeNotifier { _profiles.add(CameraProfile.alpr()); _profiles.addAll(await ProfileService().load()); - // Load enabled profile IDs and test mode from prefs + // Load enabled profile IDs and upload/test mode from prefs final prefs = await SharedPreferences.getInstance(); final enabledIds = prefs.getStringList(_enabledPrefsKey); if (enabledIds != null && enabledIds.isNotEmpty) { @@ -68,7 +76,21 @@ class AppState extends ChangeNotifier { // By default, all are enabled _enabled.addAll(_profiles); } - _testMode = prefs.getBool(_testModePrefsKey) ?? false; + // Upload mode loading (including migration from old test_mode bool) + if (prefs.containsKey(_uploadModePrefsKey)) { + final idx = prefs.getInt(_uploadModePrefsKey) ?? 0; + if (idx >= 0 && idx < UploadMode.values.length) { + _uploadMode = UploadMode.values[idx]; + } + } else if (prefs.containsKey(_legacyTestModePrefsKey)) { + // migrate legacy test_mode (true->simulate, false->prod) + final legacy = prefs.getBool(_legacyTestModePrefsKey) ?? false; + _uploadMode = legacy ? UploadMode.simulate : UploadMode.production; + await prefs.remove(_legacyTestModePrefsKey); + await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); + } + // Ensure AuthService follows loaded mode + _auth.setUploadMode(_uploadMode); await _loadQueue(); @@ -287,30 +309,30 @@ class AppState extends ChangeNotifier { _uploadTimer = Timer.periodic(const Duration(seconds: 10), (t) async { if (_queue.isEmpty) return; + // Retrieve access after every tick (accounts for re-login) final access = await _auth.getAccessToken(); if (access == null) return; // not logged in final item = _queue.first; - bool ok; - if (_testMode) { - // Test mode - simulate successful upload without actually calling OSM API - print('AppState: Test mode - simulating upload for ${item.coord}'); + if (_uploadMode == UploadMode.simulate) { + // Simulate successful upload without calling real API + print('AppState: UploadMode.simulate - simulating upload for ${item.coord}'); await Future.delayed(const Duration(seconds: 1)); // Simulate network delay ok = true; - print('AppState: Test mode - simulated upload successful'); + print('AppState: Simulated upload successful'); } else { - // Real upload + // Real upload -- pass uploadMode so uploader can switch between prod and sandbox final up = Uploader(access, () { _queue.remove(item); _saveQueue(); notifyListeners(); - }); + }, uploadMode: _uploadMode); ok = await up.upload(item); } - - if (ok && _testMode) { - // In test mode, manually remove from queue since Uploader callback won't be called + + if (ok && _uploadMode == UploadMode.simulate) { + // Remove manually for simulate mode _queue.remove(item); _saveQueue(); notifyListeners(); diff --git a/lib/keys.dart b/lib/keys.dart new file mode 100644 index 0000000..95154d7 --- /dev/null +++ b/lib/keys.dart @@ -0,0 +1,6 @@ +// OpenStreetMap OAuth client IDs for this app. +// +// NEVER commit real secrets to public repos. For open source, use keys.dart.example instead. + +const String kOsmProdClientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; // example - replace with real +const String kOsmSandboxClientId = 'x26twxRKTZwf1a4Ha1a-wkXncBzqnJv8JwtacJope9Q'; // example - replace with real diff --git a/lib/keys.dart.example b/lib/keys.dart.example new file mode 100644 index 0000000..068bf19 --- /dev/null +++ b/lib/keys.dart.example @@ -0,0 +1,3 @@ +// Example OSM OAuth key config +const String kOsmProdClientId = 'YOUR_PROD_CLIENT_ID_HERE'; +const String kOsmSandboxClientId = 'YOUR_SANDBOX_CLIENT_ID_HERE'; diff --git a/lib/models/camera_profile.dart b/lib/models/camera_profile.dart index f71c479..0804856 100644 --- a/lib/models/camera_profile.dart +++ b/lib/models/camera_profile.dart @@ -20,7 +20,9 @@ class CameraProfile { name: 'Generic Flock', tags: const { 'man_made': 'surveillance', - 'surveillance:type': 'ALPR', + 'surveillance': 'public', + 'surveillance:zone': 'traffic', + 'surveillance:type': 'ALPR', // left for backward compatibility — you may want to revisit per OSM best practice 'camera:type': 'fixed', 'manufacturer': 'Flock Safety', 'manufacturer:wikidata': 'Q108485435', diff --git a/lib/screens/profile_editor.dart b/lib/screens/profile_editor.dart index f0012e9..037f992 100644 --- a/lib/screens/profile_editor.dart +++ b/lib/screens/profile_editor.dart @@ -20,9 +20,13 @@ class _ProfileEditorState extends State { static const _defaultTags = [ MapEntry('man_made', 'surveillance'), + MapEntry('surveillance', 'public'), + MapEntry('surveillance:zone', 'traffic'), MapEntry('surveillance:type', 'ALPR'), MapEntry('camera:type', 'fixed'), + MapEntry('camera:mount', ''), MapEntry('manufacturer', ''), + MapEntry('manufacturer:wikidata', ''), ]; @override diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 80147e7..f62159f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -143,22 +143,72 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(), - // Test mode toggle - SwitchListTile( - secondary: const Icon(Icons.bug_report), - title: const Text('Test Mode'), - subtitle: const Text('Simulate uploads without sending to OSM'), - value: appState.testMode, - onChanged: (value) => appState.setTestMode(value), + // Upload mode selector - Production/Sandbox/Simulate + ListTile( + leading: const Icon(Icons.cloud_upload), + title: const Text('Upload Destination'), + subtitle: const Text('Choose where cameras are uploaded'), + trailing: DropdownButton( + value: appState.uploadMode, + items: const [ + DropdownMenuItem( + value: UploadMode.production, + child: Text('Production'), + ), + DropdownMenuItem( + value: UploadMode.sandbox, + child: Text('Sandbox'), + ), + DropdownMenuItem( + value: UploadMode.simulate, + child: Text('Simulate'), + ), + ], + onChanged: (mode) { + if (mode != null) appState.setUploadMode(mode); + }, + ), + ), + // Help text + Padding( + padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12), + child: Builder( + builder: (context) { + switch (appState.uploadMode) { + case UploadMode.production: + return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87)); + case UploadMode.sandbox: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Uploads go to the OSM Sandbox (safe for testing, resets regularly).', + style: TextStyle(fontSize: 12, color: Colors.orange), + ), + SizedBox(height: 2), + Text( + 'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.', + style: TextStyle(fontSize: 11, color: Colors.redAccent), + ), + ], + ); + case UploadMode.simulate: + default: + return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple)); + } + }, + ), ), const Divider(), // Queue management ListTile( leading: const Icon(Icons.queue), title: Text('Pending uploads: ${appState.pendingCount}'), - subtitle: appState.testMode - ? const Text('Test mode enabled - uploads simulated') - : const Text('Tap to view queue'), + subtitle: appState.uploadMode == UploadMode.simulate + ? const Text('Simulate mode enabled – uploads simulated') + : appState.uploadMode == UploadMode.sandbox + ? const Text('Sandbox mode – uploads go to OSM Sandbox') + : const Text('Tap to view queue'), onTap: appState.pendingCount > 0 ? () { _showQueueDialog(context, appState); } : null, diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index b6d80d4..e681c60 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -7,31 +7,42 @@ import 'package:oauth2_client/oauth2_helper.dart'; import 'package:http/http.dart' as http; /// Handles PKCE OAuth login with OpenStreetMap. +import '../app_state.dart'; + +import '../keys.dart'; + class AuthService { - static const String _clientId = 'Js6Fn3NR3HEGaD0ZIiHBQlV9LrVcHmsOsDmApHtSyuY'; + // Both client IDs from keys.dart static const _redirect = 'flockmap://auth'; - late final OAuth2Helper _helper; + late OAuth2Helper _helper; String? _displayName; + UploadMode _mode = UploadMode.production; - AuthService() { + AuthService({UploadMode mode = UploadMode.production}) { + setUploadMode(mode); + } + + void setUploadMode(UploadMode mode) { + _mode = mode; + final isSandbox = (mode == UploadMode.sandbox); + final authBase = isSandbox + ? 'https://master.apis.dev.openstreetmap.org' // sandbox auth + : 'https://www.openstreetmap.org'; + final clientId = isSandbox ? kOsmSandboxClientId : kOsmProdClientId; final client = OAuth2Client( - authorizeUrl: 'https://www.openstreetmap.org/oauth2/authorize', - tokenUrl: 'https://www.openstreetmap.org/oauth2/token', + authorizeUrl: '$authBase/oauth2/authorize', + tokenUrl: '$authBase/oauth2/token', redirectUri: _redirect, customUriScheme: 'flockmap', ); - _helper = OAuth2Helper( client, - clientId: _clientId, + clientId: clientId, scopes: ['read_prefs', 'write_api'], enablePKCE: true, ); - - print('AuthService: Initialized with scopes: [read_prefs, write_api]'); - print('AuthService: Client ID: $_clientId'); - print('AuthService: Redirect URI: $_redirect'); + print('AuthService: Initialized for $mode with $authBase and clientId $clientId'); } Future isLoggedIn() async => @@ -81,13 +92,19 @@ class AuthService { /* ───────── helper ───────── */ + String get _apiHost { + return _mode == UploadMode.sandbox + ? 'https://api06.dev.openstreetmap.org' + : 'https://api.openstreetmap.org'; + } + Future _fetchUsername(String accessToken) async { try { - print('AuthService: Fetching username from OSM API...'); + print('AuthService: Fetching username from OSM API ($_apiHost) ...'); print('AuthService: Access token (first 20 chars): ${accessToken.substring(0, math.min(20, accessToken.length))}...'); final resp = await http.get( - Uri.parse('https://api.openstreetmap.org/api/0.6/user/details.json'), + Uri.parse('$_apiHost/api/0.6/user/details.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); print('AuthService: OSM API response status: ${resp.statusCode}'); @@ -101,7 +118,7 @@ class AuthService { try { print('AuthService: Checking token permissions...'); final permResp = await http.get( - Uri.parse('https://api.openstreetmap.org/api/0.6/permissions.json'), + Uri.parse('$_apiHost/api/0.6/permissions.json'), headers: {'Authorization': 'Bearer $accessToken'}, ); print('AuthService: Permissions response ${permResp.statusCode}: ${permResp.body}'); diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 4028ab2..10db57e 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -6,12 +6,17 @@ import 'package:latlong2/latlong.dart'; import '../models/camera_profile.dart'; import '../models/osm_camera_node.dart'; -class OverpassService { - static const _endpoint = 'https://overpass-api.de/api/interpreter'; +import '../app_state.dart'; +class OverpassService { + static const _prodEndpoint = 'https://overpass-api.de/api/interpreter'; + static const _sandboxEndpoint = 'https://overpass-api.dev.openstreetmap.org/api/interpreter'; + + // You can pass UploadMode, or use production by default Future> fetchCameras( LatLngBounds bbox, List profiles, + {UploadMode uploadMode = UploadMode.production} ) async { if (profiles.isEmpty) return []; @@ -31,25 +36,35 @@ class OverpassService { out body 250; '''; - try { - final resp = - await http.post(Uri.parse(_endpoint), body: {'data': query.trim()}); - if (resp.statusCode != 200) return []; - - final data = jsonDecode(resp.body) as Map; - final elements = data['elements'] as List; - - return elements.whereType>().map((e) { - return OsmCameraNode( - id: e['id'], - coord: LatLng(e['lat'], e['lon']), - tags: Map.from(e['tags'] ?? {}), - ); - }).toList(); - } catch (_) { - // Network error – return empty list silently - return []; + Future> fetchFromUri(String endpoint, String query) async { + try { + print('[Overpass] Querying $endpoint'); + print('[Overpass] Query:\n$query'); + final resp = await http.post(Uri.parse(endpoint), body: {'data': query.trim()}); + print('[Overpass] Status: \\${resp.statusCode}, Length: \\${resp.body.length}'); + if (resp.statusCode != 200) { + print('[Overpass] Failed: \\${resp.body}'); + return []; + } + final data = jsonDecode(resp.body) as Map; + final elements = data['elements'] as List; + print('[Overpass] Retrieved elements: \\${elements.length}'); + return elements.whereType>().map((e) { + return OsmCameraNode( + id: e['id'], + coord: LatLng(e['lat'], e['lon']), + tags: Map.from(e['tags'] ?? {}), + ); + }).toList(); + } catch (e) { + print('[Overpass] Exception: \\${e}'); + // Network error – return empty list silently + return []; + } } + + // Fetch from production Overpass for all modes. + return await fetchFromUri(_prodEndpoint, query); } } diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index 9bc88a9..e48243f 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -3,11 +3,14 @@ import 'package:http/http.dart' as http; import '../models/pending_upload.dart'; +import '../app_state.dart'; + class Uploader { - Uploader(this.accessToken, this.onSuccess); + Uploader(this.accessToken, this.onSuccess, {this.uploadMode = UploadMode.production}); final String accessToken; final void Function() onSuccess; + final UploadMode uploadMode; Future upload(PendingUpload p) async { try { @@ -68,14 +71,24 @@ class Uploader { } } + String get _host { + switch (uploadMode) { + case UploadMode.sandbox: + return 'api06.dev.openstreetmap.org'; + case UploadMode.production: + default: + return 'api.openstreetmap.org'; + } + } + Future _post(String path, String body) => http.post( - Uri.https('api.openstreetmap.org', path), + Uri.https(_host, path), headers: _headers, body: body, ); Future _put(String path, String body) => http.put( - Uri.https('api.openstreetmap.org', path), + Uri.https(_host, path), headers: _headers, body: body, ); diff --git a/lib/widgets/add_camera_sheet.dart b/lib/widgets/add_camera_sheet.dart index 3d910bb..2a7f4d9 100644 --- a/lib/widgets/add_camera_sheet.dart +++ b/lib/widgets/add_camera_sheet.dart @@ -26,6 +26,9 @@ class AddCameraSheet extends StatelessWidget { Navigator.pop(context); } + final customProfiles = appState.enabledProfiles.where((p) => !p.builtin).toList(); + final allowSubmit = customProfiles.isNotEmpty && !session.profile.builtin; + return Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), @@ -64,6 +67,38 @@ class AddCameraSheet extends StatelessWidget { onChanged: (v) => appState.updateSession(directionDeg: v), ), ), + if (customProfiles.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: const [ + Icon(Icons.info_outline, color: Colors.red, size: 20), + SizedBox(width: 6), + Expanded( + child: Text( + 'Enable or create a custom profile in Settings to submit new cameras.', + style: TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ], + ), + ) + else if (session.profile.builtin) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: const [ + Icon(Icons.info_outline, color: Colors.orange, size: 20), + SizedBox(width: 6), + Expanded( + child: Text( + 'The built-in profile is for map viewing only. Please select a custom profile to submit new cameras.', + style: TextStyle(color: Colors.orange, fontSize: 13), + ), + ), + ], + ), + ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), @@ -78,7 +113,7 @@ class AddCameraSheet extends StatelessWidget { const SizedBox(width: 12), Expanded( child: ElevatedButton( - onPressed: _commit, + onPressed: allowSubmit ? _commit : null, child: const Text('Submit'), ), ), diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 8f4e4af..44c6f6f 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -82,7 +82,11 @@ class _MapViewState extends State { } catch (_) { return; // controller not ready yet } - final cams = await _overpass.fetchCameras(bounds, appState.enabledProfiles); + final cams = await _overpass.fetchCameras( + bounds, + appState.enabledProfiles, + uploadMode: appState.uploadMode, + ); if (mounted) setState(() => _cameras = cams); } @@ -99,6 +103,12 @@ class _MapViewState extends State { final appState = context.watch(); final session = appState.session; + // Always watch for changes on uploadMode/profiles and refresh if needed + // (debounced, to avoid flooding when quickly toggling) + WidgetsBinding.instance.addPostFrameCallback((_) { + _debounce(() => _refreshCameras(appState)); + }); + // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { try { @@ -181,6 +191,36 @@ class _MapViewState extends State { ], ), + // MODE INDICATOR badge (top-right) + if (appState.uploadMode == UploadMode.sandbox || appState.uploadMode == UploadMode.simulate) + Positioned( + top: 18, + right: 14, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: appState.uploadMode == UploadMode.sandbox + ? Colors.orange.withOpacity(0.90) + : Colors.deepPurple.withOpacity(0.80), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow(color: Colors.black26, blurRadius: 5, offset: Offset(0,2)), + ], + ), + child: Text( + appState.uploadMode == UploadMode.sandbox + ? 'SANDBOX MODE' + : 'SIMULATE', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + letterSpacing: 1.1, + ), + ), + ), + ), + // Attribution overlay Positioned( bottom: 20,