687 lines
21 KiB
Dart
687 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:archive/archive.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
|
import 'package:motorcycle_dash/services/sync/cloud_reconciliation_service.dart';
|
|
import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart';
|
|
import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart';
|
|
import 'package:motorcycle_dash/services/sync/r2_download_service.dart';
|
|
|
|
class _TestAuthService implements AuthService {
|
|
@override bool get isEmailVerified => false;
|
|
@override Future<void> sendEmailVerification() async {}
|
|
@override Future<void> reloadCurrentUser() async {}
|
|
@override Future<void> reauthenticate({String? password}) async {}
|
|
@override Future<void> deleteCurrentUser() async {}
|
|
AuthUser? _user;
|
|
String? token = 'token';
|
|
bool configured = true;
|
|
|
|
_TestAuthService([this._user]);
|
|
|
|
void setUser(AuthUser? user) => _user = user;
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => Stream.value(currentState);
|
|
|
|
@override
|
|
AuthState get currentState {
|
|
final user = _user;
|
|
if (user == null) {
|
|
return AuthState.unauthenticated(isFirebaseConfigured: configured);
|
|
}
|
|
return user.isAnonymous
|
|
? AuthState.anonymous(user, isFirebaseConfigured: configured)
|
|
: AuthState.authenticated(user, isFirebaseConfigured: configured);
|
|
}
|
|
|
|
@override
|
|
AuthUser? get currentUser => _user;
|
|
|
|
@override
|
|
bool get isConfigured => configured;
|
|
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async => token;
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(
|
|
String email,
|
|
String password, {
|
|
String? displayName,
|
|
}) async {}
|
|
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {}
|
|
|
|
@override
|
|
Future<void> signInAnonymously() async {
|
|
_user = const AuthUser(uid: 'anon_1', isAnonymous: true);
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {
|
|
_user = AuthUser(uid: 'user_a', email: email);
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithGoogle() async {
|
|
_user = const AuthUser(uid: 'user_google', email: 'rider@test.com');
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
_user = null;
|
|
}
|
|
|
|
@override
|
|
void dispose() {}
|
|
}
|
|
|
|
class _MemoryRideMetadataStore implements CloudRideMetadataStore {
|
|
final Map<String, List<CloudRideMetadataDocument>> byUid;
|
|
final List<CloudRideMetadataDocument>? documentsForEveryUser;
|
|
|
|
_MemoryRideMetadataStore({
|
|
Map<String, List<CloudRideMetadataDocument>>? byUid,
|
|
this.documentsForEveryUser,
|
|
}) : byUid = byUid ?? {};
|
|
|
|
@override
|
|
Future<List<CloudRideMetadataDocument>> listRideDocuments(String uid) async {
|
|
return documentsForEveryUser ?? byUid[uid] ?? const [];
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('Manual MotorCloud restore', () {
|
|
late AppDatabase db;
|
|
late _TestAuthService auth;
|
|
late int r2Requests;
|
|
|
|
setUp(() {
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
auth = _TestAuthService(
|
|
const AuthUser(uid: 'user_a', email: 'a@test.com'),
|
|
);
|
|
r2Requests = 0;
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
});
|
|
|
|
CloudRestoreService service({
|
|
required CloudRideMetadataStore store,
|
|
required http.Client client,
|
|
}) {
|
|
return CloudRestoreService(
|
|
db: db,
|
|
authService: auth,
|
|
metadataStore: store,
|
|
r2DownloadService: R2DownloadService(
|
|
client: client,
|
|
workerBaseUrl: 'https://worker.test',
|
|
),
|
|
);
|
|
}
|
|
|
|
test(
|
|
'clean install restores metadata, duration, telemetry, and is idempotent',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
_cloudRide(
|
|
id: 'ride_clean_install',
|
|
ownerId: 'user_a',
|
|
movingTime: 600,
|
|
stoppedTime: 45,
|
|
performanceRuns: [
|
|
_performanceRun(
|
|
type: '0-60',
|
|
timeMs: 3210,
|
|
topSpeedReached: 62,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
);
|
|
final zipBytes = _telemetryZip(samples: 2);
|
|
final client = _downloadClient(zipBytes);
|
|
final restore = service(store: store, client: client);
|
|
|
|
final first = await restore.restore();
|
|
final second = await restore.restore();
|
|
|
|
final rides = await db.select(db.rides).get();
|
|
final samples = await db.select(db.telemetrySamples).get();
|
|
|
|
expect(first.cloudRidesDiscovered, 1);
|
|
expect(first.metadataRowsAdded, 1);
|
|
expect(first.telemetryRidesRestored, 1);
|
|
expect(first.telemetrySamplesRestored, 2);
|
|
expect(first.performanceRunsRestored, 1);
|
|
expect(rides, hasLength(1));
|
|
expect(rides.single.cloudRideId, 'ride_clean_install');
|
|
expect(rides.single.movingTime, 600);
|
|
expect(rides.single.stoppedTime, 45);
|
|
expect(samples, hasLength(2));
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
expect(runs.single.rideId, rides.single.id);
|
|
expect(runs.single.type, '0-60');
|
|
expect(runs.single.timeMs, 3210);
|
|
expect(second.metadataRowsAdded, 0);
|
|
expect(second.telemetrySamplesRestored, 0);
|
|
expect(second.telemetryAlreadyPresent, 1);
|
|
expect(second.performanceRunsRestored, 0);
|
|
expect(second.performanceRunsAlreadyPresent, 1);
|
|
expect(await db.select(db.rides).get(), hasLength(1));
|
|
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
|
|
expect(await db.select(db.performanceRuns).get(), hasLength(1));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'signed out and anonymous users cannot mutate local restore data',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
documentsForEveryUser: [
|
|
_cloudRide(id: 'ride_auth_guard', ownerId: 'user_a'),
|
|
],
|
|
);
|
|
final restore = service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip()),
|
|
);
|
|
|
|
auth.setUser(null);
|
|
await expectLater(restore.restore(), throwsA(isA<StateError>()));
|
|
expect(await db.select(db.rides).get(), isEmpty);
|
|
|
|
auth.setUser(const AuthUser(uid: 'anon_1', isAnonymous: true));
|
|
await expectLater(restore.restore(), throwsA(isA<StateError>()));
|
|
expect(await db.select(db.rides).get(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'missing R2 object keeps additive metadata but restores no telemetry',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [_cloudRide(id: 'ride_missing_r2', ownerId: 'user_a')],
|
|
},
|
|
);
|
|
final restore = service(
|
|
store: store,
|
|
client: MockClient((request) async {
|
|
r2Requests++;
|
|
return http.Response('missing', 404);
|
|
}),
|
|
);
|
|
|
|
final result = await restore.restore();
|
|
|
|
expect(result.metadataRowsAdded, 1);
|
|
expect(result.telemetryFailed, 1);
|
|
expect(r2Requests, 1);
|
|
expect(await db.select(db.rides).get(), hasLength(1));
|
|
expect(await db.select(db.telemetrySamples).get(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('SHA mismatch does not insert telemetry', () async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [_cloudRide(id: 'ride_bad_sha', ownerId: 'user_a')],
|
|
},
|
|
);
|
|
final body = _telemetryZip(samples: 1);
|
|
final restore = service(
|
|
store: store,
|
|
client: MockClient(
|
|
(request) async => http.Response.bytes(
|
|
body,
|
|
200,
|
|
headers: {
|
|
'x-motorsignal-ride-id': request.url.queryParameters['rideId']!,
|
|
'x-motorsignal-sha256': '0' * 64,
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
final result = await restore.restore();
|
|
|
|
expect(result.metadataRowsAdded, 1);
|
|
expect(result.telemetryFailed, 1);
|
|
expect(await db.select(db.telemetrySamples).get(), isEmpty);
|
|
});
|
|
|
|
test('R2 authentication failures are reported separately', () async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [_cloudRide(id: 'ride_auth_r2', ownerId: 'user_a')],
|
|
},
|
|
);
|
|
final restore = service(
|
|
store: store,
|
|
client: MockClient(
|
|
(request) async => http.Response('auth required', 401),
|
|
),
|
|
);
|
|
|
|
final result = await restore.restore();
|
|
|
|
expect(result.metadataRowsAdded, 1);
|
|
expect(result.telemetryAuthFailures, 1);
|
|
expect(result.requiresAuthentication, isTrue);
|
|
expect(await db.select(db.telemetrySamples).get(), isEmpty);
|
|
});
|
|
|
|
test(
|
|
'existing local telemetry is never overwritten or downloaded again',
|
|
() async {
|
|
final rideId = await db
|
|
.into(db.rides)
|
|
.insert(
|
|
RidesCompanion.insert(
|
|
cloudRideId: const drift.Value('ride_existing_local'),
|
|
startTime: DateTime.utc(2026, 9, 3, 10),
|
|
),
|
|
);
|
|
await db
|
|
.into(db.telemetrySamples)
|
|
.insert(
|
|
TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: DateTime.utc(2026, 9, 3, 10),
|
|
speed: const drift.Value(31),
|
|
),
|
|
);
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
_cloudRide(id: 'ride_existing_local', ownerId: 'user_a'),
|
|
],
|
|
},
|
|
);
|
|
final restore = service(
|
|
store: store,
|
|
client: MockClient((request) async {
|
|
r2Requests++;
|
|
return http.Response.bytes(_telemetryZip(), 200);
|
|
}),
|
|
);
|
|
|
|
final result = await restore.restore();
|
|
final samples = await db.select(db.telemetrySamples).get();
|
|
|
|
expect(result.telemetryAlreadyPresent, 1);
|
|
expect(r2Requests, 0);
|
|
expect(samples, hasLength(1));
|
|
expect(samples.single.speed, 31);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'two cloud rides restore performance runs without cross-association',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
_cloudRide(
|
|
id: 'ride_child_a',
|
|
ownerId: 'user_a',
|
|
performanceRuns: [
|
|
_performanceRun(
|
|
type: '0-60',
|
|
timeMs: 3000,
|
|
topSpeedReached: 61,
|
|
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
|
|
),
|
|
],
|
|
),
|
|
_cloudRide(
|
|
id: 'ride_child_b',
|
|
ownerId: 'user_a',
|
|
performanceRuns: [
|
|
_performanceRun(
|
|
type: '0-100',
|
|
timeMs: 6400,
|
|
topSpeedReached: 102,
|
|
timestamp: DateTime.utc(2026, 9, 3, 11, 1),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
);
|
|
|
|
final result = await service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip()),
|
|
).restore();
|
|
|
|
final rides = await db.select(db.rides).get();
|
|
final rideA = rides.singleWhere(
|
|
(ride) => ride.cloudRideId == 'ride_child_a',
|
|
);
|
|
final rideB = rides.singleWhere(
|
|
(ride) => ride.cloudRideId == 'ride_child_b',
|
|
);
|
|
final rideARuns = await (db.select(
|
|
db.performanceRuns,
|
|
)..where((run) => run.rideId.equals(rideA.id))).get();
|
|
final rideBRuns = await (db.select(
|
|
db.performanceRuns,
|
|
)..where((run) => run.rideId.equals(rideB.id))).get();
|
|
|
|
expect(result.performanceRunsRestored, 2);
|
|
expect(rideARuns.single.type, '0-60');
|
|
expect(rideBRuns.single.type, '0-100');
|
|
expect(rideARuns.single.rideId, isNot(rideBRuns.single.rideId));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'existing local performance and ride events are not overwritten or duplicated',
|
|
() async {
|
|
final rideId = await db
|
|
.into(db.rides)
|
|
.insert(
|
|
RidesCompanion.insert(
|
|
cloudRideId: const drift.Value('ride_existing_children'),
|
|
startTime: DateTime.utc(2026, 9, 3, 10),
|
|
),
|
|
);
|
|
await db
|
|
.into(db.performanceRuns)
|
|
.insert(
|
|
PerformanceRunsCompanion.insert(
|
|
rideId: drift.Value(rideId),
|
|
type: '0-60',
|
|
timeMs: 3500,
|
|
topSpeedReached: 63,
|
|
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
|
|
),
|
|
);
|
|
await db
|
|
.into(db.rideEvents)
|
|
.insert(
|
|
RideEventsCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: DateTime.utc(2026, 9, 3, 10, 2),
|
|
eventType: 'Hard Braking',
|
|
value: -0.9,
|
|
),
|
|
);
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
_cloudRide(
|
|
id: 'ride_existing_children',
|
|
ownerId: 'user_a',
|
|
totalEventsCount: 1,
|
|
performanceRuns: [
|
|
_performanceRun(
|
|
type: '0-60',
|
|
timeMs: 3500,
|
|
topSpeedReached: 63,
|
|
timestamp: DateTime.utc(2026, 9, 3, 10, 1),
|
|
),
|
|
_performanceRun(
|
|
type: '0-100',
|
|
timeMs: 7000,
|
|
topSpeedReached: 101,
|
|
timestamp: DateTime.utc(2026, 9, 3, 10, 3),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
},
|
|
);
|
|
|
|
final result = await service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip()),
|
|
).restore();
|
|
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
final events = await db.select(db.rideEvents).get();
|
|
expect(result.performanceRunsRestored, 1);
|
|
expect(result.performanceRunsAlreadyPresent, 1);
|
|
expect(result.rideEventsAlreadyPresent, 1);
|
|
expect(runs, hasLength(2));
|
|
expect(events, hasLength(1));
|
|
expect(events.single.eventType, 'Hard Braking');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'malformed performance data does not destroy restored telemetry',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
_cloudRide(
|
|
id: 'ride_bad_performance',
|
|
ownerId: 'user_a',
|
|
performanceRuns: [
|
|
{'type': '0-60', 'timeMs': 'bad'},
|
|
],
|
|
),
|
|
],
|
|
},
|
|
);
|
|
|
|
final result = await service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip(samples: 2)),
|
|
).restore();
|
|
|
|
expect(result.telemetrySamplesRestored, 2);
|
|
expect(result.performanceRunsSkipped, 1);
|
|
expect(await db.select(db.telemetrySamples).get(), hasLength(2));
|
|
expect(await db.select(db.performanceRuns).get(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'canonical ride events are not fabricated from aggregate cloud counts',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [
|
|
_cloudRide(
|
|
id: 'ride_event_gap',
|
|
ownerId: 'user_a',
|
|
totalEventsCount: 3,
|
|
),
|
|
],
|
|
},
|
|
);
|
|
|
|
final result = await service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip(samples: 1)),
|
|
).restore();
|
|
|
|
expect(result.telemetrySamplesRestored, 1);
|
|
expect(result.rideEventsUnavailable, 3);
|
|
expect(await db.select(db.rideEvents).get(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'legacy cloud bundle without child data still restores ride and telemetry',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
byUid: {
|
|
'user_a': [_cloudRide(id: 'ride_legacy_bundle', ownerId: 'user_a')],
|
|
},
|
|
);
|
|
|
|
final result = await service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip(samples: 1)),
|
|
).restore();
|
|
|
|
expect(result.metadataRowsAdded, 1);
|
|
expect(result.telemetrySamplesRestored, 1);
|
|
expect(result.performanceRunsRestored, 0);
|
|
expect(result.rideEventsRestored, 0);
|
|
expect(await db.select(db.rides).get(), hasLength(1));
|
|
expect(await db.select(db.telemetrySamples).get(), hasLength(1));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'wrong-owner cloud metadata is skipped for the current account',
|
|
() async {
|
|
final store = _MemoryRideMetadataStore(
|
|
documentsForEveryUser: [
|
|
_cloudRide(id: 'ride_user_a_only', ownerId: 'user_a'),
|
|
],
|
|
);
|
|
auth.setUser(const AuthUser(uid: 'user_b', email: 'b@test.com'));
|
|
|
|
final result = await service(
|
|
store: store,
|
|
client: _downloadClient(_telemetryZip()),
|
|
).restore();
|
|
|
|
expect(result.cloudRidesDiscovered, 1);
|
|
expect(result.metadata.skipped, 1);
|
|
expect(await db.select(db.rides).get(), isEmpty);
|
|
expect(await db.select(db.telemetrySamples).get(), isEmpty);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'controller reports authentication required without invoking restore',
|
|
() async {
|
|
auth.setUser(null);
|
|
final controller = CloudRestoreController(
|
|
authService: auth,
|
|
restoreService: service(
|
|
store: _MemoryRideMetadataStore(
|
|
documentsForEveryUser: [
|
|
_cloudRide(id: 'ride_controller', ownerId: 'user_a'),
|
|
],
|
|
),
|
|
client: _downloadClient(_telemetryZip()),
|
|
),
|
|
);
|
|
|
|
final result = await controller.restoreFromMotorCloud();
|
|
|
|
expect(result, isNull);
|
|
expect(controller.state.status, CloudRestoreStatus.authRequired);
|
|
expect(await db.select(db.rides).get(), isEmpty);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
CloudRideMetadataDocument _cloudRide({
|
|
required String id,
|
|
required String ownerId,
|
|
int movingTime = 120,
|
|
int stoppedTime = 10,
|
|
List<Map<String, dynamic>>? performanceRuns,
|
|
int totalEventsCount = 0,
|
|
}) {
|
|
return CloudRideMetadataDocument(
|
|
id: id,
|
|
data: {
|
|
'id': id,
|
|
'ownerId': ownerId,
|
|
'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(),
|
|
'endTime': DateTime.utc(2026, 9, 3, 10, 12).toIso8601String(),
|
|
'distanceMiles': 4.2,
|
|
'topSpeedMph': 62.5,
|
|
'maxLeanLeft': 12.0,
|
|
'maxLeanRight': 14.0,
|
|
'maxAccelG': 0.4,
|
|
'maxBrakingG': -0.6,
|
|
'averageSpeed': 35.0,
|
|
'movingTime': movingTime,
|
|
'stoppedTime': stoppedTime,
|
|
'elevationGain': 50.0,
|
|
'elevationLoss': 20.0,
|
|
'motorcycleId': 7,
|
|
'motorcycleYear': 2024,
|
|
'motorcycleMake': 'Yamaha',
|
|
'motorcycleModel': 'MT-09',
|
|
'motorcycleVariant': 'SP',
|
|
'totalEventsCount': totalEventsCount,
|
|
'performanceRuns': ?performanceRuns,
|
|
'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip',
|
|
},
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _performanceRun({
|
|
required String type,
|
|
required double timeMs,
|
|
required double topSpeedReached,
|
|
DateTime? timestamp,
|
|
}) {
|
|
return {
|
|
'type': type,
|
|
'timeMs': timeMs,
|
|
'topSpeedReached': topSpeedReached,
|
|
'timestamp': (timestamp ?? DateTime.utc(2026, 9, 3, 10, 1))
|
|
.toIso8601String(),
|
|
};
|
|
}
|
|
|
|
MockClient _downloadClient(List<int> zipBytes) {
|
|
return MockClient((request) async {
|
|
final rideId = request.url.queryParameters['rideId']!;
|
|
return http.Response.bytes(
|
|
zipBytes,
|
|
200,
|
|
headers: {
|
|
'x-motorsignal-ride-id': rideId,
|
|
'x-motorsignal-sha256': sha256.convert(zipBytes).toString(),
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
Uint8List _telemetryZip({int samples = 1}) {
|
|
final csv = StringBuffer()
|
|
..writeln(
|
|
'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG',
|
|
);
|
|
for (var i = 0; i < samples; i++) {
|
|
csv.writeln(
|
|
'2026-09-03T10:00:0${i}Z,37.$i,-122.$i,10,${20 + i},90,5,10,1,0.1,0.2,0.3,0.01,0.02',
|
|
);
|
|
}
|
|
final bytes = utf8.encode(csv.toString());
|
|
final archive = Archive()
|
|
..addFile(ArchiveFile('telemetry.csv', bytes.length, bytes));
|
|
return Uint8List.fromList(
|
|
ZipEncoder().encode(archive, modified: DateTime.utc(1980, 1, 1)),
|
|
);
|
|
}
|