Force simulate mode when OSM OAuth secrets are missing

Preview/PR builds don't have access to GitHub Secrets, so the OAuth
client IDs are empty. Previously this caused a runtime crash from
keys.dart throwing on empty values. Now we detect missing secrets
and force simulate mode, which already fully supports fake auth
and uploads.

Also fixes a latent bug where forceLogin() would crash with
LateInitializationError in simulate mode since _helper is never
initialized when OAuth setup is skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Doug Borg
2026-03-07 11:02:41 -07:00
parent aeb1903bbc
commit 5728b4f70f
5 changed files with 106 additions and 15 deletions

View File

@@ -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;
}

View File

@@ -23,6 +23,8 @@ class UploadModeSection extends StatelessWidget {
subtitle: Text(locService.t('uploadMode.subtitle')),
trailing: DropdownButton<UploadMode>(
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,

View File

@@ -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<String?> forceLogin() async {
await _helper.removeAllTokens();
if (_mode != UploadMode.simulate) {
await _helper.removeAllTokens();
}
_displayName = null;
return await login();
}

View File

@@ -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<void> 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();

View File

@@ -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);
});
});
}