diff --git a/lib/keys.dart b/lib/keys.dart index ed9a52d..7c19381 100644 --- a/lib/keys.dart +++ b/lib/keys.dart @@ -1,16 +1,20 @@ // OpenStreetMap OAuth client IDs for this app. // These must be provided via --dart-define at build time. +/// Whether OSM OAuth secrets were provided at build time. +/// When false, the app should force simulate mode. +bool get kHasOsmSecrets { + const prod = String.fromEnvironment('OSM_PROD_CLIENTID'); + const sandbox = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); + return prod.isNotEmpty && sandbox.isNotEmpty; +} + String get kOsmProdClientId { const fromBuild = String.fromEnvironment('OSM_PROD_CLIENTID'); - if (fromBuild.isNotEmpty) return fromBuild; - - throw Exception('OSM_PROD_CLIENTID not configured. Use --dart-define=OSM_PROD_CLIENTID=your_id'); + return fromBuild; } String get kOsmSandboxClientId { const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); - if (fromBuild.isNotEmpty) return fromBuild; - - throw Exception('OSM_SANDBOX_CLIENTID not configured. Use --dart-define=OSM_SANDBOX_CLIENTID=your_id'); + return fromBuild; } \ No newline at end of file diff --git a/lib/screens/settings/sections/upload_mode_section.dart b/lib/screens/settings/sections/upload_mode_section.dart index 61da379..568196e 100644 --- a/lib/screens/settings/sections/upload_mode_section.dart +++ b/lib/screens/settings/sections/upload_mode_section.dart @@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget { subtitle: Text(locService.t('uploadMode.subtitle')), trailing: DropdownButton( value: appState.uploadMode, + // This entire section is gated behind kEnableDevelopmentModes + // in osm_account_screen.dart, so all modes are always available here. items: [ DropdownMenuItem( value: UploadMode.production, diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 481be1c..e92f5d4 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -36,6 +36,7 @@ class AuthService { void setUploadMode(UploadMode mode) { _mode = mode; + if (mode == UploadMode.simulate || !kHasOsmSecrets) return; final isSandbox = (mode == UploadMode.sandbox); final authBase = isSandbox ? 'https://master.apis.dev.openstreetmap.org' @@ -150,7 +151,9 @@ class AuthService { // Force a fresh login by clearing stored tokens Future forceLogin() async { - await _helper.removeAllTokens(); + if (_mode != UploadMode.simulate) { + await _helper.removeAllTokens(); + } _displayName = null; return await login(); } diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index c2529b5..7fbc6d0 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import '../models/tile_provider.dart'; import '../dev_config.dart'; +import '../keys.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -41,7 +42,8 @@ class SettingsState extends ChangeNotifier { bool _offlineMode = false; bool _pauseQueueProcessing = false; int _maxNodes = kDefaultMaxNodes; - UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production; + // Default must account for missing secrets (preview builds) even before init() runs + UploadMode _uploadMode = (kEnableDevelopmentModes || !kHasOsmSecrets) ? UploadMode.simulate : UploadMode.production; FollowMeMode _followMeMode = FollowMeMode.follow; bool _proximityAlertsEnabled = false; int _proximityAlertDistance = kProximityAlertDefaultDistance; @@ -150,8 +152,16 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } - // In production builds, force production mode if development modes are disabled - if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) { + // Override persisted upload mode when the current build configuration + // doesn't support it. This handles two cases: + // 1. Preview/PR builds without OAuth secrets — force simulate to avoid crashes + // 2. Production builds — force production (prefs may have sandbox/simulate + // from a previous dev build on the same device) + if (!kHasOsmSecrets && _uploadMode != UploadMode.simulate) { + debugPrint('SettingsState: No OSM secrets available, forcing simulate mode'); + _uploadMode = UploadMode.simulate; + await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); + } else if (kHasOsmSecrets && !kEnableDevelopmentModes && _uploadMode != UploadMode.production) { debugPrint('SettingsState: Development modes disabled, forcing production mode'); _uploadMode = UploadMode.production; await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); @@ -258,11 +268,10 @@ class SettingsState extends ChangeNotifier { } Future setUploadMode(UploadMode mode) async { - // In production builds, only allow production mode - if (!kEnableDevelopmentModes && mode != UploadMode.production) { - debugPrint('SettingsState: Development modes disabled, forcing production mode'); - mode = UploadMode.production; - } + // The upload mode dropdown is only visible when kEnableDevelopmentModes is + // true (gated in osm_account_screen.dart), so no secrets/dev-mode guards + // are needed here. The init() method handles forcing the correct mode on + // startup for production builds and builds without OAuth secrets. _uploadMode = mode; final prefs = await SharedPreferences.getInstance(); diff --git a/test/state/settings_state_test.dart b/test/state/settings_state_test.dart new file mode 100644 index 0000000..8e56a24 --- /dev/null +++ b/test/state/settings_state_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:deflockapp/state/settings_state.dart'; +import 'package:deflockapp/keys.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('kHasOsmSecrets (no --dart-define)', () { + test('is false when built without secrets', () { + expect(kHasOsmSecrets, isFalse); + }); + + test('client ID getters return empty strings instead of throwing', () { + expect(kOsmProdClientId, isEmpty); + expect(kOsmSandboxClientId, isEmpty); + }); + }); + + group('SettingsState without secrets', () { + test('defaults to simulate mode', () { + final state = SettingsState(); + expect(state.uploadMode, UploadMode.simulate); + }); + + test('init() forces simulate even if prefs has production stored', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.production.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + + // Verify it persisted the override + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('upload_mode'), UploadMode.simulate.index); + }); + + test('init() forces simulate even if prefs has sandbox stored', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.sandbox.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + }); + + test('init() keeps simulate if already simulate', () async { + SharedPreferences.setMockInitialValues({ + 'upload_mode': UploadMode.simulate.index, + }); + + final state = SettingsState(); + await state.init(); + + expect(state.uploadMode, UploadMode.simulate); + }); + + test('setUploadMode() allows simulate', () async { + final state = SettingsState(); + await state.setUploadMode(UploadMode.simulate); + + expect(state.uploadMode, UploadMode.simulate); + }); + }); +}