MotorSignal-App/lib/services/osm_oauth_service.dart

368 lines
12 KiB
Dart

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<String, dynamic> toJson() => {
'id': id,
'displayName': displayName,
'avatarUrl': avatarUrl,
'changesetCount': changesetCount,
};
factory OsmUserProfile.fromJson(Map<String, dynamic> 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<OsmAuthState> {
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<bool>? _inFlightExchange;
String? _lastExchangedCode;
OsmOAuthService() : super(const OsmAuthState(isLoading: true)) {
_loadStoredSession();
}
Future<void> _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<String, dynamic>);
} 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<bool> 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<bool> 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<bool> _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<String, dynamic>;
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<void> 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<String, dynamic>;
final userObj = data['user'] as Map<String, dynamic>?;
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<String, dynamic>?)?['href'] as String?,
changesetCount: (userObj['changesets'] as Map<String, dynamic>?)?['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<bool> uploadGpxTrack({
required String gpxXmlContent,
required String filename,
required String description,
List<String> 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<void> 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<OsmOAuthService, OsmAuthState>((ref) {
return OsmOAuthService();
});