import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:url_launcher/url_launcher.dart'; class OsmUserProfile { final int id; final String displayName; final String? avatarUrl; final int changesetCount; const OsmUserProfile({ required this.id, required this.displayName, this.avatarUrl, this.changesetCount = 0, }); Map toJson() => { 'id': id, 'displayName': displayName, 'avatarUrl': avatarUrl, 'changesetCount': changesetCount, }; factory OsmUserProfile.fromJson(Map json) => OsmUserProfile( id: json['id'] as int? ?? 0, displayName: json['displayName'] as String? ?? 'OSM User', avatarUrl: json['avatarUrl'] as String?, changesetCount: json['changesetCount'] as int? ?? 0, ); } class OsmAuthState { final bool isAuthenticated; final bool isLoading; final String? accessToken; final OsmUserProfile? user; final String? errorMessage; const OsmAuthState({ this.isAuthenticated = false, this.isLoading = false, this.accessToken, this.user, this.errorMessage, }); OsmAuthState copyWith({ bool? isAuthenticated, bool? isLoading, String? accessToken, OsmUserProfile? user, String? errorMessage, }) { return OsmAuthState( isAuthenticated: isAuthenticated ?? this.isAuthenticated, isLoading: isLoading ?? this.isLoading, accessToken: accessToken ?? this.accessToken, user: user ?? this.user, errorMessage: errorMessage, ); } } class OsmOAuthService extends StateNotifier { static const String clientId = 'nkd1ogsHpcMJD1Hin9UT4bCjK8T33BjUrEwF7WAYNio'; static const String redirectUri = 'https://earnable.store/oauth/callback'; static const String appSchemeRedirect = 'motorsignal://oauth/callback'; static const String authEndpoint = 'https://www.openstreetmap.org/oauth2/authorize'; static const String tokenEndpoint = 'https://www.openstreetmap.org/oauth2/token'; static const String userDetailsEndpoint = 'https://api.openstreetmap.org/api/0.6/user/details.json'; static const String gpxUploadEndpoint = 'https://api.openstreetmap.org/api/0.6/gpx/create'; static const String _prefTokenKey = 'osm_access_token'; static const String _prefUserKey = 'osm_user_profile'; static const String _prefVerifierKey = 'osm_pkce_code_verifier'; static const String _secureTokenKey = 'osm_access_token'; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); String? _currentCodeVerifier; String? _inFlightCode; Future? _inFlightExchange; String? _lastExchangedCode; OsmOAuthService() : super(const OsmAuthState(isLoading: true)) { _loadStoredSession(); } Future _loadStoredSession() async { try { final prefs = await SharedPreferences.getInstance(); var token = await _secureStorage.read(key: _secureTokenKey); if (token == null || token.isEmpty) { token = prefs.getString(_prefTokenKey); if (token != null && token.isNotEmpty) { await _secureStorage.write(key: _secureTokenKey, value: token); await prefs.remove(_prefTokenKey); } } final userJsonStr = prefs.getString(_prefUserKey); if (token != null && token.isNotEmpty) { OsmUserProfile? user; if (userJsonStr != null) { try { user = OsmUserProfile.fromJson(jsonDecode(userJsonStr) as Map); } catch (_) {} } state = OsmAuthState( isAuthenticated: true, isLoading: false, accessToken: token, user: user, ); // Refresh profile in background fetchUserProfile(); return; } } catch (_) {} state = const OsmAuthState(isAuthenticated: false, isLoading: false); } String _generateRandomString(int length) { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; final rand = Random.secure(); return List.generate(length, (_) => chars[rand.nextInt(chars.length)]).join(); } String _generateCodeChallenge(String verifier) { final bytes = utf8.encode(verifier); final digest = sha256.convert(bytes); return base64Url.encode(digest.bytes).replaceAll('=', ''); } /// Launch OpenStreetMap in external browser for login & approval Future startLoginFlow() async { state = state.copyWith(isLoading: true, errorMessage: null); try { final codeVerifier = _generateRandomString(64); final codeChallenge = _generateCodeChallenge(codeVerifier); final stateParam = _generateRandomString(16); _currentCodeVerifier = codeVerifier; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefVerifierKey, codeVerifier); final queryParams = { 'response_type': 'code', 'client_id': clientId, 'redirect_uri': redirectUri, 'scope': 'read_prefs openid write_gpx read_gpx', 'code_challenge': codeChallenge, 'code_challenge_method': 'S256', 'state': stateParam, }; final authUri = Uri.parse(authEndpoint).replace(queryParameters: queryParams); final launched = await launchUrl( authUri, mode: LaunchMode.externalApplication, ); state = state.copyWith(isLoading: false); return launched; } catch (e) { state = state.copyWith(isLoading: false, errorMessage: 'Failed to launch login: $e'); return false; } } /// Complete OAuth2 token exchange with auth code from redirect callback Future handleAuthCallback(Uri uri) async { final error = uri.queryParameters['error']; if (error != null && error.isNotEmpty) { final desc = uri.queryParameters['error_description'] ?? error; state = state.copyWith(isLoading: false, errorMessage: 'OpenStreetMap authorization error: $desc'); return false; } final code = uri.queryParameters['code']; if (code == null || code.isEmpty) { state = state.copyWith(errorMessage: 'No authorization code received.'); return false; } if (state.isAuthenticated && code == _lastExchangedCode) { return true; } if (_inFlightCode == code && _inFlightExchange != null) { return await _inFlightExchange!; } _inFlightCode = code; final exchangeFuture = _executeTokenExchange(code); _inFlightExchange = exchangeFuture; try { final result = await exchangeFuture; if (result) { _lastExchangedCode = code; } return result; } finally { if (_inFlightCode == code) { _inFlightCode = null; _inFlightExchange = null; } } } Future _executeTokenExchange(String code) async { state = state.copyWith(isLoading: true, errorMessage: null); try { final prefs = await SharedPreferences.getInstance(); var codeVerifier = _currentCodeVerifier; if (codeVerifier == null || codeVerifier.isEmpty) { codeVerifier = prefs.getString(_prefVerifierKey) ?? ''; } if (codeVerifier.isEmpty) { state = state.copyWith( isLoading: false, errorMessage: 'PKCE code verifier expired or missing. Please try logging in again.', ); return false; } debugPrint('[OsmOAuth] Exchanging authorization code'); final response = await http.post( Uri.parse(tokenEndpoint), headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: { 'grant_type': 'authorization_code', 'client_id': clientId, 'code': code, 'redirect_uri': redirectUri, 'code_verifier': codeVerifier, }, ); debugPrint('[OsmOAuth] Token exchange response: ${response.statusCode}'); if (response.statusCode == 200) { final json = jsonDecode(response.body) as Map; final accessToken = json['access_token'] as String; await _secureStorage.write(key: _secureTokenKey, value: accessToken); await prefs.remove(_prefTokenKey); await prefs.remove(_prefVerifierKey); _currentCodeVerifier = null; state = OsmAuthState( isAuthenticated: true, isLoading: false, accessToken: accessToken, ); await fetchUserProfile(); return true; } else { state = state.copyWith( isLoading: false, errorMessage: 'Token exchange failed (${response.statusCode}).', ); return false; } } catch (e) { debugPrint('[OsmOAuth] Exception during auth callback: $e'); state = state.copyWith(isLoading: false, errorMessage: 'Authentication error: $e'); return false; } } /// Fetch OpenStreetMap user profile details Future fetchUserProfile() async { final token = state.accessToken; if (token == null || token.isEmpty) return; try { final response = await http.get( Uri.parse(userDetailsEndpoint), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/json', }, ); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; final userObj = data['user'] as Map?; if (userObj != null) { final profile = OsmUserProfile( id: userObj['id'] as int? ?? 0, displayName: userObj['display_name'] as String? ?? 'OSM User', avatarUrl: (userObj['img'] as Map?)?['href'] as String?, changesetCount: (userObj['changesets'] as Map?)?['count'] as int? ?? 0, ); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefUserKey, jsonEncode(profile.toJson())); state = state.copyWith(user: profile); } } } catch (_) {} } /// Upload recorded Ride GPX track directly to OpenStreetMap Future uploadGpxTrack({ required String gpxXmlContent, required String filename, required String description, List tags = const ['motorcycle', 'motorsignal'], String visibility = 'identifiable', // 'private', 'public', 'trackable', 'identifiable' }) async { final token = state.accessToken; if (token == null || token.isEmpty) return false; try { final request = http.MultipartRequest('POST', Uri.parse(gpxUploadEndpoint)) ..headers['Authorization'] = 'Bearer $token' ..fields['description'] = description ..fields['tags'] = tags.join(',') ..fields['visibility'] = visibility ..files.add( http.MultipartFile.fromString( 'file', gpxXmlContent, filename: filename, ), ); final streamedResponse = await request.send(); return streamedResponse.statusCode == 200 || streamedResponse.statusCode == 201; } catch (_) { return false; } } /// Disconnect / Sign out from OpenStreetMap Future logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefTokenKey); await _secureStorage.delete(key: _secureTokenKey); await prefs.remove(_prefUserKey); await prefs.remove(_prefVerifierKey); state = const OsmAuthState(isAuthenticated: false, isLoading: false); } } final osmOAuthServiceProvider = StateNotifierProvider((ref) { return OsmOAuthService(); });