diff --git a/lib/services/github/github_sync_service.dart b/lib/services/github/github_sync_service.dart index 0799b0f..b29ffde 100644 --- a/lib/services/github/github_sync_service.dart +++ b/lib/services/github/github_sync_service.dart @@ -13,14 +13,7 @@ import '../export_service.dart'; import '../field_test/field_test_export_service.dart'; import 'github_auth_service.dart'; -enum GitHubUploadState { - idle, - queued, - uploading, - success, - failed, - authRequired, -} +enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired } class GitHubUploadResult { final bool success; @@ -38,7 +31,7 @@ class GitHubUploadResult { }) : timestamp = timestamp ?? DateTime.now(); } -/// Offline-first background synchronization service for GitHub ride logs +/// Offline-first background synchronization service for GitHub ride logs. class GitHubSyncService extends ChangeNotifier { final AppDatabase _db; final GitHubAuthService _auth; @@ -56,56 +49,44 @@ class GitHubSyncService extends ChangeNotifier { String get statusMessage => _statusMessage; Set get queuedRideIds => Set.unmodifiable(_uploadQueue); - bool isRideUploaded(int rideId) { - return _prefs.getBool('github_uploaded_ride_$rideId') ?? false; - } + 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'); - String? getRideCommitSha(int rideId) { - return _prefs.getString('github_sha_ride_$rideId'); - } - - String? getRideUploadTimestamp(int rideId) { - return _prefs.getString('github_uploaded_at_ride_$rideId'); - } - - /// Queues a ride for background synchronization void queueRideForUpload(int rideId) { if (isRideUploaded(rideId)) return; _uploadQueue.add(rideId); _state = GitHubUploadState.queued; _statusMessage = 'Ride #$rideId queued for GitHub upload'; notifyListeners(); - - // Trigger async upload processQueue(); } - /// Processes queued rides sequentially without blocking main thread Future processQueue() async { if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return; - final token = _auth.getToken(); if (token == null || token.isEmpty) { _state = GitHubUploadState.authRequired; - _statusMessage = 'GitHub Personal Access Token required for sync'; + _statusMessage = 'GitHub authorization required. Google/Firebase sign-in does not authorize GitHub repository writes.'; notifyListeners(); return; } - final toProcess = List.from(_uploadQueue); for (final rideId in toProcess) { await uploadRide(rideId); } } - /// Synchronizes a specific ride session to GitHub repository Future uploadRide(int rideId, {bool isManual = false}) async { final token = _auth.getToken(); if (token == null || token.isEmpty) { _state = GitHubUploadState.authRequired; - _statusMessage = 'GitHub token required'; + _statusMessage = 'GitHub authorization required. Google/Firebase sign-in is separate from GitHub repository access.'; notifyListeners(); - return GitHubUploadResult(success: false, message: 'Authentication required'); + return GitHubUploadResult( + success: false, + message: 'GitHub authorization required. Configure a GitHub Personal Access Token before uploading ride logs.', + ); } _state = GitHubUploadState.uploading; @@ -126,10 +107,8 @@ class GitHubSyncService extends ChangeNotifier { final repo = _auth.getRepository(); final branch = _auth.getBranch(); - // Generate bundle files from local SQLite database (preserving offline data integrity) 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); @@ -137,41 +116,35 @@ class GitHubSyncService extends ChangeNotifier { final samples = await (_db.select(_db.telemetrySamples) ..where((t) => t.rideId.equals(rideId)) - ..orderBy([(t) => OrderingTerm(expression: t.timestamp)]) - ).get(); + ..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(); + ..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get(); final summaryRecord = await (_db.select(_db.fieldTestSummaries) - ..where((s) => s.rideId.equals(rideId)) - ).getSingleOrNull(); + ..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}'); - // README.txt - final readme = StringBuffer(); - readme.writeln('====================================================='); - readme.writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT'); - readme.writeln('====================================================='); - readme.writeln('Application Version: ${appIdentity.semanticVersion}'); - readme.writeln('Build Number: ${appIdentity.buildNumber}'); - readme.writeln('Git Revision: ${appIdentity.gitCommit}'); - readme.writeln('Git Branch: ${appIdentity.gitBranch}'); - readme.writeln('Release Channel: ${appIdentity.releaseChannel}'); - readme.writeln('Telemetry Schema Version: ${appIdentity.telemetrySchemaVersion}'); - readme.writeln('Export Format Version: ${appIdentity.exportSchemaVersion}'); - readme.writeln('Build Timestamp: ${appIdentity.buildTimestamp}'); - readme.writeln('Field Test Session / Ride ID: $rideId'); - readme.writeln('Session Start: ${ride.startTime.toIso8601String()}'); - readme.writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}'); - readme.writeln('Export Timestamp: ${DateTime.now().toIso8601String()}'); - readme.writeln('Summary Result: ${summaryRecord?.rideQualityRating ?? "COMPLETED"}'); - readme.writeln('Ride Quality Score: ${summaryRecord?.rideQualityScore ?? 100} / 100'); - readme.writeln('Total Telemetry Samples: ${samples.length}'); - readme.writeln('Total Forensic Events: ${events.length}'); - - // ride_summary.json final rideSummaryJson = const JsonEncoder.withIndent(' ').convert({ 'rideId': ride.id, 'startTime': ride.startTime.toIso8601String(), @@ -199,43 +172,34 @@ class GitHubSyncService extends ChangeNotifier { '$folderName/field_test_events.csv': eventsCsv, '$folderName/telemetry.csv': telemetryCsv, }; - - if (gpxContent.isNotEmpty) { - filesToUpload['$folderName/telemetry.gpx'] = gpxContent; - } + if (gpxContent.isNotEmpty) filesToUpload['$folderName/telemetry.gpx'] = gpxContent; String? lastCommitSha; for (final entry in filesToUpload.entries) { final filePath = entry.key; final fileContent = entry.value; - - // Check if file already exists on branch to retrieve SHA String? existingSha; - final getUrl = Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'); - final getRes = await _client.get(getUrl, headers: { - 'Authorization': 'Bearer $token', - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'MotorSignal-Telemetry/1.0.2', - }).timeout(const Duration(seconds: 10)); - + 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)); if (getRes.statusCode == 200) { - final data = jsonDecode(getRes.body); - existingSha = data['sha']; + existingSha = jsonDecode(getRes.body)['sha']; } - // Upload file via Contents API - final putUrl = Uri.parse('https://api.github.com/repos/$repo/contents/$filePath'); final payload = { 'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]', 'content': base64.encode(utf8.encode(fileContent)), 'branch': branch, }; - if (existingSha != null) { - payload['sha'] = existingSha; - } + if (existingSha != null) payload['sha'] = existingSha; final putRes = await _client.put( - putUrl, + Uri.parse('https://api.github.com/repos/$repo/contents/$filePath'), headers: { 'Authorization': 'Bearer $token', 'Accept': 'application/vnd.github.v3+json', @@ -246,39 +210,28 @@ class GitHubSyncService extends ChangeNotifier { ).timeout(const Duration(seconds: 15)); if (putRes.statusCode == 200 || putRes.statusCode == 201) { - final resData = jsonDecode(putRes.body); - lastCommitSha = resData['commit']?['sha']; + lastCommitSha = jsonDecode(putRes.body)['commit']?['sha']; + } else if (putRes.statusCode == 401 || putRes.statusCode == 403) { + throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}). Reconnect or replace the GitHub token.'); } else { throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})'); } } - // Record success metadata locally 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); - } - + 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, - ); + 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: 'Upload failed: $e', - ); + return GitHubUploadResult(success: false, message: 'Upload failed: $e'); } } } @@ -288,4 +241,4 @@ final githubSyncServiceProvider = ChangeNotifierProvider((ref final auth = ref.watch(githubAuthServiceProvider); final prefs = ref.watch(sharedPreferencesProvider); return GitHubSyncService(db, auth, prefs); -}); +}); \ No newline at end of file