368 lines
12 KiB
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();
|
|
});
|