fix(sync): use authenticated MotorCloud backup for ride Upload Now

This commit is contained in:
bponders12-cell 2026-08-31 15:58:26 -04:00
parent 50fd401753
commit e18693ab43
1 changed files with 80 additions and 37 deletions

View File

@ -9,8 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../core/version/app_identity.dart'; import '../../core/version/app_identity.dart';
import '../../database/database.dart'; import '../../database/database.dart';
import '../../features/records/records_service.dart'; import '../../features/records/records_service.dart';
import '../auth/auth_service.dart';
import '../export_service.dart'; import '../export_service.dart';
import '../field_test/field_test_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'; import 'github_auth_service.dart';
enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired } enum GitHubUploadState { idle, queued, uploading, success, failed, authRequired }
@ -31,7 +34,13 @@ class GitHubUploadResult {
}) : timestamp = timestamp ?? DateTime.now(); }) : timestamp = timestamp ?? DateTime.now();
} }
/// Offline-first background synchronization service for GitHub ride logs. /// 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 { class GitHubSyncService extends ChangeNotifier {
final AppDatabase _db; final AppDatabase _db;
final GitHubAuthService _auth; final GitHubAuthService _auth;
@ -57,20 +66,13 @@ class GitHubSyncService extends ChangeNotifier {
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 cloud synchronization';
notifyListeners(); notifyListeners();
processQueue(); processQueue();
} }
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();
if (token == null || token.isEmpty) {
_state = GitHubUploadState.authRequired;
_statusMessage = 'GitHub authorization required. Google/Firebase sign-in does not authorize GitHub repository writes.';
notifyListeners();
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);
@ -78,35 +80,82 @@ class GitHubSyncService extends ChangeNotifier {
} }
Future<GitHubUploadResult> uploadRide(int rideId, {bool isManual = false}) async { Future<GitHubUploadResult> uploadRide(int rideId, {bool isManual = false}) async {
final token = _auth.getToken(); // IMPORTANT: Google/Firebase authentication is the normal MotorCloud cloud
if (token == null || token.isEmpty) { // authentication. A GitHub PAT is optional and only needed for explicit
_state = GitHubUploadState.authRequired; // GitHub repository export.
_statusMessage = 'GitHub authorization required. Google/Firebase sign-in is separate from GitHub repository access.'; 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(); notifyListeners();
return GitHubUploadResult( return GitHubUploadResult(
success: false, success: true,
message: 'GitHub authorization required. Configure a GitHub Personal Access Token before uploading ride logs.', 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; _state = GitHubUploadState.uploading;
_statusMessage = 'Uploading Ride #$rideId to GitHub...'; _statusMessage = 'Uploading Ride #$rideId to GitHub...';
notifyListeners(); notifyListeners();
try { try {
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
if (ride == null) { if (ride == null) throw HttpException('Ride #$rideId not found in local database');
_state = GitHubUploadState.failed;
_statusMessage = 'Ride #$rideId not found in local database';
notifyListeners();
return GitHubUploadResult(success: false, message: 'Ride record not found');
}
final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal()); final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal());
final folderName = 'field_test_session_${rideId}_$dateStr/field_test_session'; final folderName = 'field_test_session_${rideId}_$dateStr/field_test_session';
final repo = _auth.getRepository(); final repo = _auth.getRepository();
final branch = _auth.getBranch(); final branch = _auth.getBranch();
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);
@ -122,8 +171,8 @@ class GitHubSyncService extends ChangeNotifier {
..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get(); ..orderBy([(e) => OrderingTerm(expression: e.timestamp)])).get();
final summaryRecord = await (_db.select(_db.fieldTestSummaries) 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 appIdentity = AppIdentity.current();
final readme = StringBuffer() final readme = StringBuffer()
..writeln('=====================================================') ..writeln('=====================================================')
..writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT') ..writeln('MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT')
@ -153,8 +202,6 @@ class GitHubSyncService extends ChangeNotifier {
'topSpeedMph': ride.topSpeedMph, 'topSpeedMph': ride.topSpeedMph,
'maxLeanLeft': ride.maxLeanLeft, 'maxLeanLeft': ride.maxLeanLeft,
'maxLeanRight': ride.maxLeanRight, 'maxLeanRight': ride.maxLeanRight,
'maxMovingLeanLeft': ride.maxLeanLeft,
'maxMovingLeanRight': ride.maxLeanRight,
'maxAccelG': ride.maxAccelG, 'maxAccelG': ride.maxAccelG,
'maxBrakingG': ride.maxBrakingG, 'maxBrakingG': ride.maxBrakingG,
'averageSpeed': ride.averageSpeed, 'averageSpeed': ride.averageSpeed,
@ -177,8 +224,6 @@ class GitHubSyncService extends ChangeNotifier {
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;
String? existingSha;
final getRes = await _client.get( final getRes = await _client.get(
Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'), Uri.parse('https://api.github.com/repos/$repo/contents/$filePath?ref=$branch'),
headers: { headers: {
@ -187,13 +232,12 @@ class GitHubSyncService extends ChangeNotifier {
'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) { String? existingSha;
existingSha = jsonDecode(getRes.body)['sha']; if (getRes.statusCode == 200) existingSha = jsonDecode(getRes.body)['sha'];
}
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(entry.value)),
'branch': branch, 'branch': branch,
}; };
if (existingSha != null) payload['sha'] = existingSha; if (existingSha != null) payload['sha'] = existingSha;
@ -212,7 +256,7 @@ class GitHubSyncService extends ChangeNotifier {
if (putRes.statusCode == 200 || putRes.statusCode == 201) { if (putRes.statusCode == 200 || putRes.statusCode == 201) {
lastCommitSha = jsonDecode(putRes.body)['commit']?['sha']; lastCommitSha = jsonDecode(putRes.body)['commit']?['sha'];
} else if (putRes.statusCode == 401 || putRes.statusCode == 403) { } else if (putRes.statusCode == 401 || putRes.statusCode == 403) {
throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}). Reconnect or replace the GitHub token.'); throw HttpException('GitHub authorization rejected the upload (HTTP ${putRes.statusCode}).');
} else { } else {
throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})'); throw HttpException('Failed uploading $filePath (Status: ${putRes.statusCode})');
} }
@ -225,13 +269,12 @@ class GitHubSyncService extends ChangeNotifier {
_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(success: true, message: _statusMessage, commitSha: lastCommitSha, targetPath: folderName); return GitHubUploadResult(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(success: false, message: 'Upload failed: $e'); return GitHubUploadResult(success: false, message: _statusMessage);
} }
} }
} }