MotorSignal-App/lib/services/github/github_sync_service.dart

341 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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