fix(sync): use authenticated MotorCloud backup for ride Upload Now
This commit is contained in:
parent
50fd401753
commit
e18693ab43
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue