diff --git a/client/README.md b/client/README.md index 7227bfe..d140eea 100644 --- a/client/README.md +++ b/client/README.md @@ -1,7 +1,7 @@ # Cestovni — mobile client **Stack:** Flutter + Drift (ADR 003). -**Milestones:** M0 closed (CES-36, CES-37). **Android M1 closed** on `main` (`bb1d5d5`, 2026-08-16) — CES-38 math, CES-39 Log/History/vehicles, CES-57/65 prefs, CES-66 Metrics, CES-67 Maint, CES-40 photos. Next coding: **CES-41** export. See [`docs/product/delivery-plan-v1.md`](../docs/product/delivery-plan-v1.md). +**Milestones:** M0 closed (CES-36, CES-37). **Android M1 closed** on `main` (`bb1d5d5`, 2026-08-16) — CES-38 math, CES-39 Log/History/vehicles, CES-57/65 prefs, CES-66 Metrics, CES-67 Maint, CES-40 photos. **CES-41 export** on this branch. Next coding: **CES-70** import. See [`docs/product/delivery-plan-v1.md`](../docs/product/delivery-plan-v1.md). ## Quick start @@ -35,6 +35,7 @@ client/ theme/ # CES-55 visual system consumption/ # CES-38 math + validation photos/ # CES-40 receipt photo pipeline + export/ # CES-41 ZIP export (CSV + STORE zip + share) metrics/ # CES-66 aggregation maintenance/ # CES-67 date-only + history ledger db/ @@ -46,6 +47,7 @@ client/ app/ # log, history, settings, vehicle form widgets consumption/ # golden fixtures + module purity photos/ # EXIF strip, TTL, cleanup, no-upload invariant + export/ # ZIP golden, streaming, photos excluded db/ shell_smoke_test.dart ``` diff --git a/client/lib/app/pages/settings_page.dart b/client/lib/app/pages/settings_page.dart index 86dc16a..8a7f0db 100644 --- a/client/lib/app/pages/settings_page.dart +++ b/client/lib/app/pages/settings_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../db/app_database.dart'; import '../../db/repositories/settings_repository.dart'; +import '../../export/export_service.dart'; import '../active_vehicle.dart'; import '../theme/cestovni_primitives.dart'; import '../theme/cestovni_tokens.dart'; @@ -16,10 +17,13 @@ import 'vehicle_form_page.dart'; /// default vehicle) to [SettingsRepository]. Debug stays reachable /// from inside Settings until the rollback tooling lands (CES-50). class SettingsPage extends StatelessWidget { - const SettingsPage({super.key, required this.db}); + const SettingsPage({super.key, required this.db, this.onExport}); final AppDatabase db; + /// Test hook. Production leaves this null and uses [ExportService]. + final Future Function()? onExport; + @override Widget build(BuildContext context) { final colors = context.cestovniColors; @@ -43,6 +47,7 @@ class SettingsPage extends StatelessWidget { title: Text('Backup'), subtitle: Text('Offline — sign in lands in M3.'), ), + _ExportDataSection(db: db, onExport: onExport), const HairlineDivider(), const _SectionLabel(text: 'Developer'), ListTile( @@ -512,6 +517,97 @@ class _DefaultVehicleTile extends StatelessWidget { } } +/// CES-41 — Settings → Export data. Isolated [StatefulWidget] so +/// progress / error do not rebuild the rest of Settings. +class _ExportDataSection extends StatefulWidget { + const _ExportDataSection({required this.db, this.onExport}); + + final AppDatabase db; + final Future Function()? onExport; + + @override + State<_ExportDataSection> createState() => _ExportDataSectionState(); +} + +class _ExportDataSectionState extends State<_ExportDataSection> { + bool _busy = false; + String? _error; + + Future _run() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final hook = widget.onExport; + if (hook != null) { + await hook(); + } else { + await ExportService(db: widget.db).exportAndShare(); + } + } catch (_) { + if (mounted) { + setState(() => _error = 'Export failed. Try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.cestovniColors; + return Padding( + padding: const EdgeInsets.fromLTRB( + CestovniMetrics.pagePadding, + CestovniMetrics.tilePadding, + CestovniMetrics.pagePadding, + CestovniMetrics.tilePadding, + ), + child: LedgerTile( + onTap: _busy ? null : _run, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'EXPORT', + style: CestovniTypography.labelMono( + color: colors.mutedForeground, + ), + ), + const SizedBox(height: 6), + Text( + 'Export data', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Photos are not included.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.mutedForeground, + ), + ), + if (_busy) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(minHeight: 2), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.destructive, + ), + ), + ], + ], + ), + ), + ); + } +} + class _VehicleRow extends StatelessWidget { const _VehicleRow({ required this.db, diff --git a/client/lib/db/repositories/outbox_repository.dart b/client/lib/db/repositories/outbox_repository.dart index 5f608ab..b074ce9 100644 --- a/client/lib/db/repositories/outbox_repository.dart +++ b/client/lib/db/repositories/outbox_repository.dart @@ -175,6 +175,12 @@ class OutboxRepository { return query.watchSingle().map((row) => row.read(count) ?? 0); } + /// Every pending `mutation_id`, unsorted. Export hashes the sorted set. + Future> pendingMutationIds() async { + final rows = await _db.select(_db.outbox).get(); + return [for (final r in rows) r.mutationId]; + } + // ---------------------------------------------------------------- mutate /// Drop a row after the server returns `applied` or `duplicate`. diff --git a/client/lib/export/app_version.dart b/client/lib/export/app_version.dart new file mode 100644 index 0000000..94b91de --- /dev/null +++ b/client/lib/export/app_version.dart @@ -0,0 +1,9 @@ +/// App version string written into `manifest.json`. +/// +/// Locked decision 6: injected into the assembler so tests are +/// deterministic. Keep in lockstep with `client/pubspec.yaml` `version` +/// (the `+build` suffix is stripped — spec wants a semver string). +const String kAppVersion = '0.0.1'; + +/// Stage 1 export is Android-only (ADR 005 — PWA-lite has no export). +const String kExportAppPlatform = 'android'; diff --git a/client/lib/export/assembler.dart b/client/lib/export/assembler.dart new file mode 100644 index 0000000..9423ad2 --- /dev/null +++ b/client/lib/export/assembler.dart @@ -0,0 +1,93 @@ +/// Streams export tables into a [ZipSink] (CES-41). +/// +/// Pure of Flutter / `dart:io`. Takes already-filtered iterables so a +/// 1 000-row fill-up fixture can be a lazy generator. Each CSV row is +/// a separate [ZipSink.add] — that is the streaming invariant tests +/// assert. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import '../photos/photo_export_guard.dart'; +import 'csv.dart'; +import 'zip_sink.dart'; + +class ExportCsvTable { + const ExportCsvTable({ + required this.filename, + required this.header, + required this.rows, + }); + + final String filename; + final String header; + + /// Lazy. One list of fields per row, matching [header] order. + final Iterable> rows; +} + +/// Write [tables] plus [manifestJson] and [readmeText] into [sink]. +/// +/// Throws [StateError] if any entry name is inside the photo sandbox +/// (the assembler must never put `photos/` in the ZIP). +void assembleExportZip({ + required ZipSink sink, + required String manifestJson, + required String readmeText, + required List tables, +}) { + final written = []; + + void writeTextFile(String name, String text, {bool crlfAlready = false}) { + _start(sink, name, written); + final String body = crlfAlready ? text : text.replaceAll('\n', crlf); + // Chunk the body so a large README still does not land as one + // giant add — 512-byte slices are enough for the streaming test. + final Uint8List bytes = Uint8List.fromList(utf8.encode(body)); + _addInSlices(sink, bytes); + sink.closeFile(); + } + + writeTextFile('manifest.json', manifestJson); + writeTextFile('README_export.txt', readmeText, crlfAlready: true); + + for (final table in tables) { + _start(sink, table.filename, written); + sink.add(utf8Bom); + sink.add(csvHeaderBytes(table.header)); + for (final row in table.rows) { + sink.add(csvRowBytes(row)); + } + sink.closeFile(); + } + + sink.close(); + + final allowed = excludePhotoPaths(written); + if (allowed.length != written.length) { + throw StateError( + 'export assembler attempted to write a photo-sandbox path: $written', + ); + } +} + +void _start(ZipSink sink, String name, List written) { + if (isPhotoSandboxPath(name)) { + throw StateError('refusing to add photo path $name to export ZIP'); + } + sink.startFile(name); + written.add(name); +} + +void _addInSlices(ZipSink sink, Uint8List bytes) { + const int slice = 512; + if (bytes.length <= slice) { + sink.add(bytes); + return; + } + for (var i = 0; i < bytes.length; i += slice) { + final end = i + slice > bytes.length ? bytes.length : i + slice; + sink.add(bytes.sublist(i, end)); + } +} diff --git a/client/lib/export/crc32.dart b/client/lib/export/crc32.dart new file mode 100644 index 0000000..b4e8996 --- /dev/null +++ b/client/lib/export/crc32.dart @@ -0,0 +1,19 @@ +/// IEEE CRC-32 (ZIP / PNG polynomial 0xEDB88320). +/// +/// Used by the STORE zip writer so we do not pull `package:archive` +/// into the production write path. +library; + +int crc32Update(int crc, List bytes) { + var c = (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF; + for (final b in bytes) { + c ^= b & 0xFF; + for (var i = 0; i < 8; i++) { + c = (c & 1) == 1 ? ((c >> 1) ^ 0xEDB88320) : (c >> 1); + c &= 0xFFFFFFFF; + } + } + return (c ^ 0xFFFFFFFF) & 0xFFFFFFFF; +} + +int crc32(List bytes) => crc32Update(0, bytes); diff --git a/client/lib/export/csv.dart b/client/lib/export/csv.dart new file mode 100644 index 0000000..fbc2ea3 --- /dev/null +++ b/client/lib/export/csv.dart @@ -0,0 +1,38 @@ +/// RFC 4180 CSV helpers for CES-41. +/// +/// Spec: `docs/specs/export-v1.md` § CSV rules — UTF-8 with BOM, CRLF, +/// comma delimiter, empty field = null, booleans `true`/`false`. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +/// UTF-8 BOM bytes prepended to every CSV file. +final Uint8List utf8Bom = Uint8List.fromList(const [0xEF, 0xBB, 0xBF]); + +const String crlf = '\r\n'; + +/// Encode one field. [null] becomes empty; [bool] becomes `true`/`false`. +String csvField(Object? value) { + if (value == null) return ''; + final String raw = value is bool + ? (value ? 'true' : 'false') + : value.toString(); + final bool needsQuotes = raw.contains(',') || + raw.contains('"') || + raw.contains('\n') || + raw.contains('\r'); + if (!needsQuotes) return raw; + return '"${raw.replaceAll('"', '""')}"'; +} + +/// One CSV record including the trailing CRLF, UTF-8 encoded (no BOM). +Uint8List csvRowBytes(List fields) { + final String line = '${fields.map(csvField).join(',')}$crlf'; + return Uint8List.fromList(utf8.encode(line)); +} + +/// Header line including trailing CRLF, UTF-8 encoded (no BOM). +Uint8List csvHeaderBytes(String header) { + return Uint8List.fromList(utf8.encode('$header$crlf')); +} diff --git a/client/lib/export/derived.dart b/client/lib/export/derived.dart new file mode 100644 index 0000000..a81b941 --- /dev/null +++ b/client/lib/export/derived.dart @@ -0,0 +1,40 @@ +/// Canonical → CSV derived-column conversions (CES-41). +/// +/// Spec: `docs/specs/si-units.md` display rounding + `export-v1.md` § A2 +/// (both unit columns always ship). CSV values have **no** thousands +/// separators so spreadsheet tools parse them as numbers. +library; + +import '../consumption/rounding.dart'; +import '../units/display_units.dart'; + +/// Canonical meters → whole km (0 decimals, banker's). Null stays null. +String? metersToKmCsv(int? meters) => + meters == null ? null : metersToDisplayWhole(meters, 'km').toString(); + +/// Canonical meters → whole mi (0 decimals, banker's). Null stays null. +String? metersToMiCsv(int? meters) => + meters == null ? null : metersToDisplayWhole(meters, 'mi').toString(); + +/// Canonical µL → litres with 2 decimals, no grouping. +String? volumeToLitersCsv(int? microliters) => + microliters == null ? null : _volumeCsv(microliters, microlitersPerLiter); + +/// Canonical µL → US gallons with 2 decimals, no grouping. +String? volumeToGallonsCsv(int? microliters) => microliters == null + ? null + : _volumeCsv(microliters, microlitersPerUsGallon); + +/// Canonical cents → major units with 2 decimals, no grouping. +String centsToMajorCsv(int cents) { + final String sign = cents < 0 ? '-' : ''; + final int abs = cents.abs(); + return '$sign${abs ~/ 100}.${(abs % 100).toString().padLeft(2, '0')}'; +} + +String _volumeCsv(int microliters, int perUnit) { + final int scaled = divideRoundHalfEven(microliters * 100, perUnit); + final String sign = scaled < 0 ? '-' : ''; + final int abs = scaled.abs(); + return '$sign${abs ~/ 100}.${(abs % 100).toString().padLeft(2, '0')}'; +} diff --git a/client/lib/export/export_service.dart b/client/lib/export/export_service.dart new file mode 100644 index 0000000..5b5de05 --- /dev/null +++ b/client/lib/export/export_service.dart @@ -0,0 +1,130 @@ +/// Orchestrates flush → snapshot → atomic ZIP write → share (CES-41). +/// +/// Foreground-only (locked decision 5). Flush is best-effort: a network +/// failure does not fail the export; it shows up as `outbox_pending_count`. +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../db/app_database.dart'; +import '../db/repositories/outbox_repository.dart'; +import '../sync/outbox_flush_worker.dart'; +import '../sync/sync_client.dart'; +import '../sync/sync_config.dart'; +import 'app_version.dart'; +import 'snapshot.dart'; +import 'store_zip_sink.dart'; +import 'user_key_hash.dart'; +import 'zip_sink.dart'; + +typedef ShareZip = Future Function(String path); + +typedef ZipSinkFactory = ZipSink Function(File file); + +class ExportService { + ExportService({ + required this.db, + Directory Function()? sandboxDir, + this.flusher, + ShareZip? share, + ZipSinkFactory? zipSink, + this.appVersion = kAppVersion, + DateTime Function()? clock, + }) : _sandboxDir = sandboxDir, + _share = share, + _zipSink = zipSink, + _clock = clock ?? DateTime.now; + + final AppDatabase db; + final Directory Function()? _sandboxDir; + final OutboxFlushWorker? flusher; + final ShareZip? _share; + final ZipSinkFactory? _zipSink; + final String appVersion; + final DateTime Function() _clock; + + /// Default flusher against [SyncConfig.fromEnvironment], or null when + /// the stub URL is not configured (export still works offline). + static OutboxFlushWorker? defaultFlusher(AppDatabase db) { + const cfg = SyncConfig.fromEnvironment; + if (!cfg.isConfigured) return null; + return OutboxFlushWorker( + outbox: OutboxRepository(db), + client: SyncClient(baseUrl: cfg.baseUrl, bearerToken: cfg.bearerToken), + ); + } + + Future _dir() async { + final supplied = _sandboxDir; + if (supplied != null) return supplied(); + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory(p.join(docs.path, 'exports')); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + return dir; + } + + /// Write the ZIP and return the final file. Does not share. + Future exportToFile() async { + final worker = flusher ?? defaultFlusher(db); + if (worker != null) { + try { + await worker.flushOnce(); + } catch (_) { + // Best-effort. Pending count is recorded in the manifest. + } + } + + ExportSnapshot snapshot; + try { + snapshot = await takeExportSnapshot(db); + } catch (_) { + await Future.delayed(const Duration(milliseconds: 50)); + snapshot = await takeExportSnapshot(db); + } + + final exportedAt = _clock().toUtc(); + final hash = userKeyHashFromSettingsId(snapshot.settings.id); + final name = exportFilename(userKeyHash: hash, exportedAt: exportedAt); + final dir = await _dir(); + final tmp = File(p.join(dir.path, '$name.tmp')); + if (tmp.existsSync()) tmp.deleteSync(); + final dest = File(p.join(dir.path, name)); + if (dest.existsSync()) dest.deleteSync(); + + ZipSink? sink; + try { + sink = (_zipSink ?? FileZipSink.new)(tmp); + if (sink is FileZipSink) sink.stamp = exportedAt; + writeSnapshotToSink( + sink: sink, + snapshot: snapshot, + appVersion: appVersion, + exportedAt: exportedAt, + ); + tmp.renameSync(dest.path); + return dest; + } catch (e) { + sink?.abandon(); + if (tmp.existsSync()) tmp.deleteSync(); + if (dest.existsSync()) dest.deleteSync(); + rethrow; + } + } + + Future exportAndShare() async { + final file = await exportToFile(); + final share = _share; + if (share != null) { + await share(file.path); + } else { + await SharePlus.instance.share(ShareParams(files: [XFile(file.path)])); + } + return file; + } +} diff --git a/client/lib/export/headers.dart b/client/lib/export/headers.dart new file mode 100644 index 0000000..7db2b9a --- /dev/null +++ b/client/lib/export/headers.dart @@ -0,0 +1,31 @@ +/// Authoritative CSV headers for CES-41. +/// +/// Spec: `docs/specs/export-v1.md` § A1 (2026-08-16). Do not re-derive +/// from the 2026-04 body of that spec — these strings are the contract. +library; + +const String vehiclesCsvHeader = + 'id,user_key_hash,name,make,model,year,vin,fuel_type,tank_capacity_uL,tank_capacity_L,archived_at_utc,row_version,updated_at_utc'; + +const String fillUpsCsvHeader = + 'id,user_key_hash,vehicle_id,filled_at_utc,filled_at_local,odometer_m,odometer_km,odometer_mi,volume_uL,volume_L,volume_gal,total_price_cents,total_price_major,currency_code,is_full,missed_before,odometer_reset,notes,row_version,updated_at_utc'; + +const String maintenanceRulesCsvHeader = + 'id,user_key_hash,vehicle_id,name,cadence_km,cadence_days,enabled,notes,row_version,updated_at_utc'; + +const String maintenanceEventsCsvHeader = + 'id,user_key_hash,vehicle_id,rule_id,performed_at_utc,performed_at_local,odometer_m,odometer_km,odometer_mi,cost_cents,cost_major,currency_code,category,shop,notes,row_version,updated_at_utc'; + +const String settingsCsvHeader = + 'user_key_hash,preferred_distance_unit,preferred_volume_unit,currency_code,timezone,default_vehicle_id,row_version,updated_at_utc'; + +/// ZIP entry names in spec order. The assembler writes exactly this set. +const List exportZipEntryNames = [ + 'manifest.json', + 'README_export.txt', + 'vehicles.csv', + 'fill_ups.csv', + 'maintenance_rules.csv', + 'maintenance_events.csv', + 'settings.csv', +]; diff --git a/client/lib/export/manifest.dart b/client/lib/export/manifest.dart new file mode 100644 index 0000000..1e6d13c --- /dev/null +++ b/client/lib/export/manifest.dart @@ -0,0 +1,57 @@ +/// `manifest.json` builder (CES-41). +/// +/// Spec: `docs/specs/export-v1.md`. `photos_in_export` is the CES-40 +/// constant. `max_row_version_seen` is JSON `null` until M3. +library; + +import 'dart:convert'; + +import '../photos/photo_export_guard.dart'; + +const int exportSchemaVersion = 1; + +Map exportManifest({ + required String exportedAtUtc, + required String appVersion, + required String appPlatform, + required String timezone, + required String userKeyHash, + required String preferredDistanceUnit, + required String preferredVolumeUnit, + required String currencyCode, + required int vehiclesCount, + required int fillUpsCount, + required int maintenanceRulesCount, + required int maintenanceEventsCount, + required int settingsCount, + required int outboxPendingCount, + required String? outboxPendingHash, +}) { + return { + 'schema_version': exportSchemaVersion, + 'exported_at_utc': exportedAtUtc, + 'app_version': appVersion, + 'app_platform': appPlatform, + 'timezone': timezone, + 'user_key_hash': userKeyHash, + 'unit_preferences': { + 'distance': preferredDistanceUnit, + 'volume': preferredVolumeUnit, + 'currency': currencyCode, + }, + 'row_counts': { + 'vehicles': vehiclesCount, + 'fill_ups': fillUpsCount, + 'maintenance_rules': maintenanceRulesCount, + 'maintenance_events': maintenanceEventsCount, + 'settings': settingsCount, + }, + 'outbox_pending_count': outboxPendingCount, + 'outbox_pending_hash': outboxPendingHash, + 'photos_in_export': photosInExport, + 'max_row_version_seen': null, + }; +} + +String encodeManifest(Map manifest) => + '${const JsonEncoder.withIndent(' ').convert(manifest)}\n'; diff --git a/client/lib/export/readme.dart b/client/lib/export/readme.dart new file mode 100644 index 0000000..49f35a9 --- /dev/null +++ b/client/lib/export/readme.dart @@ -0,0 +1,80 @@ +/// `README_export.txt` template (CES-41). +/// +/// ASCII + CRLF. Amendments A2/A3/locked decision 6 are spelled out +/// so a spreadsheet user is not surprised by both unit columns, the +/// `cadence_km` name, or the `user_key_hash` stand-in. +library; + +import 'csv.dart'; + +String buildReadmeExport({ + required String exportedAtUtc, + required String preferredDistanceUnit, + required String preferredVolumeUnit, + required String currencyCode, + required String timezone, + required int outboxPendingCount, +}) { + final lines = [ + 'Cestovni export — created $exportedAtUtc', + '', + 'This archive contains a full copy of the structured data you have', + 'recorded in Cestovni for the account you exported from.', + '', + 'UNIT CONVENTIONS', + ' Distance canonical: meters (odometer_m)', + ' Distance display: $preferredDistanceUnit', + ' Derived columns odometer_km AND odometer_mi', + ' always ship (header does not depend on prefs).', + ' Volume canonical: microliters (volume_uL)', + ' Volume display: $preferredVolumeUnit', + ' Derived columns volume_L AND volume_gal always ship.', + ' Money canonical: integer cents (total_price_cents)', + ' Money display: $currencyCode (total_price_major)', + '', + 'DISPLAY ROUNDING', + ' Volume: 2 decimals', + ' Distance: 0 decimals', + ' L/100km: 1 decimal (not exported as a column; derived in-app)', + ' Prices: 2 decimals', + '', + 'CADENCE', + ' maintenance_rules.cadence_km stores canonical METERS despite the', + ' column name. Do not treat the value as kilometres. (CES-71 will', + ' rename the column; until then the header is cadence_km.)', + '', + 'RECEIPT PHOTOS', + ' Photos are stored only on your device with a 30-day time-to-live.', + ' They are NOT included in this export. This is by design.', + '', + 'TIMESTAMPS', + ' All *_utc columns are ISO-8601 UTC.', + ' All *_local columns use your preferred timezone ($timezone).', + ' IANA zones other than UTC currently use the device offset', + ' (no timezone database on the client yet).', + '', + 'USER KEY HASH', + ' user_key_hash is the first 8 hex characters of SHA-256 over the', + ' local settings.id. Telemetry (CES-46) is not wired; this is a', + ' stable stand-in, not the eventual telemetry user key.', + '', + 'RE-IMPORT', + ' The CANONICAL columns (odometer_m, volume_uL, total_price_cents)', + ' are the source of truth. Derived columns (odometer_km, odometer_mi,', + ' volume_L, volume_gal, total_price_major) are provided for convenience', + ' only and may lose precision after multiple open/save cycles in a', + ' spreadsheet. In-app re-import is CES-70 and is not in this ZIP.', + '', + 'OUTBOX STATUS', + ' outbox_pending_count = $outboxPendingCount', + ' If > 0, some mutations had not yet been saved to the server at', + ' the time of export. The data in the CSVs still reflects your', + ' local state at export time.', + '', + 'ROW VERSION', + ' row_version cells are empty and manifest max_row_version_seen is', + ' null until the backup server assigns versions (M3). Do not invent', + ' a number.', + ]; + return lines.join(crlf) + crlf; +} diff --git a/client/lib/export/snapshot.dart b/client/lib/export/snapshot.dart new file mode 100644 index 0000000..f4a3eb7 --- /dev/null +++ b/client/lib/export/snapshot.dart @@ -0,0 +1,253 @@ +/// Maps Drift rows → CSV field lists and runs the CES-41 assembler. +/// +/// Drift / outbox / IO live here. The ZIP bytes themselves are produced +/// by the pure assembler + [ZipSink]. +library; + +import 'package:drift/drift.dart'; + +import '../db/app_database.dart'; +import '../db/repositories/outbox_repository.dart'; +import '../db/repositories/settings_repository.dart'; +import '../photos/photo_export_guard.dart'; +import 'app_version.dart'; +import 'assembler.dart'; +import 'derived.dart'; +import 'headers.dart'; +import 'manifest.dart'; +import 'readme.dart'; +import 'timestamps.dart'; +import 'user_key_hash.dart'; +import 'zip_sink.dart'; + +class ExportSnapshot { + ExportSnapshot({ + required this.settings, + required this.vehicles, + required this.fillUps, + required this.maintenanceRules, + required this.maintenanceEvents, + required this.pendingMutationIds, + }); + + final SettingsRow settings; + final List vehicles; + final List fillUps; + final List maintenanceRules; + final List maintenanceEvents; + final List pendingMutationIds; +} + +/// Read-consistent snapshot of live rows (soft-deleted excluded). +/// +/// Spec wants `BEGIN IMMEDIATE`; we use Drift's [AppDatabase.transaction] +/// instead of a raw `BEGIN` so we do not nest against Drift's executor. +/// Archived vehicles (`archived_at` set, `deleted_at` null) **are** +/// exported — they are still the user's history. +Future takeExportSnapshot( + AppDatabase db, { + OutboxRepository? outbox, +}) async { + final box = outbox ?? OutboxRepository(db); + return db.transaction(() async { + final settings = await SettingsRepository(db).getOrBootstrap(); + final vehicles = await (db.select(db.vehicles) + ..where((v) => v.deletedAt.isNull()) + ..orderBy([(v) => OrderingTerm.asc(v.id)])) + .get(); + final fillUps = await (db.select(db.fillUps) + ..where((f) => f.deletedAt.isNull()) + ..orderBy([(f) => OrderingTerm.asc(f.id)])) + .get(); + final rules = await (db.select(db.maintenanceRules) + ..where((r) => r.deletedAt.isNull()) + ..orderBy([(r) => OrderingTerm.asc(r.id)])) + .get(); + final events = await (db.select(db.maintenanceEvents) + ..where((e) => e.deletedAt.isNull()) + ..orderBy([(e) => OrderingTerm.asc(e.id)])) + .get(); + final pending = await box.pendingMutationIds(); + return ExportSnapshot( + settings: settings, + vehicles: vehicles, + fillUps: fillUps, + maintenanceRules: rules, + maintenanceEvents: events, + pendingMutationIds: pending, + ); + }); +} + +void writeSnapshotToSink({ + required ZipSink sink, + required ExportSnapshot snapshot, + required String appVersion, + required DateTime exportedAt, + String appPlatform = kExportAppPlatform, +}) { + final settings = snapshot.settings; + final hash = userKeyHashFromSettingsId(settings.id); + final tz = settings.timezone; + final exportedAtUtc = formatExportedAt(exportedAt); + final pending = snapshot.pendingMutationIds; + final pendingCount = pending.length; + final pendingHash = outboxPendingHash(pending); + + final manifest = exportManifest( + exportedAtUtc: exportedAtUtc, + appVersion: appVersion, + appPlatform: appPlatform, + timezone: tz, + userKeyHash: hash, + preferredDistanceUnit: settings.preferredDistanceUnit, + preferredVolumeUnit: settings.preferredVolumeUnit, + currencyCode: settings.currencyCode, + vehiclesCount: snapshot.vehicles.length, + fillUpsCount: snapshot.fillUps.length, + maintenanceRulesCount: snapshot.maintenanceRules.length, + settingsCount: 1, + maintenanceEventsCount: snapshot.maintenanceEvents.length, + outboxPendingCount: pendingCount, + outboxPendingHash: pendingHash, + ); + + assembleExportZip( + sink: sink, + manifestJson: encodeManifest(manifest), + readmeText: buildReadmeExport( + exportedAtUtc: exportedAtUtc, + preferredDistanceUnit: settings.preferredDistanceUnit, + preferredVolumeUnit: settings.preferredVolumeUnit, + currencyCode: settings.currencyCode, + timezone: tz, + outboxPendingCount: pendingCount, + ), + tables: [ + ExportCsvTable( + filename: 'vehicles.csv', + header: vehiclesCsvHeader, + rows: snapshot.vehicles.map((v) => vehicleCsvRow(v, hash)), + ), + ExportCsvTable( + filename: 'fill_ups.csv', + header: fillUpsCsvHeader, + rows: snapshot.fillUps.map((f) => fillUpCsvRow(f, hash, tz)), + ), + ExportCsvTable( + filename: 'maintenance_rules.csv', + header: maintenanceRulesCsvHeader, + rows: snapshot.maintenanceRules.map((r) => ruleCsvRow(r, hash)), + ), + ExportCsvTable( + filename: 'maintenance_events.csv', + header: maintenanceEventsCsvHeader, + rows: snapshot.maintenanceEvents.map((e) => eventCsvRow(e, hash, tz)), + ), + ExportCsvTable( + filename: 'settings.csv', + header: settingsCsvHeader, + rows: [settingsCsvRow(settings, hash)], + ), + ], + ); + + final guarded = excludePhotoPaths(sink.fileNames); + if (guarded.length != sink.fileNames.length) { + throw StateError('photo path leaked into export file list'); + } +} + +List vehicleCsvRow(VehicleRow v, String hash) => [ + v.id, + hash, + v.name, + v.make, + v.model, + v.year, + v.vin, + v.fuelType, + v.tankCapacityUL, + volumeToLitersCsv(v.tankCapacityUL), + v.archivedAt == null ? null : formatUtcIso(v.archivedAt!), + v.rowVersion, // null → empty (locked decision 6) + formatUtcIso(v.updatedAt), + ]; + +List fillUpCsvRow(FillUpRow f, String hash, String tz) => [ + f.id, + hash, + f.vehicleId, + formatUtcIso(f.filledAt), + formatLocalIso(f.filledAt, tz), + f.odometerM, + metersToKmCsv(f.odometerM), + metersToMiCsv(f.odometerM), + f.volumeUL, + volumeToLitersCsv(f.volumeUL), + volumeToGallonsCsv(f.volumeUL), + f.totalPriceCents, + centsToMajorCsv(f.totalPriceCents), + f.currencyCode, + f.isFull, + f.missedBefore, + f.odometerReset, + f.notes, + f.rowVersion, + formatUtcIso(f.updatedAt), + ]; + +List ruleCsvRow(MaintenanceRuleRow r, String hash) => [ + r.id, + hash, + r.vehicleId, + r.name, + r.cadenceKm, // meters, despite the name (A3) + r.cadenceDays, + r.enabled, + r.notes, + r.rowVersion, + formatUtcIso(r.updatedAt), + ]; + +List eventCsvRow( + MaintenanceEventRow e, + String hash, + String tz, +) => + [ + e.id, + hash, + e.vehicleId, + e.ruleId, + formatUtcIso(e.performedAt), + formatLocalIso(e.performedAt, tz), + e.odometerM, + metersToKmCsv(e.odometerM), + metersToMiCsv(e.odometerM), + e.costCents, + centsToMajorCsv(e.costCents), + e.currencyCode, + e.category, + e.shop, + e.notes, + e.rowVersion, + formatUtcIso(e.updatedAt), + ]; + +List settingsCsvRow(SettingsRow s, String hash) => [ + hash, + s.preferredDistanceUnit, + s.preferredVolumeUnit, + s.currencyCode, + s.timezone, + s.defaultVehicleId, + s.rowVersion, + formatUtcIso(s.updatedAt), + ]; + +String exportFilename({ + required String userKeyHash, + required DateTime exportedAt, +}) => + 'cestovni_export_${userKeyHash}_${formatFilenameTimestamp(exportedAt)}.zip'; diff --git a/client/lib/export/store_zip_sink.dart b/client/lib/export/store_zip_sink.dart new file mode 100644 index 0000000..2ec2096 --- /dev/null +++ b/client/lib/export/store_zip_sink.dart @@ -0,0 +1,205 @@ +/// STORE-method ZIP writer (no compression) that streams to a file. +/// +/// Spec: `docs/specs/export-v1.md` assembly pipeline — row-by-row into +/// a ZIP backed by a temp file. Uses the data-descriptor bit so CRC +/// and sizes are written after each entry rather than buffering it. +/// +/// `dart:io` bridge — keep out of the pure export files. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'crc32.dart'; +import 'zip_sink.dart'; + +class FileZipSink implements ZipSink { + FileZipSink(File file) : _raf = file.openSync(mode: FileMode.write); + + final RandomAccessFile _raf; + final List<_Central> _central = <_Central>[]; + final List _names = []; + bool _closed = false; + + String? _openName; + int _openLocalOffset = 0; + int _openSize = 0; + int _openCrc = 0; + DateTime _stamp = DateTime.now().toUtc(); + + /// Override the DOS timestamp (tests). + set stamp(DateTime utc) => _stamp = utc.toUtc(); + + @override + void startFile(String name) { + if (_openName != null) throw StateError('file already open'); + _openName = name; + _names.add(name); + _openLocalOffset = _raf.positionSync(); + _openSize = 0; + _openCrc = 0; + _raf.writeFromSync(_localHeader(name, _stamp)); + } + + @override + void add(List bytes) { + if (_openName == null) throw StateError('add without startFile'); + if (bytes.isEmpty) return; + _openCrc = crc32Update(_openCrc, bytes); + _openSize += bytes.length; + _raf.writeFromSync(bytes is Uint8List ? bytes : Uint8List.fromList(bytes)); + } + + @override + void closeFile() { + final name = _openName; + if (name == null) throw StateError('closeFile without startFile'); + _raf.writeFromSync(_dataDescriptor(_openCrc, _openSize)); + _central.add(_Central( + name: name, + localOffset: _openLocalOffset, + crc: _openCrc, + size: _openSize, + stamp: _stamp, + )); + _openName = null; + } + + @override + void close() { + if (_closed) return; + if (_openName != null) throw StateError('close with a file still open'); + final int cdStart = _raf.positionSync(); + for (final e in _central) { + _raf.writeFromSync(_centralHeader(e)); + } + final int cdSize = _raf.positionSync() - cdStart; + _raf.writeFromSync(_eocd( + entries: _central.length, + cdSize: cdSize, + cdOffset: cdStart, + )); + _raf.flushSync(); + _raf.closeSync(); + _closed = true; + } + + @override + void abandon() { + if (_closed) return; + _openName = null; + try { + _raf.closeSync(); + } catch (_) {} + _closed = true; + } + + @override + List get fileNames => List.unmodifiable(_names); +} + +class _Central { + _Central({ + required this.name, + required this.localOffset, + required this.crc, + required this.size, + required this.stamp, + }); + + final String name; + final int localOffset; + final int crc; + final int size; + final DateTime stamp; +} + +Uint8List _localHeader(String name, DateTime stamp) { + final nameBytes = utf8.encode(name); + final dos = _dosDateTime(stamp); + final b = BytesBuilder(copy: false); + _u32(b, 0x04034b50); + _u16(b, 20); // version needed + _u16(b, 0x0008); // data descriptor + _u16(b, 0); // store + _u16(b, dos.time); + _u16(b, dos.date); + _u32(b, 0); // crc placeholder + _u32(b, 0); + _u32(b, 0); + _u16(b, nameBytes.length); + _u16(b, 0); + b.add(nameBytes); + return b.takeBytes(); +} + +Uint8List _dataDescriptor(int crc, int size) { + final b = BytesBuilder(copy: false); + _u32(b, 0x08074b50); + _u32(b, crc); + _u32(b, size); + _u32(b, size); + return b.takeBytes(); +} + +Uint8List _centralHeader(_Central e) { + final nameBytes = utf8.encode(e.name); + final dos = _dosDateTime(e.stamp); + final b = BytesBuilder(copy: false); + _u32(b, 0x02014b50); + _u16(b, 20); + _u16(b, 20); + _u16(b, 0x0008); + _u16(b, 0); + _u16(b, dos.time); + _u16(b, dos.date); + _u32(b, e.crc); + _u32(b, e.size); + _u32(b, e.size); + _u16(b, nameBytes.length); + _u16(b, 0); + _u16(b, 0); + _u16(b, 0); + _u16(b, 0); + _u32(b, 0); + _u32(b, e.localOffset); + b.add(nameBytes); + return b.takeBytes(); +} + +Uint8List _eocd({ + required int entries, + required int cdSize, + required int cdOffset, +}) { + final b = BytesBuilder(copy: false); + _u32(b, 0x06054b50); + _u16(b, 0); + _u16(b, 0); + _u16(b, entries); + _u16(b, entries); + _u32(b, cdSize); + _u32(b, cdOffset); + _u16(b, 0); + return b.takeBytes(); +} + +void _u16(BytesBuilder b, int v) { + b.addByte(v & 0xFF); + b.addByte((v >> 8) & 0xFF); +} + +void _u32(BytesBuilder b, int v) { + b.addByte(v & 0xFF); + b.addByte((v >> 8) & 0xFF); + b.addByte((v >> 16) & 0xFF); + b.addByte((v >> 24) & 0xFF); +} + +({int time, int date}) _dosDateTime(DateTime utc) { + final dt = utc.toUtc(); + final time = (dt.second ~/ 2) | (dt.minute << 5) | (dt.hour << 11); + final date = dt.day | (dt.month << 5) | ((dt.year - 1980) << 9); + return (time: time, date: date); +} diff --git a/client/lib/export/timestamps.dart b/client/lib/export/timestamps.dart new file mode 100644 index 0000000..f5ff7f8 --- /dev/null +++ b/client/lib/export/timestamps.dart @@ -0,0 +1,43 @@ +/// UTC / local timestamp formatting for export CSV columns. +/// +/// Spec: ISO-8601 UTC with `_utc` suffix; `_local` is civil time in +/// `settings.timezone`. There is no tz database on the client yet +/// (same approximation as CES-66): when the IANA name is UTC we emit +/// UTC civil time; otherwise we use the device offset. +library; + +/// Format a stored ISO timestamp as `YYYY-MM-DDTHH:MM:SSZ`. +String formatUtcIso(String stored) { + final DateTime dt = DateTime.parse(stored).toUtc(); + return '${_civil(dt)}Z'; +} + +/// Civil local time `YYYY-MM-DDTHH:MM:SS` (no offset) for [stored] +/// interpreted as UTC, converted via [ianaTimezone]. +String formatLocalIso(String stored, String ianaTimezone) { + final DateTime utc = DateTime.parse(stored).toUtc(); + final DateTime civil = _isUtc(ianaTimezone) ? utc : utc.toLocal(); + return _civil(civil); +} + +bool _isUtc(String tz) => + tz == 'UTC' || tz == 'Etc/UTC' || tz == 'Etc/GMT' || tz == 'GMT'; + +String _civil(DateTime dt) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${dt.year.toString().padLeft(4, '0')}-' + '${two(dt.month)}-${two(dt.day)}T' + '${two(dt.hour)}:${two(dt.minute)}:${two(dt.second)}'; +} + +/// `exported_at_utc` / filename timestamp: second-precision UTC. +String formatExportedAt(DateTime utc) => formatUtcIso(utc.toUtc().toIso8601String()); + +/// Filename stamp `YYYYMMDD_HHMMSS` in UTC. +String formatFilenameTimestamp(DateTime utc) { + final DateTime dt = utc.toUtc(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${dt.year.toString().padLeft(4, '0')}' + '${two(dt.month)}${two(dt.day)}_' + '${two(dt.hour)}${two(dt.minute)}${two(dt.second)}'; +} diff --git a/client/lib/export/user_key_hash.dart b/client/lib/export/user_key_hash.dart new file mode 100644 index 0000000..dec755b --- /dev/null +++ b/client/lib/export/user_key_hash.dart @@ -0,0 +1,23 @@ +/// Stand-in `user_key_hash` until CES-46 wires telemetry. +/// +/// Locked decision 6: first 8 hex chars of SHA-256 over `settings.id`. +/// Documented in `README_export.txt` so a later telemetry key does not +/// silently change the filename contract without a spec bump. +library; + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +String userKeyHashFromSettingsId(String settingsId) { + final digest = sha256.convert(utf8.encode(settingsId)); + return digest.toString().substring(0, 8); +} + +/// SHA-256 hex over the sorted pending `mutation_id`s, one per line. +/// Returns `null` when [sortedIds] is empty (spec: hash is null at count 0). +String? outboxPendingHash(Iterable sortedIds) { + final list = sortedIds.toList()..sort(); + if (list.isEmpty) return null; + return sha256.convert(utf8.encode(list.join('\n'))).toString(); +} diff --git a/client/lib/export/zip_sink.dart b/client/lib/export/zip_sink.dart new file mode 100644 index 0000000..6b9bdcb --- /dev/null +++ b/client/lib/export/zip_sink.dart @@ -0,0 +1,125 @@ +/// Streaming ZIP sink used by the CES-41 assembler. +/// +/// [add] is called once per small chunk (BOM, header, or a single CSV +/// row). A correct assembler never concatenates a whole table into one +/// [add]. Tests inject [CountingZipSink] / [MemoryZipSink]; production +/// uses the file-backed STORE writer. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +abstract class ZipSink { + /// Begin a new entry. [name] is the path inside the ZIP (POSIX). + void startFile(String name); + + /// Append [bytes] to the current entry. Must not retain [bytes] + /// after return — callers may reuse the buffer. + void add(List bytes); + + void closeFile(); + + void close(); + + /// Best-effort close without a valid ZIP. [FileZipSink] uses this so + /// a failed export can delete the `.tmp`. Default is a no-op. + void abandon() {} + + /// Entry names in write order. The assembler runs this list through + /// [excludePhotoPaths] as a last-line invariant. + List get fileNames; +} + +/// Records every [add] so tests can prove the assembler streams. +class CountingZipSink implements ZipSink { + final List chunkSizes = []; + int addCalls = 0; + int maxChunkBytes = 0; + int totalBytes = 0; + final List _names = []; + bool _open = false; + + @override + void startFile(String name) { + if (_open) { + throw StateError('closeFile before startFile($name)'); + } + _open = true; + _names.add(name); + } + + @override + void add(List bytes) { + if (!_open) throw StateError('add without startFile'); + addCalls++; + chunkSizes.add(bytes.length); + if (bytes.length > maxChunkBytes) maxChunkBytes = bytes.length; + totalBytes += bytes.length; + } + + @override + void closeFile() { + if (!_open) throw StateError('closeFile without startFile'); + _open = false; + } + + @override + void close() { + if (_open) throw StateError('close with a file still open'); + } + + @override + void abandon() {} + + @override + List get fileNames => List.unmodifiable(_names); +} + +/// Concatenates each entry in memory for golden assertions. +class MemoryZipSink implements ZipSink { + final Map _files = {}; + final List _order = []; + String? _current; + + @override + void startFile(String name) { + if (_current != null) throw StateError('file already open'); + _current = name; + _order.add(name); + _files[name] = BytesBuilder(copy: false); + } + + @override + void add(List bytes) { + final name = _current; + if (name == null) throw StateError('add without startFile'); + _files[name]!.add(bytes); + } + + @override + void closeFile() { + if (_current == null) throw StateError('closeFile without startFile'); + _current = null; + } + + @override + void close() { + if (_current != null) throw StateError('close with a file still open'); + } + + @override + void abandon() {} + + @override + List get fileNames => List.unmodifiable(_order); + + Uint8List bytesOf(String name) { + final builder = _files[name]; + if (builder == null) { + throw StateError('no entry $name'); + } + return Uint8List.fromList(builder.toBytes()); + } + + String utf8Of(String name) => utf8.decode(bytesOf(name)); +} diff --git a/client/lib/photos/photo_export_guard.dart b/client/lib/photos/photo_export_guard.dart index 6671f96..8b8bd26 100644 --- a/client/lib/photos/photo_export_guard.dart +++ b/client/lib/photos/photo_export_guard.dart @@ -5,9 +5,8 @@ /// `manifest.json` carries `photos_in_export: false` as a hard-coded /// assertion. /// -/// The ZIP export itself is CES-41 and is deliberately not implemented here. -/// This file exists so that when it lands there is exactly one place to call -/// rather than a fresh judgement call about which paths are safe to bundle. +/// CES-41 (`client/lib/export/`) calls [excludePhotoPaths] / [photosInExport] +/// rather than re-deciding which paths are safe to bundle. /// /// Pure module: no Flutter, no Drift, no file IO. library; diff --git a/client/pubspec.lock b/client/pubspec.lock index b33ddfe..05395e7 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -688,6 +688,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1 + url: "https://pub.dev" + source: hosted + version: "11.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shelf: dependency: transitive description: @@ -805,6 +821,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -861,6 +909,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 9a51338..89fe476 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -19,6 +19,8 @@ dependencies: path_provider: ^2.1.4 path: ^1.9.0 uuid: ^4.4.0 + # Share sheet for CES-41 ZIP export (sandbox file → user-chosen dest). + share_plus: ^11.0.0 # Visual system fonts (CES-55) — Fraunces (serif), Inter (sans), # JetBrains Mono (mono). Per cestovni-styling.md §2 / §14 with diff --git a/client/test/app/settings_page_test.dart b/client/test/app/settings_page_test.dart index 3fd6b93..3941102 100644 --- a/client/test/app/settings_page_test.dart +++ b/client/test/app/settings_page_test.dart @@ -340,6 +340,58 @@ void main() { await _drainAndClose(tester, db); }); }); + + group('CES-41 Export data', () { + testWidgets('Export data row is visible and photos disclaimer shows', + (tester) async { + final db = AppDatabase.withExecutor(NativeDatabase.memory()); + + await tester.pumpWidget(_host(SettingsPage(db: db))); + await tester.pump(); + await tester.pump(); + + await tester.scrollUntilVisible( + find.text('Export data'), + 200, + scrollable: find.byType(Scrollable).first, + ); + + expect(find.text('Export data'), findsOneWidget); + expect(find.text('Photos are not included.'), findsOneWidget); + + await _drainAndClose(tester, db); + }); + + testWidgets('tapping Export data calls the injected exporter', + (tester) async { + final db = AppDatabase.withExecutor(NativeDatabase.memory()); + var calls = 0; + + await tester.pumpWidget(_host( + SettingsPage( + db: db, + onExport: () async { + calls++; + }, + ), + )); + await tester.pump(); + await tester.pump(); + + await tester.scrollUntilVisible( + find.text('Export data'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('Export data')); + await tester.pump(); + await tester.pump(); + + expect(calls, 1); + + await _drainAndClose(tester, db); + }); + }); } Widget _host(Widget child, {ActiveVehicle? active}) { diff --git a/client/test/export/_seed.dart b/client/test/export/_seed.dart new file mode 100644 index 0000000..40f6a12 --- /dev/null +++ b/client/test/export/_seed.dart @@ -0,0 +1,86 @@ +import 'package:cestovni/db/app_database.dart'; +import 'package:cestovni/db/repositories/fill_ups_repository.dart'; +import 'package:cestovni/db/repositories/maintenance_events_repository.dart'; +import 'package:cestovni/db/repositories/settings_repository.dart'; +import 'package:cestovni/db/repositories/vehicles_repository.dart'; + +/// Known fixture used by the golden ZIP test. +class GoldenSeed { + GoldenSeed({ + required this.vehicleId, + required this.fillUpId, + required this.ruleId, + required this.eventId, + required this.settings, + }); + + final String vehicleId; + final String fillUpId; + final String ruleId; + final String eventId; + final SettingsRow settings; +} + +Future seedGoldenExport(AppDatabase db) async { + final settingsRepo = SettingsRepository(db); + await settingsRepo.getOrBootstrap(); + + final vehicleId = await VehiclesRepository(db).create( + const VehicleDraft( + name: 'Octavia', + fuelType: VehicleFuelType.gasoline, + make: 'Skoda', + model: 'Mk3', + year: 2018, + tankCapacityUL: 55000000, + ), + ); + + await settingsRepo.update(timezone: 'UTC', defaultVehicleId: vehicleId); + final settings = await settingsRepo.getOrBootstrap(); + + final fillUpId = await FillUpsRepository(db).create( + FillUpDraft( + vehicleId: vehicleId, + filledAt: DateTime.utc(2026, 8, 1, 10, 30, 0), + odometerM: 120000000, + volumeUL: 42000000, + totalPriceCents: 6100, + currencyCode: 'EUR', + isFull: true, + notes: 'hello, "world"', + ), + ); + + final maint = MaintenanceEventsRepository(db); + final ruleId = await maint.upsertReminderRule( + MaintenanceRuleDraft( + vehicleId: vehicleId, + name: 'oil', + cadenceKmMeters: 10000000, + cadenceDays: 365, + notes: 'every 10k km', + ), + ); + final eventId = await maint.create( + MaintenanceEventDraft( + vehicleId: vehicleId, + performedAt: DateTime.utc(2026, 7, 15, 12, 0, 0), + category: 'oil', + costCents: 8900, + currencyCode: 'EUR', + odometerM: 115000000, + shop: 'Bosch, Praha', + notes: 'filter too', + ruleId: ruleId, + ), + ); + + return GoldenSeed( + vehicleId: vehicleId, + fillUpId: fillUpId, + ruleId: ruleId, + eventId: eventId, + settings: settings, + ); +} diff --git a/client/test/export/assembler_test.dart b/client/test/export/assembler_test.dart new file mode 100644 index 0000000..44cde11 --- /dev/null +++ b/client/test/export/assembler_test.dart @@ -0,0 +1,132 @@ +import 'package:cestovni/export/assembler.dart'; +import 'package:cestovni/export/headers.dart'; +import 'package:cestovni/export/zip_sink.dart'; +import 'package:cestovni/photos/photo_export_guard.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('assembler streams ~1000 fill-up rows as many small adds', () { + final sink = CountingZipSink(); + assembleExportZip( + sink: sink, + manifestJson: '{"schema_version":1}\n', + readmeText: 'Cestovni export\r\n', + tables: [ + ExportCsvTable( + filename: 'vehicles.csv', + header: vehiclesCsvHeader, + rows: const [], + ), + ExportCsvTable( + filename: 'fill_ups.csv', + header: fillUpsCsvHeader, + rows: _lazyFillUps(1000), + ), + ExportCsvTable( + filename: 'maintenance_rules.csv', + header: maintenanceRulesCsvHeader, + rows: const [], + ), + ExportCsvTable( + filename: 'maintenance_events.csv', + header: maintenanceEventsCsvHeader, + rows: const [], + ), + ExportCsvTable( + filename: 'settings.csv', + header: settingsCsvHeader, + rows: const [], + ), + ], + ); + + expect(sink.fileNames, exportZipEntryNames); + expect( + sink.addCalls, + greaterThan(1000), + reason: 'one add per CSV row plus BOM/header/other files — never a ' + 'single concatenated table', + ); + expect( + sink.maxChunkBytes, + lessThanOrEqualTo(512), + reason: 'README slices at 512; a CSV row is smaller. A fully buffered ' + 'table would be tens of KB in one add.', + ); + expect(sink.maxChunkBytes, lessThan(sink.totalBytes)); + expect( + sink.chunkSizes.where((n) => n == sink.totalBytes), + isEmpty, + reason: 'no single add may be the entire ZIP payload', + ); + }); + + test('assembler refuses a photos/ entry name', () { + final sink = CountingZipSink(); + expect( + () => assembleExportZip( + sink: sink, + manifestJson: '{}', + readmeText: 'x\r\n', + tables: [ + ExportCsvTable( + filename: 'photos/receipt.jpg', + header: 'id', + rows: const [ + ['1'], + ], + ), + ], + ), + throwsA(isA()), + ); + }); + + test('assembled file list survives excludePhotoPaths unchanged', () { + final sink = MemoryZipSink(); + assembleExportZip( + sink: sink, + manifestJson: '{}', + readmeText: 'x\r\n', + tables: [ + for (final name in [ + 'vehicles.csv', + 'fill_ups.csv', + 'maintenance_rules.csv', + 'maintenance_events.csv', + 'settings.csv', + ]) + ExportCsvTable(filename: name, header: 'id', rows: const []), + ], + ); + expect(excludePhotoPaths(sink.fileNames), sink.fileNames); + expect(sink.fileNames.any(isPhotoSandboxPath), isFalse); + }); +} + +Iterable> _lazyFillUps(int count) sync* { + for (var i = 0; i < count; i++) { + yield [ + 'id-$i', + 'hashhash', + 'veh', + '2026-08-01T10:30:00Z', + '2026-08-01T10:30:00', + 1000 + i, + '1', + '1', + 1000, + '0.00', + '0.00', + 0, + '0.00', + 'EUR', + true, + false, + false, + null, + null, + '2026-08-01T10:30:00Z', + ]; + } +} diff --git a/client/test/export/atomicity_test.dart b/client/test/export/atomicity_test.dart new file mode 100644 index 0000000..f61a191 --- /dev/null +++ b/client/test/export/atomicity_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:cestovni/export/export_service.dart'; +import 'package:cestovni/export/store_zip_sink.dart'; +import 'package:cestovni/export/zip_sink.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../db/_harness.dart'; +import '_seed.dart'; + +void main() { + test('a mid-write failure leaves no .tmp and no final ZIP', () async { + final db = openInMemoryDb(); + addTearDown(db.close); + await seedGoldenExport(db); + + final dir = Directory.systemTemp.createTempSync('cestovni-export-atom-'); + addTearDown(() { + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + + final service = ExportService( + db: db, + sandboxDir: () => dir, + share: (_) async {}, + zipSink: (file) => _ThrowingSink(FileZipSink(file), throwAfterAdds: 6), + clock: () => DateTime.utc(2026, 8, 16, 12, 0, 0), + ); + + await expectLater(service.exportToFile(), throwsA(isA())); + + final leftovers = dir.listSync(); + expect( + leftovers, + isEmpty, + reason: 'atomicity: neither the .tmp nor the final ZIP may remain. ' + 'Found: $leftovers', + ); + }); +} + +class _ThrowingSink implements ZipSink { + _ThrowingSink(this._inner, {required this.throwAfterAdds}); + + final ZipSink _inner; + final int throwAfterAdds; + int _adds = 0; + + @override + void startFile(String name) => _inner.startFile(name); + + @override + void add(List bytes) { + _inner.add(bytes); + _adds++; + if (_adds >= throwAfterAdds) { + throw StateError('injected failure after $_adds adds'); + } + } + + @override + void closeFile() => _inner.closeFile(); + + @override + void close() => _inner.close(); + + @override + void abandon() => _inner.abandon(); + + @override + List get fileNames => _inner.fileNames; +} diff --git a/client/test/export/csv_test.dart b/client/test/export/csv_test.dart new file mode 100644 index 0000000..3e2c1e2 --- /dev/null +++ b/client/test/export/csv_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:cestovni/export/csv.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('null becomes an empty field', () { + expect(csvField(null), ''); + }); + + test('booleans are lowercase true/false', () { + expect(csvField(true), 'true'); + expect(csvField(false), 'false'); + }); + + test('RFC 4180 quoting for comma, quote, and newline', () { + expect(csvField('hello, world'), '"hello, world"'); + expect(csvField('say "hi"'), '"say ""hi"""'); + expect(csvField('line\nbreak'), '"line\nbreak"'); + expect(csvField('cr\r'), '"cr\r"'); + }); + + test('plain fields are unquoted', () { + expect(csvField('Octavia'), 'Octavia'); + expect(csvField(42), '42'); + }); + + test('row bytes are UTF-8 with CRLF and no BOM', () { + final bytes = csvRowBytes(['a', null, true]); + expect(bytes, isNot(equals(utf8Bom))); + expect(utf8.decode(bytes), 'a,,true\r\n'); + }); + + test('header bytes end in CRLF', () { + expect(utf8.decode(csvHeaderBytes('id,name')), 'id,name\r\n'); + }); + + test('UTF-8 BOM is the Excel-friendly three-byte prefix', () { + expect(utf8Bom, [0xEF, 0xBB, 0xBF]); + }); +} diff --git a/client/test/export/exclusions_test.dart b/client/test/export/exclusions_test.dart new file mode 100644 index 0000000..d44099e --- /dev/null +++ b/client/test/export/exclusions_test.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:cestovni/db/repositories/drafts_repository.dart'; +import 'package:cestovni/db/repositories/fill_ups_repository.dart'; +import 'package:cestovni/db/repositories/maintenance_events_repository.dart'; +import 'package:cestovni/db/repositories/photo_refs_repository.dart'; +import 'package:cestovni/db/repositories/vehicles_repository.dart'; +import 'package:cestovni/export/export_service.dart'; +import 'package:cestovni/export/headers.dart'; +import 'package:cestovni/export/snapshot.dart'; +import 'package:cestovni/export/zip_sink.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../db/_harness.dart'; +import '_seed.dart'; +import 'zip_read.dart'; + +void main() { + test('soft-deleted rows and drafts are absent from the ZIP', () async { + final db = openInMemoryDb(); + addTearDown(db.close); + final seed = await seedGoldenExport(db); + + await FillUpsRepository(db).softDelete(seed.fillUpId); + await MaintenanceEventsRepository(db).softDelete(seed.eventId); + await VehiclesRepository(db).softDelete(seed.vehicleId); + + final liveId = await VehiclesRepository(db).create( + const VehicleDraft(name: 'Live', fuelType: VehicleFuelType.diesel), + ); + await DraftsRepository(db).save( + DraftSnapshot( + vehicleId: liveId, + odometerM: 1, + notes: 'should-not-export', + ), + ); + + final snapshot = await takeExportSnapshot(db); + expect(snapshot.vehicles.map((v) => v.id), [liveId]); + expect(snapshot.fillUps, isEmpty); + expect(snapshot.maintenanceEvents, isEmpty); + + final mem = MemoryZipSink(); + writeSnapshotToSink( + sink: mem, + snapshot: snapshot, + appVersion: '0.0.1', + exportedAt: DateTime.utc(2026, 8, 16), + ); + expect(mem.utf8Of('fill_ups.csv'), isNot(contains(seed.fillUpId))); + expect(mem.utf8Of('vehicles.csv'), isNot(contains('Octavia'))); + expect(mem.utf8Of('vehicles.csv'), contains('Live')); + expect(mem.fileNames, isNot(contains('drafts.csv'))); + expect(mem.fileNames, isNot(contains('outbox.csv'))); + expect(mem.fileNames, isNot(contains('photo_refs.csv'))); + expect(mem.utf8Of('fill_ups.csv'), isNot(contains('should-not-export'))); + }); + + test('archived vehicles are exported (not treated as deleted)', () async { + final db = openInMemoryDb(); + addTearDown(db.close); + final id = await VehiclesRepository(db).create( + const VehicleDraft(name: 'Parked', fuelType: VehicleFuelType.gasoline), + ); + await VehiclesRepository(db).archive(id); + final snapshot = await takeExportSnapshot(db); + expect(snapshot.vehicles.single.id, id); + expect(snapshot.vehicles.single.archivedAt, isNotNull); + }); + + test('photo_refs and JPEG bytes never appear in the ZIP', () async { + final db = openInMemoryDb(); + addTearDown(db.close); + final vehicleId = await VehiclesRepository(db).create( + const VehicleDraft(name: 'Daily', fuelType: VehicleFuelType.gasoline), + ); + final draftId = await DraftsRepository(db).save( + DraftSnapshot(vehicleId: vehicleId), + ); + final sha = List.filled(64, 'a').join(); + await PhotoRefsRepository(db).insert( + draftId: draftId, + capturedAt: DateTime.utc(2026, 8, 1), + byteSize: 12, + sha256Hex: sha, + ttlExpiresAt: DateTime.utc(2026, 8, 31), + ); + + final dir = Directory.systemTemp.createTempSync('cestovni-export-photos-'); + addTearDown(() { + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + final file = await ExportService( + db: db, + sandboxDir: () => dir, + share: (_) async {}, + clock: () => DateTime.utc(2026, 8, 16, 12, 0, 0), + ).exportToFile(); + + final entries = readStoreZip(file.readAsBytesSync()); + expect(entries.keys.toList(), exportZipEntryNames); + expect(entries.keys.where((n) => n.contains('photos')), isEmpty); + for (final bytes in entries.values) { + expect(looksLikeJpeg(bytes), isFalse); + expect(looksLikePng(bytes), isFalse); + } + final joined = entries.values.map(String.fromCharCodes).join(); + expect(joined, isNot(contains(sha))); + expect(joined, isNot(contains(draftId))); + }); +} diff --git a/client/test/export/golden_zip_test.dart b/client/test/export/golden_zip_test.dart new file mode 100644 index 0000000..9cfd561 --- /dev/null +++ b/client/test/export/golden_zip_test.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cestovni/export/app_version.dart'; +import 'package:cestovni/export/derived.dart'; +import 'package:cestovni/export/export_service.dart'; +import 'package:cestovni/export/headers.dart'; +import 'package:cestovni/export/snapshot.dart'; +import 'package:cestovni/export/user_key_hash.dart'; +import 'package:cestovni/export/zip_sink.dart'; +import 'package:cestovni/photos/photo_export_guard.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../db/_harness.dart'; +import '_seed.dart'; +import 'zip_read.dart'; + +void main() { + test('golden snapshot: headers, cells, manifest stand-ins', () async { + final db = openInMemoryDb(); + addTearDown(db.close); + final seed = await seedGoldenExport(db); + final snapshot = await takeExportSnapshot(db); + final hash = userKeyHashFromSettingsId(seed.settings.id); + + final mem = MemoryZipSink(); + final exportedAt = DateTime.utc(2026, 8, 16, 12, 0, 0); + writeSnapshotToSink( + sink: mem, + snapshot: snapshot, + appVersion: kAppVersion, + exportedAt: exportedAt, + ); + + expect(mem.fileNames, exportZipEntryNames); + + final vehicles = csvRecords(mem.bytesOf('vehicles.csv')); + expect(vehicles.first, vehiclesCsvHeader); + final v = parseCsvRecord(vehicles[1]); + expect(v[0], seed.vehicleId); + expect(v[1], hash); + expect(v[2], 'Octavia'); + expect(v[8], '55000000'); + expect(v[9], '55.00'); + expect(v[11], '', reason: 'row_version is empty until M3'); + + final fills = csvRecords(mem.bytesOf('fill_ups.csv')); + expect(fills.first, fillUpsCsvHeader); + final f = parseCsvRecord(fills[1]); + expect(f[0], seed.fillUpId); + expect(f[5], '120000000'); + expect(f[6], metersToKmCsv(120000000)); + expect(f[7], metersToMiCsv(120000000)); + expect(f[8], '42000000'); + expect(f[9], volumeToLitersCsv(42000000)); + expect(f[10], volumeToGallonsCsv(42000000)); + expect(f[11], '6100'); + expect(f[12], centsToMajorCsv(6100)); + expect(f[14], 'true'); + expect(f[17], 'hello, "world"'); + expect(f[18], ''); + + final rules = csvRecords(mem.bytesOf('maintenance_rules.csv')); + expect(rules.first, maintenanceRulesCsvHeader); + final r = parseCsvRecord(rules[1]); + expect(r[0], seed.ruleId); + expect(r[4], '10000000', reason: 'cadence_km is meters, exported verbatim'); + expect(r[6], 'true'); + expect(r[7], 'every 10k km'); + + final events = csvRecords(mem.bytesOf('maintenance_events.csv')); + expect(events.first, maintenanceEventsCsvHeader); + final e = parseCsvRecord(events[1]); + expect(e[0], seed.eventId); + expect(e[12], 'oil'); + expect(e[13], 'Bosch, Praha'); + + final settingsLines = csvRecords(mem.bytesOf('settings.csv')); + expect(settingsLines.first, settingsCsvHeader); + final s = parseCsvRecord(settingsLines[1]); + expect(s[0], hash); + expect(s[4], 'UTC'); + expect(s[5], seed.vehicleId); + + final manifest = jsonDecode(mem.utf8Of('manifest.json')) as Map; + expect(manifest['photos_in_export'], photosInExport); + expect(manifest['photos_in_export'], isFalse); + expect(manifest['max_row_version_seen'], isNull); + expect(manifest['app_platform'], kExportAppPlatform); + expect(manifest['app_version'], kAppVersion); + expect(manifest['user_key_hash'], hash); + expect(manifest['row_counts']['vehicles'], 1); + expect(manifest['row_counts']['fill_ups'], 1); + expect(manifest['row_counts']['maintenance_rules'], 1); + expect(manifest['row_counts']['maintenance_events'], 1); + expect(manifest['row_counts']['settings'], 1); + + final readme = mem.utf8Of('README_export.txt'); + expect(readme, contains('cadence_km stores canonical METERS')); + expect(readme, contains('first 8 hex characters of SHA-256')); + expect(readme.contains('\r\n'), isTrue); + + final name = exportFilename(userKeyHash: hash, exportedAt: exportedAt); + expect( + name, + matches(RegExp(r'^cestovni_export_[0-9a-f]{8}_20260816_120000\.zip$')), + ); + expect(name, 'cestovni_export_${hash}_20260816_120000.zip'); + }); + + test('ExportService writes a valid STORE ZIP on disk', () async { + final db = openInMemoryDb(); + addTearDown(db.close); + await seedGoldenExport(db); + + final dir = Directory.systemTemp.createTempSync('cestovni-export-golden-'); + addTearDown(() { + if (dir.existsSync()) dir.deleteSync(recursive: true); + }); + + final shared = []; + final service = ExportService( + db: db, + sandboxDir: () => dir, + share: (path) async => shared.add(path), + clock: () => DateTime.utc(2026, 8, 16, 12, 0, 0), + ); + final file = await service.exportAndShare(); + expect(file.existsSync(), isTrue); + expect(file.path, isNot(contains('.tmp'))); + expect( + file.uri.pathSegments.last, + matches(RegExp(r'^cestovni_export_[0-9a-f]{8}_20260816_120000\.zip$')), + ); + expect(shared, [file.path]); + + final entries = readStoreZip(file.readAsBytesSync()); + expect(entries.keys.toList(), exportZipEntryNames); + for (final bytes in entries.values) { + expect(looksLikeJpeg(bytes), isFalse); + expect(looksLikePng(bytes), isFalse); + } + expect(entries.keys.any((n) => n.contains('photos')), isFalse); + }); +} diff --git a/client/test/export/headers_test.dart b/client/test/export/headers_test.dart new file mode 100644 index 0000000..4249561 --- /dev/null +++ b/client/test/export/headers_test.dart @@ -0,0 +1,53 @@ +import 'package:cestovni/export/headers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Locked to `docs/specs/export-v1.md` § A1. If this fails, the spec +/// and the assembler drifted — do not "fix" the test by re-deriving. +void main() { + test('vehicles.csv header matches export-v1 § A1', () { + expect( + vehiclesCsvHeader, + 'id,user_key_hash,name,make,model,year,vin,fuel_type,tank_capacity_uL,tank_capacity_L,archived_at_utc,row_version,updated_at_utc', + ); + }); + + test('fill_ups.csv header matches export-v1 § A1', () { + expect( + fillUpsCsvHeader, + 'id,user_key_hash,vehicle_id,filled_at_utc,filled_at_local,odometer_m,odometer_km,odometer_mi,volume_uL,volume_L,volume_gal,total_price_cents,total_price_major,currency_code,is_full,missed_before,odometer_reset,notes,row_version,updated_at_utc', + ); + }); + + test('maintenance_rules.csv header includes notes', () { + expect( + maintenanceRulesCsvHeader, + 'id,user_key_hash,vehicle_id,name,cadence_km,cadence_days,enabled,notes,row_version,updated_at_utc', + ); + }); + + test('maintenance_events.csv header includes category and shop', () { + expect( + maintenanceEventsCsvHeader, + 'id,user_key_hash,vehicle_id,rule_id,performed_at_utc,performed_at_local,odometer_m,odometer_km,odometer_mi,cost_cents,cost_major,currency_code,category,shop,notes,row_version,updated_at_utc', + ); + }); + + test('settings.csv header includes default_vehicle_id', () { + expect( + settingsCsvHeader, + 'user_key_hash,preferred_distance_unit,preferred_volume_unit,currency_code,timezone,default_vehicle_id,row_version,updated_at_utc', + ); + }); + + test('ZIP entry names are the spec file set in order', () { + expect(exportZipEntryNames, [ + 'manifest.json', + 'README_export.txt', + 'vehicles.csv', + 'fill_ups.csv', + 'maintenance_rules.csv', + 'maintenance_events.csv', + 'settings.csv', + ]); + }); +} diff --git a/client/test/export/module_purity_test.dart b/client/test/export/module_purity_test.dart new file mode 100644 index 0000000..b9fa91a --- /dev/null +++ b/client/test/export/module_purity_test.dart @@ -0,0 +1,97 @@ +/// Static guard on `client/lib/export/`: CSV / ZIP assembly stay pure +/// Dart so streaming behaviour can be tested without Flutter, Drift, or +/// a sandbox. Mirrors `test/photos/module_purity_test.dart`. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Files allowed to touch the file system, Drift, Flutter, or share. +const _bridgeFiles = { + // STORE ZIP writer (`dart:io` RandomAccessFile). + 'store_zip_sink.dart', + // Drift snapshot + CSV row mapping. + 'snapshot.dart', + // Flush + atomic rename + share_plus / path_provider. + 'export_service.dart', +}; + +const _forbiddenForPureFiles = [ + 'dart:io', + 'package:drift/', + 'package:flutter/', + 'package:path_provider/', + 'package:share_plus/', + 'package:cestovni/db/', +]; + +void main() { + final exportDir = _resolveExportDir(); + + test('export module directory is discoverable', () { + expect( + exportDir.existsSync(), + isTrue, + reason: 'client/lib/export/ must exist (looked at ${exportDir.path}).', + ); + }); + + final dartFiles = exportDir + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.dart')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + test('every pure export file avoids platform, file-system and Drift imports', + () { + final violations = []; + + for (final file in dartFiles) { + final basename = _basename(file.path); + if (_bridgeFiles.contains(basename)) continue; + + final contents = file.readAsStringSync(); + for (final forbidden in _forbiddenForPureFiles) { + if (contents.contains("import '$forbidden") || + contents.contains('import "$forbidden')) { + violations.add('$basename imports $forbidden'); + } + } + } + + expect( + violations, + isEmpty, + reason: 'Pure files in client/lib/export/ must not import the file ' + 'system, platform channels, Flutter, or the app DB. Add the file to ' + '_bridgeFiles only with a documented reason. Violations:\n - ' + '${violations.join("\n - ")}', + ); + }); + + test('every declared bridge file exists', () { + final present = dartFiles.map((f) => _basename(f.path)).toSet(); + + expect( + _bridgeFiles.difference(present), + isEmpty, + reason: 'a file listed in _bridgeFiles was renamed or removed — update ' + 'the purity invariant with it', + ); + }); +} + +Directory _resolveExportDir() { + for (final candidate in const ['lib/export', 'client/lib/export']) { + final dir = Directory(candidate); + if (dir.existsSync()) return dir.absolute; + } + return Directory('lib/export').absolute; +} + +String _basename(String path) { + final idx = path.lastIndexOf('/'); + return idx < 0 ? path : path.substring(idx + 1); +} diff --git a/client/test/export/outbox_test.dart b/client/test/export/outbox_test.dart new file mode 100644 index 0000000..361ba14 --- /dev/null +++ b/client/test/export/outbox_test.dart @@ -0,0 +1,61 @@ +import 'package:cestovni/db/repositories/fill_ups_repository.dart'; +import 'package:cestovni/db/repositories/outbox_repository.dart'; +import 'package:cestovni/db/repositories/vehicles_repository.dart'; +import 'package:cestovni/export/snapshot.dart'; +import 'package:cestovni/export/user_key_hash.dart'; +import 'package:cestovni/export/zip_sink.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../db/_harness.dart'; + +void main() { + test('manifest records three pending outbox mutations and a stable hash', + () async { + final db = openInMemoryDb(); + addTearDown(db.close); + final vehicleId = await VehiclesRepository(db).create( + const VehicleDraft(name: 'Daily', fuelType: VehicleFuelType.gasoline), + ); + final fills = FillUpsRepository(db); + for (var i = 0; i < 3; i++) { + await fills.create( + FillUpDraft( + vehicleId: vehicleId, + filledAt: DateTime.utc(2026, 8, 1, 10, i), + odometerM: 1000000 * (i + 1), + volumeUL: 40000000, + totalPriceCents: 5000, + currencyCode: 'EUR', + isFull: true, + ), + ); + } + + final ids = await OutboxRepository(db).pendingMutationIds(); + expect(ids, hasLength(3)); + final expectedHash = outboxPendingHash(ids); + + final snapshot = await takeExportSnapshot(db); + expect(snapshot.pendingMutationIds, unorderedEquals(ids)); + expect(outboxPendingHash(snapshot.pendingMutationIds), expectedHash); + + final mem = MemoryZipSink(); + writeSnapshotToSink( + sink: mem, + snapshot: snapshot, + appVersion: '0.0.1', + exportedAt: DateTime.utc(2026, 8, 16, 12), + ); + expect(mem.utf8Of('manifest.json'), contains('"outbox_pending_count": 3')); + expect(mem.utf8Of('manifest.json'), contains(expectedHash!)); + expect( + outboxPendingHash(ids), + expectedHash, + reason: 'hash is stable for a stable mutation_id set', + ); + }); + + test('empty outbox yields a null pending hash', () { + expect(outboxPendingHash(const []), isNull); + }); +} diff --git a/client/test/export/zip_read.dart b/client/test/export/zip_read.dart new file mode 100644 index 0000000..d3cffdb --- /dev/null +++ b/client/test/export/zip_read.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Minimal STORE-ZIP reader for CES-41 tests. Understands data +/// descriptors (GP bit 3) by taking sizes from the central directory. +Map readStoreZip(Uint8List bytes) { + if (bytes.length < 22) { + throw FormatException('too small to be a ZIP (${bytes.length} bytes)'); + } + var eocd = bytes.length - 22; + while (eocd >= 0 && _u32(bytes, eocd) != 0x06054b50) { + eocd--; + } + if (eocd < 0) throw FormatException('EOCD not found'); + final entries = _u16(bytes, eocd + 10); + final cdOffset = _u32(bytes, eocd + 16); + var pos = cdOffset; + final out = {}; + for (var i = 0; i < entries; i++) { + if (_u32(bytes, pos) != 0x02014b50) { + throw FormatException('bad central header at $pos'); + } + final size = _u32(bytes, pos + 20); + final nameLen = _u16(bytes, pos + 28); + final extraLen = _u16(bytes, pos + 30); + final commentLen = _u16(bytes, pos + 32); + final localOff = _u32(bytes, pos + 42); + final name = utf8.decode(bytes.sublist(pos + 46, pos + 46 + nameLen)); + if (_u32(bytes, localOff) != 0x04034b50) { + throw FormatException('bad local header for $name'); + } + final lName = _u16(bytes, localOff + 26); + final lExtra = _u16(bytes, localOff + 28); + final dataStart = localOff + 30 + lName + lExtra; + out[name] = Uint8List.fromList(bytes.sublist(dataStart, dataStart + size)); + pos += 46 + nameLen + extraLen + commentLen; + } + return out; +} + +int _u16(Uint8List b, int o) => b[o] | (b[o + 1] << 8); + +int _u32(Uint8List b, int o) => + b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24); + +/// Split a UTF-8 CSV (with BOM) into records. Trailing CRLF is dropped. +List csvRecords(Uint8List bytes) { + var text = utf8.decode(bytes); + if (text.startsWith('\uFEFF')) text = text.substring(1); + if (text.endsWith('\r\n')) { + text = text.substring(0, text.length - 2); + } + if (text.isEmpty) return const []; + return text.split('\r\n'); +} + +bool looksLikeJpeg(Uint8List bytes) => + bytes.length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + +bool looksLikePng(Uint8List bytes) => + bytes.length >= 4 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47; + +/// RFC 4180 field split for a single record (no embedded CRLF). +List parseCsvRecord(String line) { + final out = []; + final buf = StringBuffer(); + var inQuotes = false; + for (var i = 0; i < line.length; i++) { + final c = line[i]; + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length && line[i + 1] == '"') { + buf.write('"'); + i++; + } else { + inQuotes = false; + } + } else { + buf.write(c); + } + } else if (c == '"') { + inQuotes = true; + } else if (c == ',') { + out.add(buf.toString()); + buf.clear(); + } else { + buf.write(c); + } + } + out.add(buf.toString()); + return out; +} diff --git a/docs/product/delivery-plan-v1.md b/docs/product/delivery-plan-v1.md index 990ab8d..12641b7 100644 --- a/docs/product/delivery-plan-v1.md +++ b/docs/product/delivery-plan-v1.md @@ -12,20 +12,20 @@ Stage 5 exit (copied from workflow): **running build with test strategy tied to ## Current focus -**Recommended next coding work:** **[CES-41](https://linear.app/personal-interests-llc/issue/CES-41) Export ZIP** — the first M2 vertical, now that **CES-40 Photo pipeline** closed the last M1 one. The photo exclusion guard it needs already exists (`client/lib/photos/photo_export_guard.dart`). Parallel ops: **[CES-63](https://linear.app/personal-interests-llc/issue/CES-63)** iPhone install-doc + T1; **[CES-68](https://linear.app/personal-interests-llc/issue/CES-68)** Android APK anytime for demo distribution. +**Recommended next coding work:** **[CES-70](https://linear.app/personal-interests-llc/issue/CES-70) ZIP import** — device-to-device restore of the CES-41 archive (merge vs replace is a product lock; spec stub still TBD). **Do not start M3 (CES-42–45)** unless product redirects. Parallel ops: **[CES-63](https://linear.app/personal-interests-llc/issue/CES-63)** iPhone install-doc + T1; **[CES-68](https://linear.app/personal-interests-llc/issue/CES-68)** Android APK anytime for demo distribution. | Track | Issue | Why now | Done when | | ----- | ----- | ------- | --------- | -| **A — M2 export** | **[CES-41](https://linear.app/personal-interests-llc/issue/CES-41)** | First M2 vertical; all M1 verticals closed | Streaming ZIP + manifest per `export-v1.md`, photos excluded via the CES-40 guard | +| **A — M2 import** | **[CES-70](https://linear.app/personal-interests-llc/issue/CES-70)** | CES-41 export shipped; import is the matching restore path | Spec (merge vs replace) + restore of vehicles / fill-ups / maint / settings; photos never imported | | **B — M-dist (ops)** | **[CES-63](https://linear.app/personal-interests-llc/issue/CES-63)** | CI deploy live; install doc + phone T1 still open | [`install-ios.md`](install-ios.md) finalized; iPhone T1 on **CES-62** | -**Shipped this cycle:** **CES-67** (Maintenance tab + History Maint chip) and **CES-40** (receipt photo pipeline + Log attach UI). **M2/M3** stay on the spine. +**Shipped this cycle:** **CES-41** (Export ZIP — Settings → Export data, STORE ZIP, photos excluded). Earlier: **CES-67** (Maintenance) and **CES-40** (receipt photos). **M3** stays on the spine after import, not before. -**Prompts (executed):** [`prompts/ces-67-maintenance.md`](prompts/ces-67-maintenance.md) · [`prompts/ces-40-photo-pipeline.md`](prompts/ces-40-photo-pipeline.md) +**Prompts (executed):** [`prompts/ces-67-maintenance.md`](prompts/ces-67-maintenance.md) · [`prompts/ces-40-photo-pipeline.md`](prompts/ces-40-photo-pipeline.md) · [`prompts/ces-41-export.md`](prompts/ces-41-export.md) -**Prompt (next coding):** [`prompts/ces-41-export.md`](prompts/ces-41-export.md) — READY. Do **not** start M3 (CES-42–45) until export ships. +**Prompt (next coding):** **CES-70 import** — needs a spec (merge vs replace, id collisions). Do **not** start M3 (CES-42–45) until product redirects. -**Manual checklist (CES-40):** [`ces-40-manual-test.md`](ces-40-manual-test.md) — needs a physical Android device with a camera; not runnable in CI or on the Cloud VM. +**Manual checklist (CES-40):** [`ces-40-manual-test.md`](ces-40-manual-test.md) — needs a physical Android device with a camera; not runnable in CI or on the Cloud VM. CES-41 device timing (10k rows / 30 s) rides along with **[CES-68](https://linear.app/personal-interests-llc/issue/CES-68)**. --- @@ -96,7 +96,7 @@ Rollup mirrors milestones **M0→M5** and verticals **CES-36..CES-47** ([epic CE ### M1 — Local logging + math -- 🟩 **M1 rollup (closed 2026-08-16)** — offline logging usable end-to-end without a server. Log / History / vehicles / settings / metrics / Maint / photos on `main` (`bb1d5d5`). Next coding is **CES-41** (M2), not another M1 vertical. +- 🟩 **M1 rollup (closed 2026-08-16)** — offline logging usable end-to-end without a server. Log / History / vehicles / settings / metrics / Maint / photos on `main` (`bb1d5d5`). **CES-41 export shipped** (M2); next coding is **CES-70** import, not M3. - 🟩 **CES-38 — Consumption math + golden tests** — **Done in repo on `main`** (2026-05): `client/lib/consumption/`, auto-discovery runner over 20 `tests/math/fixtures/`, module-purity test, validation wired in Log/History save paths. Follow-ups: **CES-51** / **CES-52** (non-blocking). - 🟩 **CES-39 — Fill-up + vehicle UI (core)** — **Done** (repo 2026-05, Linear 2026-07-17): repos + vehicle CRUD + Log/History UI; 121+ tests. Out of scope → **CES-65** / **CES-66** / **CES-67** / **CES-40**. - 🟩 **CES-57 — Settings prefs + default vehicle** — **Done** (PR #9, Linear Done). Display wiring closed as **CES-65**. @@ -108,12 +108,12 @@ Rollup mirrors milestones **M0→M5** and verticals **CES-36..CES-47** ([epic CE ### M2 — Export -- 🟥 **M2 rollup** — export before backup exists. - - 🟥 **CES-41 — Export ZIP** — **Todo / next coding.** Prompt [`prompts/ces-41-export.md`](prompts/ces-41-export.md). Reuse `PhotoExportGuard`. Fixture-driven tests in `tests/export/` (planned). +- 🟩 **M2 rollup** — on-device ZIP export exists; import is **CES-70** (next coding, spec TBD). + - 🟩 **CES-41 — Export ZIP** — **Done in repo** (`client/lib/export/`, Settings → Export data). STORE ZIP (no `archive` write path), A1 headers, `photos_in_export: false`, streaming test over 1 000 lazy rows. Device 10k timing deferred to CES-68. Tests: `client/test/export/` + pointer [`tests/export/README.md`](../../tests/export/README.md). ### M3 — Backup + restore -- 🟥 **M3 rollup** — ADR 002 + `sync-protocol` closed in running code + tests. *(Fill-up outbox gate slice + `server/dev-sync-stub/` already on `main`. Remaining: real Postgres/RLS, production API, vehicles/settings/maint enqueue, restore UX. **Do not start until CES-41 ships.**)* +- 🟥 **M3 rollup** — ADR 002 + `sync-protocol` closed in running code + tests. *(Fill-up outbox gate slice + `server/dev-sync-stub/` already on `main`. Remaining: real Postgres/RLS, production API, vehicles/settings/maint enqueue, restore UX. **Do not start until CES-70 is scoped or product redirects.**)* - 🟥 **CES-42 — Server Postgres + RLS migrations** — `[tests/rls/](../../tests/rls/)`, `[ci/rls-regression.yml](../../ci/rls-regression.yml)`. - 🟥 **CES-43 — Server API + auth** — contract tests `[tests/contract/](../../tests/contract/)` (managed + self-host). Dev stub on `main` is **not** this ticket. - 🟥 **CES-44 — Backup / outbox (client)** — fill-up enqueue/flush **gate slice on `main`**; vehicles/settings/maint still do not enqueue. Depends on CES-37 + real CES-43. @@ -172,7 +172,7 @@ Epic: **[CES-35 Delivery v1](https://linear.app/personal-interests-llc/issue/CES | 3 | [CES-38](https://linear.app/personal-interests-llc/issue/CES-38) | Consumption math module + golden tests | M1 | `docs/specs/consumption-math.md` | CES-37 | low | **Done in repo on `main`** — `client/lib/consumption/`, `client/test/consumption/` (20 fixtures), phase 2 purity + CI coverage (2026-05) | | 4 | [CES-39](https://linear.app/personal-interests-llc/issue/CES-39) | Fill-up + vehicle UI (core logging) | M1 | `docs/specs/data-model.md` + `docs/product/PRODUCT_BRIEF.md` + `docs/product/ux/cestovni-views.md` + `docs/product/ux/DATA_CONTRACTS.md` + `docs/product/ux/DELIVERY_ACCEPTANCE.md` + `docs/product/ux/UX_IMPLEMENTATION_GAPS.md` | CES-37, CES-38 (CES-53–CES-56 **Done** in repo) | high | **Done in repo on `main`** (2026-05) — repos + vehicle CRUD (phases 1–2) + Log/History UI (phase 3); 121+ widget/DB tests. Out of CES-39 scope: Metrics/Maint tabs, photo (**CES-40**), Log/History prefs *display* follow-on (post-CES-57) | | 5 | [CES-40](https://linear.app/personal-interests-llc/issue/CES-40) | Photo pipeline implementation | M1 | `docs/specs/photo-pipeline.md` | CES-37 | medium | **Done** (2026-08-16, PR #18) — `client/lib/photos/` + Log attach UI; isolate decode; 47 tests in `client/test/photos/` + `log_page_photos_test.dart`. Manual device checklist: [`ces-40-manual-test.md`](ces-40-manual-test.md) (not yet run). | -| 6 | [CES-41](https://linear.app/personal-interests-llc/issue/CES-41) | Export ZIP | M2 | `docs/specs/export-v1.md` | CES-37 | medium | **Todo / next coding** — prompt [`prompts/ces-41-export.md`](prompts/ces-41-export.md). Photo exclusion guard already on `main`. | +| 6 | [CES-41](https://linear.app/personal-interests-llc/issue/CES-41) | Export ZIP | M2 | `docs/specs/export-v1.md` | CES-37 | medium | **Done in repo** — `client/lib/export/`, Settings Export, `client/test/export/`. Prompt [`prompts/ces-41-export.md`](prompts/ces-41-export.md). | | 7 | [CES-42](https://linear.app/personal-interests-llc/issue/CES-42) | Server Postgres + RLS migrations | M3 | `docs/specs/data-model.md` + `docs/specs/adr/001-backend-api-boundary.md` | — | medium | — | | 8 | [CES-43](https://linear.app/personal-interests-llc/issue/CES-43) | Server API + auth | M3 | `docs/specs/adr/001-backend-api-boundary.md` + `docs/specs/sync-protocol.md` | CES-42 | high | **Backlog** — `server/dev-sync-stub/` (fill-ups only) is **not** CES-43. | | 9 | [CES-44](https://linear.app/personal-interests-llc/issue/CES-44) | Backup / outbox (client) | M3 | `docs/specs/adr/002-backup-sync-layer.md` + `docs/specs/sync-protocol.md` | CES-37, CES-43 | high | **Gate slice on `main`** (fill-up enqueue/flush). Remaining: vehicles/settings/maint + real API. | @@ -197,7 +197,7 @@ Epic: **[CES-35 Delivery v1](https://linear.app/personal-interests-llc/issue/CES | RLS / roles | `[data-model.md](../specs/data-model.md)`, [ADR 001](../specs/adr/001-backend-api-boundary.md) | SQL regression | `[tests/rls/](../../tests/rls/)`, `[tests/roles/](../../tests/roles/)`, `[ci/rls-regression.yml](../../ci/rls-regression.yml)` | | API contract | [ADR 001](../specs/adr/001-backend-api-boundary.md), `[sync-protocol.md](../specs/sync-protocol.md)` | Contract tests against managed + self-host | `[tests/contract/](../../tests/contract/)` | | Backup / restore | `[sync-protocol.md](../specs/sync-protocol.md)`, [ADR 002](../specs/adr/002-backup-sync-layer.md) | Integration (client+server) | `tests/backup/` (to land in M3) | -| Export shape | `[export-v1.md](../specs/export-v1.md)` | Fixture-driven ZIP assembly + manifest assertions | `tests/export/` (to land in M2) | +| Export shape | `[export-v1.md](../specs/export-v1.md)` | Fixture-driven ZIP assembly + manifest assertions | `client/test/export/` (pointer [`tests/export/README.md`](../../tests/export/README.md)) | | Telemetry drift | `[telemetry-allowlist.md](../specs/telemetry-allowlist.md)` + `[telemetry-events.v1.yaml](../specs/telemetry-events.v1.yaml)` | YAML + client-source scanner in CI | `[ci/telemetry-gate.py](../../ci/telemetry-gate.py)` + `[ci/telemetry-gate.yml](../../ci/telemetry-gate.yml)` | | Migration rollback | `[TBD-migration-rollback.md](../specs/TBD-migration-rollback.md)` (stub) | Down-migration fixtures | `tests/migrations/` (to land in M5) | @@ -209,8 +209,8 @@ Epic: **[CES-35 Delivery v1](https://linear.app/personal-interests-llc/issue/CES Leading emoji tracks **exit** state (independent of per-vertical RYG above, but should converge at stage close). - 🟩 Every vertical above has a Linear issue with a `Spec:` line. *(CES-35 epic + CES-36..CES-47; M0 follow-ups CES-48/49/50 created 2026-04-22, linked to their downstream verticals via `blocks`.)* -- 🟨 M0 + M1 land: offline app runs, fill-up works end-to-end, golden math tests green. *(**M0 closed**. **M1 closed** — CES-38/39/57 + CES-65/66/67 + CES-40 all Done. **Next:** CES-41 export — see [Current focus](#current-focus).)* -- 🟥 M2 lands: ZIP export round-trips for a representative fixture. +- 🟨 M0 + M1 land: offline app runs, fill-up works end-to-end, golden math tests green. *(**M0 closed**. **M1 closed** — CES-38/39/57 + CES-65/66/67 + CES-40 all Done. **CES-41 export shipped.** **Next:** CES-70 import — see [Current focus](#current-focus).)* +- 🟩 M2 lands: ZIP export round-trips for a representative fixture. *(CES-41. Import is CES-70.)* - 🟥 M3 lands: backup/restore passes `tests/contract/` + integration fixtures; RLS regression green. - 🟥 M4 lands: `ci/telemetry-gate.`* green; client emits only allow-listed events. - 🟥 M5 lands: migration rollback spec real (not stub); rollback tooling proven against fixture. @@ -233,5 +233,5 @@ When every exit bullet above is 🟩, Stage 5 exit is met — flip workflow perc - `[launch-copy-v1.md](launch-copy-v1.md)` — Stage 4 copy; feeds Stage 6. - `[../specs/platform-compliance-v1.md](../specs/platform-compliance-v1.md)` — compliance posture already signed off. -*Last updated: 2026-08-16 — hygiene after `main` `bb1d5d5` (CES-40 merge). Android M1 closed. Next coding: CES-41.* +*Last updated: 2026-08-16 — CES-41 Export ZIP. Next coding: CES-70 import.* diff --git a/docs/product/prompts/ces-41-export.md b/docs/product/prompts/ces-41-export.md index b205d29..38f11f0 100644 --- a/docs/product/prompts/ces-41-export.md +++ b/docs/product/prompts/ces-41-export.md @@ -1,8 +1,10 @@ # Cursor execution prompt — CES-41 Export ZIP -> **Status: READY** (2026-08-16). Handoff after **CES-40** photos merged to `main` (PR #18). -> Linear **[CES-41](https://linear.app/personal-interests-llc/issue/CES-41)** — **Todo** → set **In Progress** when you start. -> Product direction: **do CES-41 next.** Do **not** pick up M3 (CES-42–45), CES-51, or PWA-lite unless the user explicitly redirects. +> **Status: EXECUTED** (2026-08-16) — shipped on `cursor/ces-41-export-9a29` ([PR #21](https://github.com/JMNofziger/cestovni/pull/21)). +> Linear **[CES-41](https://linear.app/personal-interests-llc/issue/CES-41)**. +> Next coding focus: **[CES-70](https://linear.app/personal-interests-llc/issue/CES-70)** ZIP import — see [`../delivery-plan-v1.md`](../delivery-plan-v1.md) §Current focus. +> +> Kept as an archive of the execution brief (do not re-run). **Branch:** cut `cursor/ces-41-export-` from **`main`** **Linear:** [CES-41](https://linear.app/personal-interests-llc/issue/CES-41) diff --git a/docs/product/ux/cestovni-views.md b/docs/product/ux/cestovni-views.md index ad3bc9d..184d04e 100644 --- a/docs/product/ux/cestovni-views.md +++ b/docs/product/ux/cestovni-views.md @@ -158,7 +158,7 @@ Screenshot: `screenshots/dark-midnight/settings.png` - Preferences currently implemented in `settings_page.dart` (distance/volume/currency/timezone). - Vehicle CRUD is implemented across `vehicle_list_page.dart`, `vehicle_detail_page.dart`, and `vehicle_form_page.dart`. -- Export is **not** in Settings yet — next coding **CES-41** (`prompts/ces-41-export.md`). Destructive reset remains Later. +- Export is **shipped (CES-41)** — Settings → Backup → **Export data** (`client/lib/export/`, photos excluded). Destructive reset remains Later. **Scope gate** diff --git a/tests/export/README.md b/tests/export/README.md new file mode 100644 index 0000000..fb3f1fb --- /dev/null +++ b/tests/export/README.md @@ -0,0 +1,25 @@ +# tests/export — pointer + +`docs/specs/export-v1.md` § Test expectations places export tests in +`tests/export/`. They live in the Flutter client so `flutter test` picks +them up without a second runner: + +| Spec expectation | Implementation | +|------------------|----------------| +| Golden ZIP + A1 headers | [`client/test/export/golden_zip_test.dart`](../../client/test/export/golden_zip_test.dart) + [`headers_test.dart`](../../client/test/export/headers_test.dart) | +| CSV rules (BOM, CRLF, quoting, nulls, bools) | [`client/test/export/csv_test.dart`](../../client/test/export/csv_test.dart) | +| Atomicity (failed write leaves no file) | [`client/test/export/atomicity_test.dart`](../../client/test/export/atomicity_test.dart) | +| Photos excluded | [`client/test/export/exclusions_test.dart`](../../client/test/export/exclusions_test.dart) + [`assembler_test.dart`](../../client/test/export/assembler_test.dart) + CES-40 [`no_upload_invariant_test.dart`](../../client/test/photos/no_upload_invariant_test.dart) | +| Outbox pending count + hash | [`client/test/export/outbox_test.dart`](../../client/test/export/outbox_test.dart) | +| Streaming (not a 10k device timing gate) | [`client/test/export/assembler_test.dart`](../../client/test/export/assembler_test.dart) — `CountingZipSink` over a lazy 1 000-row iterable | +| Module purity | [`client/test/export/module_purity_test.dart`](../../client/test/export/module_purity_test.dart) | + +**Not in CI (per `export-v1.md` § A4):** 10 000-row / 30 s / 10 MB device timing. That pass moves to [CES-68](https://linear.app/personal-interests-llc/issue/CES-68). + +**Not in this folder:** ZIP import ([CES-70](https://linear.app/personal-interests-llc/issue/CES-70)). + +Run them with: + +```bash +cd client && flutter test --no-pub test/export/ test/app/settings_page_test.dart +``` diff --git a/tests/photos/README.md b/tests/photos/README.md index df1733f..3958de1 100644 --- a/tests/photos/README.md +++ b/tests/photos/README.md @@ -9,7 +9,7 @@ tests in `tests/photos/`. They live in the Flutter client instead, so | 1. EXIF strip | [`client/test/photos/photo_processing_test.dart`](../../client/test/photos/photo_processing_test.dart) | | 2. TTL purge | [`client/test/photos/photo_ttl_test.dart`](../../client/test/photos/photo_ttl_test.dart) + [`photo_service_test.dart`](../../client/test/photos/photo_service_test.dart) §"cleanup sweep" | | 3. Orphan handling | [`client/test/photos/photo_service_test.dart`](../../client/test/photos/photo_service_test.dart) §"cleanup sweep" | -| 4. Export exclusion | [`client/test/photos/no_upload_invariant_test.dart`](../../client/test/photos/no_upload_invariant_test.dart) — guard + test only; the ZIP itself is CES-41 | +| 4. Export exclusion | [`client/test/photos/no_upload_invariant_test.dart`](../../client/test/photos/no_upload_invariant_test.dart) — guard; ZIP assertions in [`client/test/export/`](../../client/test/export/) (CES-41) | | 5. Sandbox backup disabled | `android:allowBackup="false"` in [`client/android/app/src/main/AndroidManifest.xml`](../../client/android/app/src/main/AndroidManifest.xml); **not** covered by an automated test (needs a device — see the manual checklist) | Also here, beyond the spec list: