MotorSignal-App/lib/services/sync/sync_bundle_worker.dart

146 lines
6.2 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/version/app_identity.dart';
import '../../database/database.dart';
import '../export_service.dart';
import '../field_test/field_test_export_service.dart';
/// Top-level isolate entrypoint for CPU-heavy ZIP compression.
/// Never receives non-isolate-safe objects (database handles, streams, etc.).
Uint8List compressFilesToZipIsolate(Map<String, String> filesMap) {
final archive = Archive();
for (final entry in filesMap.entries) {
final bytes = utf8.encode(entry.value);
archive.addFile(ArchiveFile(entry.key, bytes.length, bytes));
}
final zipEncoder = ZipEncoder();
// Keep retry bytes and the recorded SHA-256 stable for unchanged ride data.
final compressed = zipEncoder.encode(archive, modified: DateTime.utc(1980, 1, 1));
return Uint8List.fromList(compressed);
}
/// Service that queries local forensic datasets and offloads ZIP compression
/// to a background worker isolate via compute(), preventing main thread stutter.
class SyncBundleWorker {
final AppDatabase _db;
SyncBundleWorker(this._db);
/// Generates the standard multi-format forensic bundle for a ride session
/// and compresses it inside a background isolate.
Future<Uint8List?> generateCompressedBundle(int rideId) async {
try {
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
if (ride == null) return null;
final fieldTestExport = FieldTestExportService(_db);
final exportService = ExportService(_db);
// 1. Query local authoritative data on the main isolate
final sessionJson = await fieldTestExport.generateFieldTestSessionJson(
rideId,
generatedAt: ride.endTime ?? ride.startTime,
);
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) => drift.OrderingTerm(expression: t.timestamp)])
).get();
final events = await (_db.select(_db.fieldTestEvents)
..where((e) => e.rideId.equals(rideId))
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)])
).get();
final summaryRecord = await (_db.select(_db.fieldTestSummaries)
..where((s) => s.rideId.equals(rideId))
).getSingleOrNull();
final appIdentity = AppIdentity.current();
// 2. Build README.txt audit header
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('Field Test Session / Ride ID: $rideId');
readme.writeln('Session Start: ${ride.startTime.toIso8601String()}');
readme.writeln('Session End: ${ride.endTime?.toIso8601String() ?? "IN_PROGRESS"}');
readme.writeln('Summary Rating: ${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}');
// 3. Build ride_summary.json
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',
'motorcycleId': ride.motorcycleId,
'motorcycleMake': ride.motorcycleMake,
'motorcycleModel': ride.motorcycleModel,
'motorcycleYear': ride.motorcycleYear,
'software': appIdentity.toJson(),
});
// 4. Assemble isolated serializable string DTO map
final filesMap = <String, String>{
'README.txt': readme.toString(),
'ride_summary.json': rideSummaryJson,
'field_test_session.json': sessionJson,
'field_test_events.csv': eventsCsv,
'telemetry.csv': telemetryCsv,
};
if (gpxContent.isNotEmpty) {
filesMap['telemetry.gpx'] = gpxContent;
}
// 5. Offload CPU-heavy compression to background worker isolate
return await compute(compressFilesToZipIsolate, filesMap);
} catch (e) {
debugPrint('[SyncBundleWorker] Bundle generation error: $e');
return null;
}
}
/// Writes compressed bytes to a temporary file for local inspection or upload
Future<File?> writeBundleToTempFile(int rideId, Uint8List zipBytes) async {
try {
final tempDir = await getTemporaryDirectory();
final dateStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final file = File('${tempDir.path}/ride_${rideId}_$dateStr.zip');
return await file.writeAsBytes(zipBytes);
} catch (e) {
debugPrint('[SyncBundleWorker] Error writing temp zip file: $e');
return null;
}
}
}