fix(sync): clarify GitHub authentication requirement for ride uploads
This commit is contained in:
parent
0522e6cbbf
commit
50fd401753
|
|
@ -13,14 +13,7 @@ import '../export_service.dart';
|
||||||
import '../field_test/field_test_export_service.dart';
|
import '../field_test/field_test_export_service.dart';
|
||||||
import 'github_auth_service.dart';
|
import 'github_auth_service.dart';
|
||||||
|
|
||||||
enum GitHubUploadState {
|
enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired }
|
||||||
idle,
|
|
||||||
queued,
|
|
||||||
uploading,
|
|
||||||
success,
|
|
||||||
failed,
|
|
||||||
authRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitHubUploadResult {
|
class GitHubUploadResult {
|
||||||
final bool success;
|
final bool success;
|
||||||
|
|
@ -38,7 +31,7 @@ class GitHubUploadResult {
|
||||||
}) : timestamp = timestamp ?? DateTime.now();
|
}) : 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 {
|
class GitHubSyncService extends ChangeNotifier {
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final GitHubAuthService _auth;
|
final GitHubAuthService _auth;
|
||||||
|
|
@ -56,56 +49,44 @@ class GitHubSyncService extends ChangeNotifier {
|
||||||
String get statusMessage => _statusMessage;
|
String get statusMessage => _statusMessage;
|
||||||
Set<int> get queuedRideIds => Set.unmodifiable(_uploadQueue);
|
Set<int> get queuedRideIds => Set.unmodifiable(_uploadQueue);
|
||||||
|
|
||||||
bool isRideUploaded(int rideId) {
|
bool isRideUploaded(int rideId) => _prefs.getBool('github_uploaded_ride_$rideId') ?? false;
|
||||||
return _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) {
|
void queueRideForUpload(int rideId) {
|
||||||
if (isRideUploaded(rideId)) return;
|
if (isRideUploaded(rideId)) return;
|
||||||
_uploadQueue.add(rideId);
|
_uploadQueue.add(rideId);
|
||||||
_state = GitHubUploadState.queued;
|
_state = GitHubUploadState.queued;
|
||||||
_statusMessage = 'Ride #$rideId queued for GitHub upload';
|
_statusMessage = 'Ride #$rideId queued for GitHub upload';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Trigger async upload
|
|
||||||
processQueue();
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes queued rides sequentially without blocking main thread
|
|
||||||
Future<void> processQueue() async {
|
Future<void> processQueue() async {
|
||||||
if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return;
|
if (_uploadQueue.isEmpty || _state == GitHubUploadState.uploading) return;
|
||||||
|
|
||||||
final token = _auth.getToken();
|
final token = _auth.getToken();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
_state = GitHubUploadState.authRequired;
|
_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();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final toProcess = List<int>.from(_uploadQueue);
|
final toProcess = List<int>.from(_uploadQueue);
|
||||||
for (final rideId in toProcess) {
|
for (final rideId in toProcess) {
|
||||||
await uploadRide(rideId);
|
await uploadRide(rideId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronizes a specific ride session to GitHub repository
|
|
||||||
Future<GitHubUploadResult> uploadRide(int rideId, {bool isManual = false}) async {
|
Future<GitHubUploadResult> uploadRide(int rideId, {bool isManual = false}) async {
|
||||||
final token = _auth.getToken();
|
final token = _auth.getToken();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
_state = GitHubUploadState.authRequired;
|
_state = GitHubUploadState.authRequired;
|
||||||
_statusMessage = 'GitHub token required';
|
_statusMessage = 'GitHub authorization required. Google/Firebase sign-in is separate from GitHub repository access.';
|
||||||
notifyListeners();
|
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;
|
_state = GitHubUploadState.uploading;
|
||||||
|
|
@ -126,10 +107,8 @@ class GitHubSyncService extends ChangeNotifier {
|
||||||
final repo = _auth.getRepository();
|
final repo = _auth.getRepository();
|
||||||
final branch = _auth.getBranch();
|
final branch = _auth.getBranch();
|
||||||
|
|
||||||
// Generate bundle files from local SQLite database (preserving offline data integrity)
|
|
||||||
final fieldTestExport = FieldTestExportService(_db);
|
final fieldTestExport = FieldTestExportService(_db);
|
||||||
final exportService = ExportService(_db);
|
final exportService = ExportService(_db);
|
||||||
|
|
||||||
final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId);
|
final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId);
|
||||||
final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId);
|
final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId);
|
||||||
final telemetryCsv = await exportService.exportRideCsv(rideId);
|
final telemetryCsv = await exportService.exportRideCsv(rideId);
|
||||||
|
|
@ -137,41 +116,35 @@ class GitHubSyncService extends ChangeNotifier {
|
||||||
|
|
||||||
final samples = await (_db.select(_db.telemetrySamples)
|
final samples = await (_db.select(_db.telemetrySamples)
|
||||||
..where((t) => t.rideId.equals(rideId))
|
..where((t) => t.rideId.equals(rideId))
|
||||||
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])
|
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])).get();
|
||||||
).get();
|
|
||||||
final events = await (_db.select(_db.fieldTestEvents)
|
final events = await (_db.select(_db.fieldTestEvents)
|
||||||
..where((e) => e.rideId.equals(rideId))
|
..where((e) => e.rideId.equals(rideId))
|
||||||
..orderBy([(e) => OrderingTerm(expression: e.timestamp)])
|
..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get();
|
||||||
).get();
|
|
||||||
final summaryRecord = await (_db.select(_db.fieldTestSummaries)
|
final summaryRecord = await (_db.select(_db.fieldTestSummaries)
|
||||||
..where((s) => s.rideId.equals(rideId))
|
..where((s) => s.rideId.equals(rideId))).getSingleOrNull();
|
||||||
).getSingleOrNull();
|
|
||||||
|
|
||||||
final appIdentity = AppIdentity.current();
|
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({
|
final rideSummaryJson = const JsonEncoder.withIndent(' ').convert({
|
||||||
'rideId': ride.id,
|
'rideId': ride.id,
|
||||||
'startTime': ride.startTime.toIso8601String(),
|
'startTime': ride.startTime.toIso8601String(),
|
||||||
|
|
@ -199,43 +172,34 @@ class GitHubSyncService extends ChangeNotifier {
|
||||||
'$folderName/field_test_events.csv': eventsCsv,
|
'$folderName/field_test_events.csv': eventsCsv,
|
||||||
'$folderName/telemetry.csv': telemetryCsv,
|
'$folderName/telemetry.csv': telemetryCsv,
|
||||||
};
|
};
|
||||||
|
if (gpxContent.isNotEmpty) filesToUpload['$folderName/telemetry.gpx'] = gpxContent;
|
||||||
if (gpxContent.isNotEmpty) {
|
|
||||||
filesToUpload['$folderName/telemetry.gpx'] = gpxContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? lastCommitSha;
|
String? lastCommitSha;
|
||||||
for (final entry in filesToUpload.entries) {
|
for (final entry in filesToUpload.entries) {
|
||||||
final filePath = entry.key;
|
final filePath = entry.key;
|
||||||
final fileContent = entry.value;
|
final fileContent = entry.value;
|
||||||
|
|
||||||
// Check if file already exists on branch to retrieve SHA
|
|
||||||
String? existingSha;
|
String? existingSha;
|
||||||
final getUrl = Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch');
|
final getRes = await _client.get(
|
||||||
final getRes = await _client.get(getUrl, headers: {
|
Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'),
|
||||||
|
headers: {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
'User-Agent': 'MotorSignal-Telemetry/1.0.2',
|
'User-Agent': 'MotorSignal-Telemetry/1.0.2',
|
||||||
}).timeout(const Duration(seconds: 10));
|
},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
if (getRes.statusCode == 200) {
|
if (getRes.statusCode == 200) {
|
||||||
final data = jsonDecode(getRes.body);
|
existingSha = jsonDecode(getRes.body)['sha'];
|
||||||
existingSha = data['sha'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload file via Contents API
|
|
||||||
final putUrl = Uri.parse('https://api.github.com/repos/$repo/contents/$filePath');
|
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]',
|
'message': 'feat(telemetry): upload ride #$rideId forensic session data [v1.0.2+102]',
|
||||||
'content': base64.encode(utf8.encode(fileContent)),
|
'content': base64.encode(utf8.encode(fileContent)),
|
||||||
'branch': branch,
|
'branch': branch,
|
||||||
};
|
};
|
||||||
if (existingSha != null) {
|
if (existingSha != null) payload['sha'] = existingSha;
|
||||||
payload['sha'] = existingSha;
|
|
||||||
}
|
|
||||||
|
|
||||||
final putRes = await _client.put(
|
final putRes = await _client.put(
|
||||||
putUrl,
|
Uri.parse('https://api.github.com/repos/$repo/contents/$filePath'),
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
|
@ -246,39 +210,28 @@ class GitHubSyncService extends ChangeNotifier {
|
||||||
).timeout(const Duration(seconds: 15));
|
).timeout(const Duration(seconds: 15));
|
||||||
|
|
||||||
if (putRes.statusCode == 200 || putRes.statusCode == 201) {
|
if (putRes.statusCode == 200 || putRes.statusCode == 201) {
|
||||||
final resData = jsonDecode(putRes.body);
|
lastCommitSha = jsonDecode(putRes.body)['commit']?['sha'];
|
||||||
lastCommitSha = resData['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 {
|
} else {
|
||||||
throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})');
|
throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record success metadata locally
|
|
||||||
await _prefs.setBool('github_uploaded_ride_$rideId', true);
|
await _prefs.setBool('github_uploaded_ride_$rideId', true);
|
||||||
await _prefs.setString('github_uploaded_at_ride_$rideId', DateTime.now().toIso8601String());
|
await _prefs.setString('github_uploaded_at_ride_$rideId', DateTime.now().toIso8601String());
|
||||||
if (lastCommitSha != null) {
|
if (lastCommitSha != null) await _prefs.setString('github_sha_ride_$rideId', lastCommitSha);
|
||||||
await _prefs.setString('github_sha_ride_$rideId', lastCommitSha);
|
|
||||||
}
|
|
||||||
|
|
||||||
_uploadQueue.remove(rideId);
|
_uploadQueue.remove(rideId);
|
||||||
_state = GitHubUploadState.success;
|
_state = GitHubUploadState.success;
|
||||||
_statusMessage = 'Ride #$rideId successfully uploaded to $repo ($branch)';
|
_statusMessage = 'Ride #$rideId successfully uploaded to $repo ($branch)';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
return GitHubUploadResult(
|
return GitHubUploadResult(success: true, message: _statusMessage, commitSha: lastCommitSha, targetPath: folderName);
|
||||||
success: true,
|
|
||||||
message: _statusMessage,
|
|
||||||
commitSha: lastCommitSha,
|
|
||||||
targetPath: folderName,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_state = GitHubUploadState.failed;
|
_state = GitHubUploadState.failed;
|
||||||
_statusMessage = 'Upload failed for Ride #$rideId: $e';
|
_statusMessage = 'Upload failed for Ride #$rideId: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return GitHubUploadResult(
|
return GitHubUploadResult(success: false, message: 'Upload failed: $e');
|
||||||
success: false,
|
|
||||||
message: 'Upload failed: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue