341 lines
14 KiB
Dart
341 lines
14 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:drift/drift.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:intl/intl.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import '../../core/version/app_identity.dart';
|
||
import '../../database/database.dart';
|
||
import '../../features/records/records_service.dart';
|
||
import '../auth/auth_providers.dart';
|
||
import '../auth/auth_service.dart';
|
||
import '../export_service.dart';
|
||
import '../field_test/field_test_export_service.dart';
|
||
import '../sync/firebase_sync_provider.dart';
|
||
import '../sync/sync_models.dart';
|
||
import 'github_auth_service.dart';
|
||
|
||
enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired }
|
||
|
||
class GitHubUploadResult {
|
||
final bool success;
|
||
final String message;
|
||
final String? commitSha;
|
||
final String? targetPath;
|
||
final DateTime timestamp;
|
||
|
||
GitHubUploadResult({
|
||
required this.success,
|
||
required this.message,
|
||
this.commitSha,
|
||
this.targetPath,
|
||
DateTime? timestamp,
|
||
}) : timestamp = timestamp ?? DateTime.now();
|
||
}
|
||
|
||
/// Compatibility facade for the historical ride-detail sync service.
|
||
///
|
||
/// Normal MotorSignal cloud backup is Firebase Authentication + Firestore + R2.
|
||
/// The historical GitHub PAT exporter remains available only through the
|
||
/// explicit `uploadRideToGitHub` developer/export path and is never used by
|
||
/// the normal History -> Upload Now action.
|
||
class GitHubSyncService extends ChangeNotifier {
|
||
final AppDatabase _db;
|
||
final GitHubAuthService _githubAuth;
|
||
final AuthService _authService;
|
||
final SharedPreferences _prefs;
|
||
final http.Client _client;
|
||
|
||
GitHubUploadState _state = GitHubUploadState.idle;
|
||
String _statusMessage = '';
|
||
final Set<int> _uploadQueue = {};
|
||
|
||
/// Keeps the historical constructor compatible with tests/callers that pass
|
||
/// an HTTP client as the optional fourth argument. Production additionally
|
||
/// supplies the shared Firebase AuthService as the fifth argument.
|
||
GitHubSyncService(
|
||
this._db,
|
||
this._githubAuth,
|
||
this._prefs, [
|
||
http.Client? client,
|
||
AuthService? authService,
|
||
]) : _authService = authService ?? FirebaseAuthService(),
|
||
_client = client ?? http.Client();
|
||
|
||
GitHubUploadState get state => _state;
|
||
String get statusMessage => _statusMessage;
|
||
Set<int> get queuedRideIds => Set.unmodifiable(_uploadQueue);
|
||
|
||
String? _userScopedKey(String key, int rideId) {
|
||
final uid = _authService.currentUser?.uid;
|
||
if (uid == null || uid.isEmpty) return null;
|
||
return '${key}_${uid}_$rideId';
|
||
}
|
||
|
||
bool isRideUploaded(int rideId) {
|
||
final key = _userScopedKey('motorcloud_uploaded_ride', rideId);
|
||
if (key != null) return _prefs.getBool(key) ?? false;
|
||
// Legacy unscoped key – used when no authenticated user is available.
|
||
return _prefs.getBool('motorcloud_uploaded_ride_$rideId') ?? false;
|
||
}
|
||
|
||
String? getRideCommitSha(int rideId) {
|
||
final key = _userScopedKey('motorcloud_ride_remote_id', rideId);
|
||
if (key != null) return _prefs.getString(key);
|
||
return _prefs.getString('motorcloud_ride_remote_id_$rideId');
|
||
}
|
||
|
||
String? getRideUploadTimestamp(int rideId) {
|
||
final key = _userScopedKey('motorcloud_uploaded_at_ride', rideId);
|
||
return key == null ? null : _prefs.getString(key);
|
||
}
|
||
|
||
void queueRideForUpload(int rideId) {
|
||
if (isRideUploaded(rideId)) return;
|
||
_uploadQueue.add(rideId);
|
||
_state = GitHubUploadState.queued;
|
||
_statusMessage = 'Ride #$rideId queued for cloud synchronization';
|
||
notifyListeners();
|
||
processQueue();
|
||
}
|
||
|
||
Future<void> processQueue() async {
|
||
if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return;
|
||
final toProcess = List<int>.from(_uploadQueue);
|
||
for (final rideId in toProcess) {
|
||
await uploadRide(rideId);
|
||
}
|
||
}
|
||
|
||
/// Normal History -> Upload Now operation.
|
||
///
|
||
/// Deliberately ignores any configured GitHub PAT. A user's ride must be
|
||
/// written to that user's MotorCloud account, never to the developer's
|
||
/// public repository.
|
||
Future<GitHubUploadResult> uploadRide(int rideId, {bool isManual = false}) async {
|
||
return _uploadToMotorCloud(rideId);
|
||
}
|
||
|
||
/// Explicit legacy/developer export. Not used by the normal ride UI.
|
||
Future<GitHubUploadResult> uploadRideToGitHub(int rideId) async {
|
||
final token = _githubAuth.getToken();
|
||
if (token == null || token.isEmpty) {
|
||
return GitHubUploadResult(
|
||
success: false,
|
||
message: 'GitHub export requires an explicitly configured GitHub token.',
|
||
);
|
||
}
|
||
return _uploadToGitHub(rideId, token);
|
||
}
|
||
|
||
Future<GitHubUploadResult> _uploadToMotorCloud(int rideId) async {
|
||
final user = _authService.currentUser;
|
||
if (user == null || user.isAnonymous) {
|
||
_state = GitHubUploadState.authRequired;
|
||
_statusMessage = 'Sign in to MotorCloud before uploading a ride.';
|
||
notifyListeners();
|
||
return GitHubUploadResult(success: false, message: _statusMessage);
|
||
}
|
||
|
||
if (isRideUploaded(rideId)) {
|
||
_state = GitHubUploadState.success;
|
||
_statusMessage = 'Ride #$rideId is already backed up to your MotorCloud account.';
|
||
notifyListeners();
|
||
return GitHubUploadResult(
|
||
success: true,
|
||
message: _statusMessage,
|
||
commitSha: getRideCommitSha(rideId),
|
||
targetPath: 'MotorCloud',
|
||
);
|
||
}
|
||
|
||
_state = GitHubUploadState.uploading;
|
||
_statusMessage = 'Backing up Ride #$rideId to MotorCloud...';
|
||
notifyListeners();
|
||
|
||
try {
|
||
final provider = FirebaseSyncProvider(db: _db, authService: _authService);
|
||
final result = await provider.uploadRide(SyncItem(
|
||
localRideId: rideId,
|
||
providerId: 'firebase',
|
||
createdAt: DateTime.now(),
|
||
));
|
||
|
||
if (result.success) {
|
||
await _prefs.setBool(_userScopedKey('motorcloud_uploaded_ride', rideId)!, true);
|
||
await _prefs.setString(
|
||
_userScopedKey('motorcloud_uploaded_at_ride', rideId)!,
|
||
DateTime.now().toIso8601String(),
|
||
);
|
||
if (result.remoteId != null) {
|
||
await _prefs.setString(
|
||
_userScopedKey('motorcloud_ride_remote_id', rideId)!,
|
||
result.remoteId!,
|
||
);
|
||
}
|
||
_uploadQueue.remove(rideId);
|
||
_state = GitHubUploadState.success;
|
||
_statusMessage = 'Ride #$rideId backed up to your MotorCloud account';
|
||
notifyListeners();
|
||
return GitHubUploadResult(
|
||
success: true,
|
||
message: _statusMessage,
|
||
commitSha: result.remoteId,
|
||
targetPath: 'MotorCloud',
|
||
);
|
||
}
|
||
|
||
_state = result.requiresAuth ? GitHubUploadState.authRequired : GitHubUploadState.failed;
|
||
_statusMessage = result.errorMessage ?? 'MotorCloud backup failed';
|
||
notifyListeners();
|
||
return GitHubUploadResult(success: false, message: _statusMessage);
|
||
} catch (e) {
|
||
_state = GitHubUploadState.failed;
|
||
_statusMessage = 'MotorCloud backup failed: $e';
|
||
notifyListeners();
|
||
return GitHubUploadResult(success: false, message: _statusMessage);
|
||
}
|
||
}
|
||
|
||
Future<GitHubUploadResult> _uploadToGitHub(int rideId, String token) async {
|
||
_state = GitHubUploadState.uploading;
|
||
_statusMessage = 'Uploading Ride #$rideId to GitHub...';
|
||
notifyListeners();
|
||
|
||
try {
|
||
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
|
||
if (ride == null) throw HttpException('Ride #$rideId not found in local database');
|
||
|
||
final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal());
|
||
final folderName = 'field_test_session_${rideId}_$dateStr/field_test_session';
|
||
final repo = _githubAuth.getRepository();
|
||
final branch = _githubAuth.getBranch();
|
||
final fieldTestExport = FieldTestExportService(_db);
|
||
final exportService = ExportService(_db);
|
||
final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId);
|
||
final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId);
|
||
final telemetryCsv = await exportService.exportRideCsv(rideId);
|
||
final gpxContent = await exportService.exportRideGpx(rideId);
|
||
|
||
final samples = await (_db.select(_db.telemetrySamples)
|
||
..where((t) => t.rideId.equals(rideId))
|
||
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])).get();
|
||
final events = await (_db.select(_db.fieldTestEvents)
|
||
..where((e) => e.rideId.equals(rideId))
|
||
..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get();
|
||
final summaryRecord = await (_db.select(_db.fieldTestSummaries)
|
||
..where((s) => s.rideId.equals(rideId))).getSingleOrNull();
|
||
final appIdentity = AppIdentity.current();
|
||
|
||
final readme = StringBuffer()
|
||
..writeln('=====================================================')
|
||
..writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT')
|
||
..writeln('=====================================================')
|
||
..writeln('Application Version: ${appIdentity.semanticVersion}')
|
||
..writeln('Build Number: ${appIdentity.buildNumber}')
|
||
..writeln('Git Revision: ${appIdentity.gitCommit}')
|
||
..writeln('Git Branch: ${appIdentity.gitBranch}')
|
||
..writeln('Release Channel: ${appIdentity.releaseChannel}')
|
||
..writeln('Telemetry Schema Version: ${appIdentity.telemetrySchemaVersion}')
|
||
..writeln('Export Format Version: ${appIdentity.exportSchemaVersion}')
|
||
..writeln('Build Timestamp: ${appIdentity.buildTimestamp}')
|
||
..writeln('Field Test Session / Ride ID: $rideId')
|
||
..writeln('Session Start: ${ride.startTime.toIso8601String()}')
|
||
..writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}')
|
||
..writeln('Export Timestamp: ${DateTime.now().toIso8601String()}')
|
||
..writeln('Summary Result: ${summaryRecord?.rideQualityRating ?? "COMPLETED"}')
|
||
..writeln('Ride Quality Score: ${summaryRecord?.rideQualityScore ?? 100} / 100')
|
||
..writeln('Total Telemetry Samples: ${samples.length}')
|
||
..writeln('Total Forensic Events: ${events.length}');
|
||
|
||
final rideSummaryJson = const JsonEncoder.withIndent(' ').convert({
|
||
'rideId': ride.id,
|
||
'startTime': ride.startTime.toIso8601String(),
|
||
'endTime': ride.endTime?.toIso8601String(),
|
||
'distanceMiles': ride.distanceMiles,
|
||
'topSpeedMph': ride.topSpeedMph,
|
||
'maxLeanLeft': ride.maxLeanLeft,
|
||
'maxLeanRight': ride.maxLeanRight,
|
||
'maxAccelG': ride.maxAccelG,
|
||
'maxBrakingG': ride.maxBrakingG,
|
||
'averageSpeed': ride.averageSpeed,
|
||
'movingTime': ride.movingTime,
|
||
'stoppedTime': ride.stoppedTime,
|
||
'qualityScore': summaryRecord?.rideQualityScore ?? 100,
|
||
'qualityRating': summaryRecord?.rideQualityRating ?? 'EXCELLENT',
|
||
'software': appIdentity.toJson(),
|
||
});
|
||
|
||
final filesToUpload = <String, String>{
|
||
'$folderName/README.txt': readme.toString(),
|
||
'$folderName/ride_summary.json': rideSummaryJson,
|
||
'$folderName/field_test_session.json': sessionJson,
|
||
'$folderName/field_test_events.csv': eventsCsv,
|
||
'$folderName/telemetry.csv': telemetryCsv,
|
||
};
|
||
if (gpxContent.isNotEmpty) filesToUpload['$folderName/telemetry.gpx'] = gpxContent;
|
||
|
||
String? lastCommitSha;
|
||
for (final entry in filesToUpload.entries) {
|
||
final filePath = entry.key;
|
||
final getRes = await _client.get(
|
||
Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'),
|
||
headers: {
|
||
'Authorization': 'Bearer $token',
|
||
'Accept': 'application/vnd.github.v3+json',
|
||
'User-Agent': 'MotorSignal-Telemetry/1.0.2',
|
||
},
|
||
).timeout(const Duration(seconds: 10));
|
||
String? existingSha;
|
||
if (getRes.statusCode == 200) existingSha = jsonDecode(getRes.body)['sha'];
|
||
|
||
final payload = <String, dynamic>{
|
||
'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]',
|
||
'content': base64.encode(utf8.encode(entry.value)),
|
||
'branch': branch,
|
||
};
|
||
if (existingSha != null) payload['sha'] = existingSha;
|
||
|
||
final putRes = await _client.put(
|
||
Uri.parse('https://api.github.com/repos/$repo/contents/$filePath'),
|
||
headers: {
|
||
'Authorization': 'Bearer $token',
|
||
'Accept': 'application/vnd.github.v3+json',
|
||
'Content-Type': 'application/json',
|
||
'User-Agent': 'MotorSignal-Telemetry/1.0.2',
|
||
},
|
||
body: jsonEncode(payload),
|
||
).timeout(const Duration(seconds: 15));
|
||
|
||
if (putRes.statusCode == 200 || putRes.statusCode == 201) {
|
||
lastCommitSha = jsonDecode(putRes.body)['commit']?['sha'];
|
||
} else if (putRes.statusCode == 401 || putRes.statusCode == 403) {
|
||
throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}).');
|
||
} else {
|
||
throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})');
|
||
}
|
||
}
|
||
|
||
_state = GitHubUploadState.success;
|
||
_statusMessage = 'Ride #$rideId exported to $repo ($branch)';
|
||
notifyListeners();
|
||
return GitHubUploadResult(success: true, message: _statusMessage, commitSha: lastCommitSha, targetPath: folderName);
|
||
} catch (e) {
|
||
_state = GitHubUploadState.failed;
|
||
_statusMessage = 'GitHub export failed for Ride #$rideId: $e';
|
||
notifyListeners();
|
||
return GitHubUploadResult(success: false, message: _statusMessage);
|
||
}
|
||
}
|
||
}
|
||
|
||
final githubSyncServiceProvider = ChangeNotifierProvider<GitHubSyncService>((ref) {
|
||
final db = ref.watch(databaseProvider);
|
||
final githubAuth = ref.watch(githubAuthServiceProvider);
|
||
final prefs = ref.watch(sharedPreferencesProvider);
|
||
final authService = ref.watch(authServiceProvider);
|
||
return GitHubSyncService(db, githubAuth, prefs, null, authService);
|
||
});
|