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

287 lines
12 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_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();
}
/// Ride synchronization service.
///
/// Historical versions of the ride-detail screen called this service directly,
/// which required a separate GitHub PAT even when the user was already signed
/// into MotorCloud with Google/Firebase. When no GitHub PAT is configured we now
/// use the authoritative Firebase + R2 provider, matching Settings > Back Up All.
/// GitHub remains available when a PAT is explicitly configured.
class GitHubSyncService extends ChangeNotifier {
final AppDatabase _db;
final GitHubAuthService _auth;
final SharedPreferences _prefs;
final http.Client _client;
GitHubUploadState _state = GitHubUploadState.idle;
String _statusMessage = '';
final Set<int> _uploadQueue = {};
GitHubSyncService(this._db, this._auth, this._prefs, [http.Client? client])
: _client = client ?? http.Client();
GitHubUploadState get state => _state;
String get statusMessage => _statusMessage;
Set<int> get queuedRideIds => Set.unmodifiable(_uploadQueue);
bool isRideUploaded(int rideId) => _prefs.getBool('github_uploaded_ride_$rideId') ?? false;
String? getRideCommitSha(int rideId) => _prefs.getString('github_sha_ride_$rideId');
String? getRideUploadTimestamp(int rideId) => _prefs.getString('github_uploaded_at_ride_$rideId');
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);
}
}
Future<GitHubUploadResult> uploadRide(int rideId, {bool isManual = false}) async {
// IMPORTANT: Google/Firebase authentication is the normal MotorCloud cloud
// authentication. A GitHub PAT is optional and only needed for explicit
// GitHub repository export.
final githubToken = _auth.getToken();
if (githubToken == null || githubToken.isEmpty) {
return _uploadToMotorCloud(rideId);
}
return _uploadToGitHub(rideId, githubToken);
}
Future<GitHubUploadResult> _uploadToMotorCloud(int rideId) async {
_state = GitHubUploadState.uploading;
_statusMessage = 'Backing up Ride #$rideId to MotorCloud...';
notifyListeners();
try {
final provider = FirebaseSyncProvider(
db: _db,
authService: FirebaseAuthService(),
);
final result = await provider.uploadRide(
SyncItem(
localRideId: rideId,
providerId: 'firebase',
createdAt: DateTime.now(),
),
);
if (result.success) {
await _prefs.setBool('github_uploaded_ride_$rideId', true);
await _prefs.setString('github_uploaded_at_ride_$rideId', DateTime.now().toIso8601String());
if (result.remoteId != null) {
await _prefs.setString('github_sha_ride_$rideId', result.remoteId!);
}
_uploadQueue.remove(rideId);
_state = GitHubUploadState.success;
_statusMessage = 'Ride #$rideId backed up to MotorCloud';
notifyListeners();
return GitHubUploadResult(
success: true,
message: _statusMessage,
commitSha: result.remoteId,
targetPath: 'MotorCloud',
);
}
if (result.requiresAuth) {
_state = GitHubUploadState.authRequired;
} else {
_state = 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 = _auth.getRepository();
final branch = _auth.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})');
}
}
await _prefs.setBool('github_uploaded_ride_$rideId', true);
await _prefs.setString('github_uploaded_at_ride_$rideId', DateTime.now().toIso8601String());
if (lastCommitSha != null) await _prefs.setString('github_sha_ride_$rideId', lastCommitSha);
_uploadQueue.remove(rideId);
_state = GitHubUploadState.success;
_statusMessage = 'Ride #$rideId successfully uploaded to $repo ($branch)';
notifyListeners();
return GitHubUploadResult(success: true, message: _statusMessage, commitSha: lastCommitSha, targetPath: folderName);
} catch (e) {
_state = GitHubUploadState.failed;
_statusMessage = 'Upload failed for Ride #$rideId: $e';
notifyListeners();
return GitHubUploadResult(success: false, message: _statusMessage);
}
}
}
final githubSyncServiceProvider = ChangeNotifierProvider<GitHubSyncService>((ref) {
final db = ref.watch(databaseProvider);
final auth = ref.watch(githubAuthServiceProvider);
final prefs = ref.watch(sharedPreferencesProvider);
return GitHubSyncService(db, auth, prefs);
});